diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d1fbe0d..cd8ac81 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,116 +1,130 @@ -# LUME - Copilot Instructions +# LUME - AI LED Strip Controller -## Project Overview - -**LUME** is an ESP32-S3 LED controller (LILYGO T-Display S3) for WS2812B LED strips with AI-generated effects. Three control paths: web UI, AI prompts (OpenRouter API), and sACN/E1.31 DMX protocol. Built with PlatformIO/Arduino framework. +ESP32-S3 + FastLED firmware with REST API, sACN/E1.31, MQTT, and segment-based LED control. **Access:** `http://lume.local` | **AP Mode:** `LUME-Setup` (password: `ledcontrol`) -## Architecture (v2) - -**Component Boundaries:** -- `main.cpp`: WiFi setup (dual AP/STA), async web server routes, OTA, event loop coordination, watchdog -- `core/controller.*`: LumeController - owns LED array, segments, protocols, frame updates -- `core/segment.*`: Segment class with effect binding, scratchpad, and parameters -- `core/effect_registry.h`: Effect function registry with metadata -- `effects/*.cpp`: One file per effect (solid, rainbow, fire, confetti, gradient, pulse) -- `protocols/sacn.*`: sACN/E1.31 protocol adapter using Protocol interface -- `anthropic_client.*`: FreeRTOS task for async LLM calls, JSON effect spec parsing -- `storage.*`: NVS (Non-Volatile Storage) wrapper for config/scenes/LED state persistence -- `web_ui.h`: Single-file embedded HTML/CSS/JS (PROGMEM constant, ~1200 lines) -- `constants.h`: All magic numbers centralized (timeouts, buffer sizes, ports, limits) -- `logging.h`: Structured logging with levels (DEBUG/INFO/WARN/ERROR) and component tags +## Architecture Overview + +**Single-Writer Model:** `LumeController` owns the LED buffer (`CRGB leds[]`). All mutations flow through it: +- Main loop calls `controller.update()` at ~60 FPS +- Web handlers/protocols enqueue commands or use atomic buffers +- Effects are pure functions writing to their segment's `SegmentView` **Data Flow:** ``` -Web UI → JSON POST → main.cpp handler → lume::controller → Segment → Effect -AI Prompt → anthropic_client task → JSON spec → applyEffectSpec() → Segment -sACN network → SacnProtocol → ProtocolBuffer → controller.update() → FastLED +Web UI → JSON POST → API handlers → controller.enqueueCommand() → Segment → Effect +sACN/MQTT → Protocol implementations → ProtocolBuffer (atomic) → controller.update() ``` -**Critical: Async Body Handling Pattern** -ESP async web server requires buffering request bodies manually. Every POST handler MUST: -1. Validate `total > MAX_REQUEST_BODY_SIZE` at `index == 0` → return 413 -2. Accumulate chunks in global buffer (e.g., `configBodyBuffer`) -3. Process only when `index + len >= total` +**Key Components:** +- [src/core/controller.h](src/core/controller.h) - Orchestrates segments, frame timing, protocols +- [src/core/segment.h](src/core/segment.h) - LED range + effect binding + 512-byte scratchpad +- [src/core/effect_registry.h](src/core/effect_registry.h) - Self-registering effects with metadata +- [src/network/server.cpp](src/network/server.cpp) - Route registration and WebSocket state sync -## Build & Deployment +## Adding New Effects -```bash -pio run -t upload # First flash via USB -pio device monitor # 115200 baud, includes ESP32 exception decoder +Create `src/effects/youreffect.cpp`: + +```cpp +#include "../core/effect_registry.h" + +namespace lume { + +void effectYourEffect(SegmentView& view, const EffectParams& params, + uint32_t frame, bool firstFrame) { + // frame for timing (use with beatsin8, etc.) + // firstFrame = true when scratchpad was reset + for (uint16_t i = 0; i < view.size(); i++) { + view[i] = ColorFromPalette(params.palette, i + frame); + } +} + +// Choose macro based on parameter usage: +// REGISTER_EFFECT_PALETTE - palette + speed +// REGISTER_EFFECT_COLORS - primary + secondary colors + speed +// REGISTER_EFFECT_ANIMATED - speed + intensity +REGISTER_EFFECT_PALETTE(effectYourEffect, "youreffect", "Your Effect"); + +} // namespace lume ``` -**OTA Updates:** Set `upload_port = ` in `platformio.ini` (password: `ledcontrol`) +Registration macros set `usesSpeed`, `usesPalette`, etc. flags that the Web UI reads to show/hide controls. See [docs/ADDING_EFFECTS.md](docs/ADDING_EFFECTS.md) for full macro table. + +## API Handler Pattern -## Development Secrets +API handlers in `src/api/` follow this structure: -Create `src/secrets.h` (gitignored) from `secrets.h.example`: ```cpp -#define DEV_WIFI_SSID "YourNetwork" -#define DEV_WIFI_PASSWORD "YourPass" -#define DEV_API_KEY "sk-or-..." -#define DEV_OPENROUTER_MODEL "anthropic/claude-3-haiku" -#define DEV_LED_COUNT 160 -#define DEV_DEFAULT_BRIGHTNESS 128 +// Static buffer for async body accumulation +static String bodyBuffer; + +void handleEndpointPost(AsyncWebServerRequest* request, uint8_t* data, + size_t len, size_t index, size_t total) { + if (index == 0 && !checkAuth(request)) { + sendUnauthorized(request); + return; + } + // Accumulate body, process when complete +} ``` -## Key Patterns +Routes are registered in [src/network/server.cpp](src/network/server.cpp) with the `.onBody()` pattern for POST requests. -**Logging:** Use structured macros, not `Serial.print()`: -```cpp -LOG_INFO(LogTag::WIFI, "Connected! IP: %s", WiFi.localIP().toString().c_str()); -LOG_WARN(LogTag::LED, "Failed to apply effect: %s", errorMsg.c_str()); -LOG_DEBUG(LogTag::SACN, "Uni %d: %d pkts", universe, count); -``` -Tags: `MAIN`, `WIFI`, `LED`, `AI`, `SACN`, `WEB`, `OTA`, `STORAGE` +## Build & Deploy -**Constants:** All values in `constants.h`. Never inline magic numbers: -```cpp -constexpr uint32_t SACN_DATA_TIMEOUT_MS = 5000; -constexpr size_t MAX_REQUEST_BODY_SIZE = 16384; -constexpr uint32_t PROMPT_RATE_LIMIT_MS = 3000; +```bash +pio run -t upload # USB upload (first flash) +pio run -t upload --upload-port lume.local # OTA after initial flash +pio run -t uploadfs # Upload LittleFS web UI assets ``` -**Rate Limiting:** `/api/prompt` has 3-second cooldown to prevent API key exhaustion. +**Configuration:** Hardware pin/limits in [src/constants.h](src/constants.h). Dev credentials in `src/secrets.h` (copy from `secrets.h.example`). -**sACN Priority:** When protocol data flows, LED updates skip normal effects. Timeout after 5s returns to effects. +**OTA password:** Default `ledcontrol`, or the auth token if configured in web UI. -**LED Pin:** Set `LED_DATA_PIN` in `constants.h`. Compile-time only (FastLED requirement). +## Web UI Development -**OTA Safety:** LEDs off during OTA, watchdog disabled to prevent timeout during long uploads. +Frontend assets live in `data/` and are served via LittleFS: +- Edit `data/index.html`, `data/assets/app.js`, `data/assets/app.css` +- Run `pio run -t uploadfs` to push changes to device +- Firmware must be flashed first; `uploadfs` only updates the filesystem partition -**API Route Order:** More specific routes registered FIRST (`/api/prompt/apply` before `/api/prompt`). +## Critical Conventions -## Common Tasks +- **Namespace:** All core code in `namespace lume {}` +- **Global singleton:** `lume::controller` - access via `extern` declarations +- **Config persistence:** `Storage` class wraps NVS. Use `storage.saveConfig()` +- **Thread safety:** Protocol data uses `ProtocolBuffer` with atomic flags +- **Effect state:** Use segment scratchpad via `getScratchpad()`, not static variables +- **Logging:** Use macros from [src/logging.h](src/logging.h): `LOG_INFO()`, `LOG_ERROR()`, etc. +- **Constants:** All magic numbers in [src/constants.h](src/constants.h) with `_MS`, `_SIZE` suffixes +- **Route order:** Register specific routes FIRST (`/api/prompt/apply` before `/api/prompt`) -**Add new effect:** -1. Create `src/effects/myeffect.cpp` -2. Implement effect function with signature: `void effectMyEffect(SegmentView&, const EffectParams&, uint32_t frame, bool firstFrame)` -3. Register with `REGISTER_EFFECT("myeffect", "My Effect", EffectCategory::Animated, effectMyEffect)` -4. Include in `effects/effects.h` +## Hardware & Runtime Notes -**Add new constant:** Always in `constants.h` with descriptive name (e.g., `_MS`, `_SIZE` suffix) +- **LED Pin:** `LED_DATA_PIN` in constants.h (compile-time only, FastLED requirement) +- **Power:** External 5V supply (~60mA/LED at full white). ESP32 GND must connect to strip GND. +- **Watchdog:** 30s timeout auto-resets if main loop hangs; disabled during OTA +- **sACN Priority:** When protocol data flows, effects are skipped. 5s timeout returns to effects. -**Add POST handler:** Follow the body buffering pattern with size validation at `index == 0` +## Development Workflow -## Hardware Notes +**Test immediately after implementation.** Use curl or the shell scripts in `test/` to verify changes before moving on. Update or add docs when functionality is confirmed working. -- **GPIO 21:** Default LED data pin (configured in `constants.h`, compile-time only) -- **Power:** External 5V supply (~60mA/LED). ESP32 GND must connect to strip GND. -- **Watchdog:** 30s timeout auto-resets if main loop hangs -- **PSRAM:** Enabled (`-DBOARD_HAS_PSRAM`) +```bash +# Quick connectivity check +curl http://lume.local/health + +# Test effect change +curl -X PUT http://lume.local/api/v2/segments/0 \ + -H "Content-Type: application/json" \ + -d '{"effect":"fire","speed":150}' +``` -## API Quick Reference +See [test/](test/) for shell scripts covering API endpoints. -Base URL: `http://` (AP mode: `http://192.168.4.1`) +## Known Residuals -| Endpoint | Method | Body Example | -|----------|--------|--------------| -| `/api/segments` | GET | Returns all segments, effects, capabilities | -| `/api/pixels` | POST | `{"fill": [255,0,0]}` or `{"rgb": [r,g,b,...]}` | -| `/api/prompt` | POST | `{"prompt": "warm sunset fading to purple"}` | -| `/api/prompt/status` | GET | Returns: `idle\|queued\|running\|done\|error` | -| `/api/prompt/apply` | POST | Applies last generated spec | -| `/api/scenes` | POST | `{"name": "Sunset", "spec": "{...}"}` | -| `/api/scenes/{id}/apply` | POST | Load saved scene | +Some v1 artifacts remain (e.g., `src/api/scenes.cpp` uses old format, some constants reference removed `anthropic_client`). These are marked with TODOs and will be cleaned up as features are reimplemented. Check `docs/archive/` for historical reference. diff --git a/README.md b/README.md index 89123c7..05e827f 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,22 @@ ESP32-S3 + FastLED firmware with modern Web UI, API, sACN, OTA, and natural-lang --- +## 💡 Why LUME? + + +LUME brings **AI-powered control** to your LED strips without sacrificing flexibility. Whether you want to say "make it look like a campfire" or precisely configure sACN universes, LUME handles both. + +- **Bring your favorites** — Port effects from WLED, write new ones, or use the built-in collection. You can also add new effects using the pre-written Copilot prompt (see [ADDING_EFFECTS.md](docs/ADDING_EFFECTS.md)). +- **API-first design** — Control via REST, MQTT, sACN, or natural language +- **Hackable** — Clean C++ codebase with effect registration macros and metadata that make each effect's parameters and UI behavior obvious from the code itself + +--- + ## ✨ Features | Category | What You Get | |----------|--------------| -| 🤖 **AI Effects** | Describe effects in natural language via OpenRouter API | +| 🤖 **AI Effects** | Describe effects in natural language via Anthropic Claude | | 🎨 **23 Built-in Effects** | Rainbow, Fire, Confetti, Meteor, Twinkle, Candle, Gradient, Pulse... | | 🔲 **Segments** | Split your strip into independent zones with different effects | | 🎨 **Color Palettes** | 12 palettes: Ocean, Lava, Sunset, Forest, Party... | @@ -62,7 +73,7 @@ That's it. No config files needed. 1. **Power on** your ESP32-S3 board 2. **Connect** to WiFi network `LUME-Setup` (password: `ledcontrol`) 3. **Open** `http://192.168.4.1` -4. **Configure** your home WiFi, LED count, and [OpenRouter API key](https://openrouter.ai/) +4. **Configure** your home WiFi, LED count, and [Anthropic API key](https://console.anthropic.com/) 5. **Done!** Access via `http://lume.local` > 💾 **Your settings are saved to flash memory** and survive firmware updates. You only need to configure once. @@ -120,7 +131,8 @@ Connect professional lighting software like QLC+, xLights, or TouchDesigner. | Doc | What's Inside | |-----|---------------| | [Hardware Setup](docs/HARDWARE.md) | Wiring, power calculation, GPIO pins | -| [API Reference](docs/API.md) | All REST endpoints with examples | +| [API Reference](docs/API_V2.md) | All REST endpoints with examples | +| [Adding Effects](docs/ADDING_EFFECTS.md) | Guide to creating custom LED effects | | [sACN Guide](docs/SACN.md) | E1.31 protocol setup and Python examples | | [MQTT Guide](docs/MQTT.md) | Home Assistant, Node-RED, topic structure | | [Development](docs/DEVELOPMENT.md) | Architecture, building, contributing | @@ -142,12 +154,22 @@ See [Hardware Setup](docs/HARDWARE.md) for power calculations and GPIO configura --- -## 🧪 Built With +## � Contributing + +**Love WLED effects?** Bring them over! Check out [ADDING_EFFECTS.md](docs/ADDING_EFFECTS.md) for the porting guide. The effect system is designed to be simple and self-documenting. + +**Ideas for new features?** Open an issue or PR. This project is young and welcomes contributions! + +**Found a bug?** Please report it. Include your board model, firmware version, and steps to reproduce. + +--- + +## �🧪 Built With - [FastLED](https://fastled.io/) - LED control library - [ESPAsyncWebServer](https://github.com/me-no-dev/ESPAsyncWebServer) - Async web server - [ArduinoJson](https://arduinojson.org/) - JSON parsing -- [OpenRouter](https://openrouter.ai/) - AI API access +- [Anthropic Claude](https://anthropic.com/) - AI natural language control --- @@ -155,9 +177,10 @@ See [Hardware Setup](docs/HARDWARE.md) for power calculations and GPIO configura This project is in active development. On the horizon: -- 📊 More effects (porting favorites from the community) +- 📊 More effects and palettes - 🔲 2D matrix support - 🎛️ Physical button controls +- 🎬 Scene presets and scheduling - 🔮 Matter/Thread support --- diff --git a/data/assets/app.css b/data/assets/app.css new file mode 100644 index 0000000..475eb96 --- /dev/null +++ b/data/assets/app.css @@ -0,0 +1,596 @@ + :root { + --bg: #0a0a0a; + --card: #18181b; + --surface: #27272a; + --card-hover: #27272a; + --border: #27272a; + --text: #fafafa; + --text-muted: #a1a1aa; + --primary: #3b82f6; + --primary-hover: #2563eb; + --success: #22c55e; + --error: #ef4444; + --warning: #f59e0b; + --radius: 8px; + } + + [data-theme="light"] { + --bg: #ffffff; + --card: #f9fafb; + --surface: #ffffff; + --card-hover: #f3f4f6; + --border: #e5e7eb; + --text: #0a0a0a; + --text-muted: #6b7280; + --primary: #3b82f6; + --primary-hover: #2563eb; + --success: #22c55e; + --error: #ef4444; + --warning: #f59e0b; + --radius: 8px; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + min-height: 100vh; + padding: 16px; + } + + .container { + max-width: 800px; + margin: 0 auto; + } + + header { + text-align: center; + margin-bottom: 24px; + padding: 16px 0; + position: relative; + } + + header h1 { + font-size: 24px; + font-weight: 600; + margin-bottom: 4px; + } + + header p { + color: var(--text-muted); + font-size: 14px; + } + + .theme-toggle, .config-toggle { + position: absolute; + top: 16px; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + font-size: 20px; + } + + .theme-toggle { + right: 0; + } + + .config-toggle { + left: 0; + } + + .theme-toggle:hover, .config-toggle:hover { + background: var(--card-hover); + transform: scale(1.05); + } + + .theme-toggle:active, .config-toggle:active { + transform: scale(0.95); + } + + .modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + z-index: 1000; + align-items: center; + justify-content: center; + padding: 16px; + } + + .modal.show { + display: flex; + } + + .modal-content { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + padding: 24px; + position: relative; + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); + } + + .modal-title { + font-size: 20px; + font-weight: 600; + } + + .modal-close { + background: transparent; + border: none; + font-size: 24px; + cursor: pointer; + color: var(--text-muted); + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; + } + + .modal-close:hover { + background: var(--card-hover); + color: var(--text); + } + + .status-bar { + display: flex; + gap: 16px; + flex-wrap: wrap; + justify-content: center; + margin-bottom: 24px; + } + + .status-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-muted); + } + + .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success); + } + + .status-dot.offline { background: var(--error); } + .status-dot.loading { background: var(--warning); animation: pulse 1s infinite; } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + .card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 16px; + } + + .card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + .card-title { + font-size: 16px; + font-weight: 600; + } + + .form-group { + margin-bottom: 16px; + } + + .form-group:last-child { + margin-bottom: 0; + } + + label { + display: block; + font-size: 14px; + font-weight: 500; + margin-bottom: 6px; + color: var(--text); + } + + .label-row { + display: flex; + justify-content: space-between; + align-items: center; + } + + .label-value { + font-size: 12px; + color: var(--text-muted); + font-family: monospace; + } + + input[type="text"], + input[type="password"], + input[type="number"], + select, + textarea { + width: 100%; + padding: 10px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 14px; + outline: none; + transition: border-color 0.2s; + } + + input:focus, + select:focus, + textarea:focus { + border-color: var(--primary); + } + + textarea { + resize: vertical; + min-height: 80px; + } + + input[type="range"] { + width: 100%; + height: 6px; + border-radius: 3px; + background: var(--border); + outline: none; + -webkit-appearance: none; + } + + input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--primary); + cursor: pointer; + } + + input[type="color"] { + width: 100%; + height: 40px; + border: none; + border-radius: var(--radius); + cursor: pointer; + padding: 0; + } + + .color-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + } + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 16px; + font-size: 14px; + font-weight: 500; + border-radius: var(--radius); + border: none; + cursor: pointer; + transition: all 0.2s; + } + + .btn-primary { + background: var(--primary); + color: white; + } + + .btn-primary:hover { + background: var(--primary-hover); + } + + .btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn-outline { + background: transparent; + border: 1px solid var(--border); + color: var(--text); + } + + .btn-outline:hover { + background: var(--card-hover); + } + + .btn-group { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + .toggle { + position: relative; + width: 48px; + height: 26px; + } + + .toggle input { + opacity: 0; + width: 0; + height: 0; + } + + .toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--border); + border-radius: 26px; + transition: 0.2s; + } + + .toggle-slider:before { + content: ""; + position: absolute; + height: 20px; + width: 20px; + left: 3px; + bottom: 3px; + background: white; + border-radius: 50%; + transition: 0.2s; + } + + .toggle input:checked + .toggle-slider { + background: var(--primary); + } + + .toggle input:checked + .toggle-slider:before { + transform: translateX(22px); + } + + .toast { + position: fixed; + bottom: 20px; + right: 20px; + padding: 12px 20px; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 14px; + transform: translateY(100px); + opacity: 0; + transition: all 0.3s; + z-index: 1000; + max-width: 300px; + } + + .toast.show { + transform: translateY(0); + opacity: 1; + } + + .toast.success { border-color: var(--success); } + .toast.error { border-color: var(--error); } + + .prompt-output { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; + font-family: monospace; + font-size: 12px; + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; + } + + .prompt-status { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg); + border-radius: var(--radius); + font-size: 13px; + margin-bottom: 12px; + } + + .prompt-status.running { color: var(--warning); } + .prompt-status.done { color: var(--success); } + .prompt-status.error { color: var(--error); } + + .scene-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 8px; + } + + .scene-item:hover { + border-color: var(--primary); + } + + .scene-name { + font-weight: 500; + } + + .scene-actions { + display: flex; + gap: 8px; + } + + .scene-actions button { + padding: 4px 10px; + font-size: 12px; + } + + .grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + } + + @media (max-width: 600px) { + .grid-2, .color-row { + grid-template-columns: 1fr; + } + + body { + padding: 12px; + } + } + + .collapsible-header { + cursor: pointer; + user-select: none; + } + + .collapsible-header::after { + content: "▼"; + font-size: 10px; + margin-left: 8px; + transition: transform 0.2s; + } + + .collapsible.collapsed .collapsible-header::after { + transform: rotate(-90deg); + } + + .collapsible.collapsed .collapsible-content { + display: none; + } + + /* Tile selector for effects and palettes */ + .tile-row { + display: flex; + gap: 8px; + overflow-x: auto; + padding: 4px 0 12px 0; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + } + + .tile-row::-webkit-scrollbar { + height: 4px; + } + + .tile-row::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; + } + + .tile { + flex: 0 0 auto; + min-width: 70px; + padding: 10px 14px; + background: var(--card); + border: 2px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + text-align: center; + font-size: 12px; + transition: all 0.15s; + white-space: nowrap; + } + + .tile:hover { + background: var(--card-hover); + border-color: var(--text-muted); + } + + .tile.selected { + border-color: var(--primary); + background: rgba(59, 130, 246, 0.1); + } + + .tile-icon { + font-size: 18px; + display: block; + margin-bottom: 4px; + } + + .tile-label { + font-size: 11px; + color: var(--text-muted); + } + + .tile.selected .tile-label { + color: var(--primary); + } + + /* Palette preview strip */ + .palette-preview { + height: 4px; + border-radius: 2px; + margin-top: 6px; + } + + /* Nightlight expand/collapse */ + #nightlightControls { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out, opacity 0.2s; + opacity: 0; + } + + #nightlightControls.expanded { + max-height: 300px; + opacity: 1; + } + \ No newline at end of file diff --git a/data/assets/app.js b/data/assets/app.js new file mode 100644 index 0000000..f7da0eb --- /dev/null +++ b/data/assets/app.js @@ -0,0 +1,989 @@ + // Modal management + function openConfigModal() { + document.getElementById('configModal').classList.add('show'); + loadSegmentsConfig(); // Load segments when modal opens + } + + function closeConfigModal() { + document.getElementById('configModal').classList.remove('show'); + } + + // Close modal when clicking outside + document.addEventListener('click', function(e) { + const modal = document.getElementById('configModal'); + if (e.target === modal) { + closeConfigModal(); + } + }); + + // Theme management + function toggleTheme() { + const html = document.documentElement; + const currentTheme = html.getAttribute('data-theme') || 'dark'; + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + + html.setAttribute('data-theme', newTheme); + localStorage.setItem('theme', newTheme); + + // Update icon + const icon = document.getElementById('themeIcon'); + icon.textContent = newTheme === 'dark' ? '🌙' : '☀️'; + } + + // Load theme from localStorage on page load + function loadTheme() { + const savedTheme = localStorage.getItem('theme') || 'dark'; + document.documentElement.setAttribute('data-theme', savedTheme); + const icon = document.getElementById('themeIcon'); + icon.textContent = savedTheme === 'dark' ? '🌙' : '☀️'; + } + + // Load theme immediately + loadTheme(); + + // Load palette preset (v2 cannot read preset back reliably) + (function loadPalettePreset(){ + const saved = localStorage.getItem('palettePreset'); + if (saved) { + const pal = document.getElementById('palette'); + if (pal) pal.value = saved; + selectTileByValue('paletteTiles', saved); + } + })(); + + // State + let sliderBindings = {}; + let effectMetadata = {}; // Map of effect ID -> metadata (usesPalette, usesSpeed, etc.) + let activeSegmentId = 0; // Currently selected segment + + // Segment name helpers (stored in localStorage) + function getSegmentName(segmentId) { + const names = JSON.parse(localStorage.getItem('segmentNames') || '{}'); + return names[segmentId] || null; + } + + function setSegmentName(segmentId, name) { + const names = JSON.parse(localStorage.getItem('segmentNames') || '{}'); + if (name && name.trim()) { + names[segmentId] = name.trim(); + } else { + delete names[segmentId]; + } + localStorage.setItem('segmentNames', JSON.stringify(names)); + } + + // Toast notification + function showToast(message, type = 'info') { + const toast = document.getElementById('toast'); + toast.textContent = message; + toast.className = 'toast show ' + type; + setTimeout(() => toast.classList.remove('show'), 3000); + } + + // API helpers + async function api(endpoint, method = 'GET', body = null) { + const options = { + method, + headers: { 'Content-Type': 'application/json' } + }; + if (body) options.body = JSON.stringify(body); + + const response = await fetch('/api' + endpoint, options); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json(); + } + + + // v2 API helpers (segments/controller) + const PALETTE_PRESETS = { + "rainbow": 0, + "lava": 1, + "ocean": 2, + "party": 3, + "forest": 4, + "cloud": 5, + "heat": 6 +}; + + async function apiV2(path, method = 'GET', body = null) { + const options = { + method, + headers: { 'Content-Type': 'application/json' } + }; + if (body) options.body = JSON.stringify(body); + + const response = await fetch('/api/v2' + path, options); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json(); + } + + // WebSocket (optional): server should expose /ws and send {type:'state', controller:{...}, segments:[...]} + let ws = null; + function connectWebSocket() { + try { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + ws = new WebSocket(`${proto}://${location.host}/ws`); + ws.onopen = () => console.log('WS connected'); + ws.onclose = () => { + console.log('WS disconnected, retrying...'); + setTimeout(connectWebSocket, 2000); + }; + ws.onmessage = (evt) => { + try { + const msg = JSON.parse(evt.data); + if (msg.type === 'state') { + if (msg.controller) applyControllerToUI(msg.controller); + if (msg.segments) { + // Update the currently active segment, not always segment 0 + const activeSeg = msg.segments.find(s => s.id === activeSegmentId); + if (activeSeg) applySegmentToUI(activeSeg); + } + } + } catch (e) { + console.warn('WS message parse error', e); + } + }; + } catch (e) { + console.warn('WS init failed', e); + } + } + + function selectActiveSegment(segments) { + // Current UI is single-segment oriented; default to segment 0 if present, else first segment. + if (!Array.isArray(segments) || segments.length === 0) return null; + const s0 = segments.find(s => s.id === 0); + return s0 || segments[0]; + } + + function applyControllerToUI(controller) { + if (!controller) return; + document.getElementById('powerToggle').checked = controller.power !== false; + // Brightness input is user-controlled only, never updated from server + } + + function applySegmentToUI(seg) { + if (!seg) return; + document.getElementById('effect').value = seg.effect || 'rainbow'; + // Speed input is user-controlled only, never updated from server + + // Palette cannot be read back reliably in v2 (see docs); keep last selected in localStorage. + const savedPalette = localStorage.getItem('palettePreset') || 'rainbow'; + document.getElementById('palette').value = savedPalette; + + selectTileByValue('effectTiles', seg.effect || 'rainbow'); + selectTileByValue('paletteTiles', savedPalette); + + if (seg.primaryColor) { + const [r, g, b] = seg.primaryColor; + document.getElementById('primaryColor').value = rgbToHex(r, g, b); + } + if (seg.secondaryColor) { + const [r, g, b] = seg.secondaryColor; + document.getElementById('secondaryColor').value = rgbToHex(r, g, b); + } + } + + // Load segments into dropdown selector + async function loadSegments() { + try { + const data = await apiV2('/segments'); + const selector = document.getElementById('segmentSelector'); + + if (data.segments && data.segments.length > 0) { + selector.innerHTML = data.segments.map(seg => { + const customName = getSegmentName(seg.id); + const label = customName + ? `${customName} (LEDs ${seg.start}-${seg.stop})` + : `Segment ${seg.id} (LEDs ${seg.start}-${seg.stop})`; + return ``; + }).join(''); + + // Load the first segment's state + activeSegmentId = data.segments[0].id; + selector.value = activeSegmentId; + await loadSegmentState(activeSegmentId); + } else { + selector.innerHTML = ''; + } + } catch (e) { + console.error('Failed to load segments:', e); + } + } + + // Switch to a different segment + async function switchSegment(segmentId) { + activeSegmentId = segmentId; + await loadSegmentState(segmentId); + } + + // Load a specific segment's state into UI controls + async function loadSegmentState(segmentId) { + try { + const seg = await apiV2(`/segments/${segmentId}`); + + document.getElementById('effect').value = seg.effect || 'rainbow'; + document.getElementById('speed').value = seg.speed || 100; + document.getElementById('speedValue').textContent = seg.speed || 100; + document.getElementById('intensity').value = seg.intensity ?? 128; + document.getElementById('intensityValue').textContent = seg.intensity ?? 128; + selectTileByValue('effectTiles', seg.effect || 'rainbow'); + + const savedPalette = localStorage.getItem('palettePreset') || 'rainbow'; + document.getElementById('palette').value = savedPalette; + selectTileByValue('paletteTiles', savedPalette); + + if (seg.primaryColor) { + const [r, g, b] = seg.primaryColor; + document.getElementById('primaryColor').value = rgbToHex(r, g, b); + } + if (seg.secondaryColor) { + const [r, g, b] = seg.secondaryColor; + document.getElementById('secondaryColor').value = rgbToHex(r, g, b); + } + + // Update control visibility based on effect + updateEffectControls(seg.effect); + } catch (e) { + console.error(`Failed to load segment ${segmentId} state:`, e); + } + } + + // v2: Load LED state (controller + segments) + async function loadLedState() { + try { + const state = await apiV2('/segments'); + // Update controller state + document.getElementById('powerToggle').checked = state.power !== false; + document.getElementById('brightness').value = state.brightness ?? 128; + document.getElementById('brightnessValue').textContent = state.brightness ?? 128; + + // Load segments into dropdown + await loadSegments(); + } catch (e) { + console.error('Failed to load LED state (v2):', e); + } + } + + // Load effect metadata from API + async function loadEffectMetadata() { + try { + const data = await apiV2('/effects'); + if (data.effects) { + data.effects.forEach(effect => { + effectMetadata[effect.id] = { + usesPalette: effect.usesPalette, + usesPrimaryColor: effect.usesPrimaryColor, + usesSecondaryColor: effect.usesSecondaryColor, + usesSpeed: effect.usesSpeed, + usesIntensity: effect.usesIntensity + }; + }); + } + } catch (e) { + console.error('Failed to load effect metadata:', e); + } + } + + // Update control visibility based on selected effect + function updateEffectControls(effectId) { + const metadata = effectMetadata[effectId]; + if (!metadata) return; // Metadata not loaded yet or effect not found + + // Palette controls + const paletteSection = document.querySelector('.palette-section'); + if (paletteSection) { + paletteSection.style.display = metadata.usesPalette ? '' : 'none'; + } + + // Speed controls + const speedControl = document.querySelector('.speed-control'); + if (speedControl) { + speedControl.style.display = metadata.usesSpeed ? '' : 'none'; + } + + // Intensity controls + const intensityControl = document.querySelector('.intensity-control'); + if (intensityControl) { + intensityControl.style.display = metadata.usesIntensity ? '' : 'none'; + } + + // Primary color + const primaryColorControl = document.querySelector('.primary-color'); + if (primaryColorControl) { + primaryColorControl.style.display = metadata.usesPrimaryColor ? '' : 'none'; + } + + // Secondary color + const secondaryColorControl = document.querySelector('.secondary-color'); + if (secondaryColorControl) { + secondaryColorControl.style.display = metadata.usesSecondaryColor ? '' : 'none'; + } + + // Hide entire color section if neither color is used + // EXCEPT when custom palette is selected (needs color input) + const colorControls = document.querySelector('.color-controls'); + if (colorControls) { + const selectedPalette = document.getElementById('palette')?.value; + const isCustomPalette = selectedPalette === 'custom'; + const usesAnyColor = metadata.usesPrimaryColor || metadata.usesSecondaryColor || (metadata.usesPalette && isCustomPalette); + colorControls.style.display = usesAnyColor ? '' : 'none'; + + // Show both colors for custom palette + if (isCustomPalette && metadata.usesPalette) { + if (primaryColorControl) primaryColorControl.style.display = ''; + if (secondaryColorControl) secondaryColorControl.style.display = ''; + } + } + } + + // v2: Apply LED state - updates controller + segment 0 + async function applyLedState() { + const controller = { + power: document.getElementById('powerToggle').checked, + brightness: parseInt(document.getElementById('brightness').value) + }; + + const paletteName = document.getElementById('palette').value; + localStorage.setItem('palettePreset', paletteName); + + const segment = { + effect: document.getElementById('effect').value, + speed: parseInt(document.getElementById('speed').value), + intensity: parseInt(document.getElementById('intensity').value), + primaryColor: hexToRgb(document.getElementById('primaryColor').value), + secondaryColor: hexToRgb(document.getElementById('secondaryColor').value), + palette: PALETTE_PRESETS[paletteName] ?? 0 + }; + + try { + // Update controller + await apiV2('/controller', 'PUT', controller); + + // Update active segment + if (activeSegmentId >= 0) { + await apiV2(`/segments/${activeSegmentId}`, 'PUT', segment); + showToast('Settings applied!', 'success'); + } else { + showToast('No segment selected', 'error'); + } + } catch (e) { + showToast('Failed to apply settings (v2)', 'error'); + console.error(e); + } + } + + + // Load status + async function loadStatus() { + try { + const status = await api('/status'); + + document.getElementById('wifiDot').className = 'status-dot'; + document.getElementById('wifiStatus').textContent = status.wifi || 'Connected'; + document.getElementById('ipAddress').textContent = status.ip || '--'; + + const uptime = status.uptime || 0; + const hours = Math.floor(uptime / 3600); + const mins = Math.floor((uptime % 3600) / 60); + document.getElementById('uptime').textContent = `${hours}h ${mins}m`; + + // Update sACN status + const sacn = status.sacn || {}; + const sacnDot = document.getElementById('sacnDot'); + const sacnText = document.getElementById('sacnStatusText'); + if (!sacn.enabled) { + sacnDot.className = 'status-dot offline'; + sacnText.textContent = 'Not enabled'; + } else if (sacn.receiving) { + sacnDot.className = 'status-dot'; + sacnText.textContent = `Receiving (${sacn.packets} pkts, uni ${sacn.universe})`; + } else { + sacnDot.className = 'status-dot loading'; + sacnText.textContent = `Waiting for data (uni ${sacn.universe})`; + } + + // Update MQTT status + const mqtt = status.mqtt || {}; + const mqttDot = document.getElementById('mqttDot'); + const mqttText = document.getElementById('mqttStatusText'); + if (!mqtt.enabled) { + mqttDot.className = 'status-dot offline'; + mqttText.textContent = 'Not enabled'; + } else if (mqtt.connected) { + mqttDot.className = 'status-dot'; + mqttText.textContent = `Connected to ${mqtt.broker}`; + } else { + mqttDot.className = 'status-dot loading'; + mqttText.textContent = 'Connecting...'; + } + } catch (e) { + document.getElementById('wifiDot').className = 'status-dot offline'; + document.getElementById('wifiStatus').textContent = 'Offline'; + } + } + + // Load LED state (v2) defined above + + // Simple slider handlers - update display on input, apply on release + function setupSlider(inputId, valueId) { + const input = document.getElementById(inputId); + const display = document.getElementById(valueId); + if (!input || !display) return; + + let isDragging = false; + + // Update display while dragging + input.addEventListener('input', () => { + display.textContent = input.value; + }); + + // Mark as dragging + ['pointerdown', 'mousedown', 'touchstart'].forEach(evt => { + input.addEventListener(evt, () => { + isDragging = true; + }); + }); + + // Send update when released + ['pointerup', 'mouseup', 'touchend'].forEach(evt => { + input.addEventListener(evt, () => { + if (isDragging) { + isDragging = false; + applyLedState(); + } + }); + }); + } + + setupSlider('brightness', 'brightnessValue'); + setupSlider('speed', 'speedValue'); + setupSlider('intensity', 'intensityValue'); + + // Tile selector functions - auto-apply on selection + function selectEffect(tile) { + const container = document.getElementById('effectTiles'); + container.querySelectorAll('.tile').forEach(t => t.classList.remove('selected')); + tile.classList.add('selected'); + const effectId = tile.dataset.value; + document.getElementById('effect').value = effectId; + updateEffectControls(effectId); + applyLedState(); + } + + function selectPalette(tile) { + localStorage.setItem('palettePreset', tile.dataset.value); + const container = document.getElementById('paletteTiles'); + container.querySelectorAll('.tile').forEach(t => t.classList.remove('selected')); + tile.classList.add('selected'); + document.getElementById('palette').value = tile.dataset.value; + + // Update control visibility (custom palette needs color pickers) + const currentEffect = document.getElementById('effect').value; + updateEffectControls(currentEffect); + + applyLedState(); + } + + function selectTileByValue(containerId, value) { + const container = document.getElementById(containerId); + if (!container) return; + container.querySelectorAll('.tile').forEach(t => { + if (t.dataset.value === value) { + t.classList.add('selected'); + } else { + t.classList.remove('selected'); + } + }); + } + + // Apply LED state (v2) defined above + + // Nightlight functions + let nightlightPollInterval = null; + + async function loadNightlightStatus() { + try { + const status = await api('/nightlight'); + updateNightlightUI(status); + } catch (e) { + console.error('Failed to load nightlight status:', e); + } + } + + // Flag to prevent toggle change handler from firing during programmatic updates + let updatingNightlightUI = false; + + function updateNightlightUI(status) { + const isActive = status.active; + const controls = document.getElementById('nightlightControls'); + const toggle = document.getElementById('nightlightToggle'); + + // Prevent change handler from running when we set checked programmatically + updatingNightlightUI = true; + toggle.checked = isActive; + updatingNightlightUI = false; + + document.getElementById('startNightlightBtn').style.display = isActive ? 'none' : ''; + document.getElementById('stopNightlightBtn').style.display = isActive ? '' : 'none'; + document.getElementById('nightlightProgress').style.display = isActive ? '' : 'none'; + + // Expand/collapse controls based on active state + if (isActive) { + controls.classList.add('expanded'); + } else { + controls.classList.remove('expanded'); + } + + if (isActive) { + const progress = Math.round((status.progress || 0) * 100); + document.getElementById('nightlightProgressBar').style.width = progress + '%'; + document.getElementById('nightlightProgressText').textContent = progress + '% complete'; + + // Start polling for progress updates + if (!nightlightPollInterval) { + nightlightPollInterval = setInterval(loadNightlightStatus, 2000); + } + } else { + // Stop polling + if (nightlightPollInterval) { + clearInterval(nightlightPollInterval); + nightlightPollInterval = null; + } + } + } + + function toggleNightlightControls(show) { + const controls = document.getElementById('nightlightControls'); + if (show) { + controls.classList.add('expanded'); + } else { + controls.classList.remove('expanded'); + } + } + + async function startNightlight() { + const durationMinutes = parseInt(document.getElementById('nightlightDuration').value); + const targetBrightness = parseInt(document.getElementById('nightlightTarget').value); + + try { + const result = await api('/nightlight', 'POST', { + duration: durationMinutes * 60, // Convert to seconds + targetBrightness: targetBrightness + }); + showToast('Nightlight started!', 'success'); + // Wait a moment for server to process, then start polling + setTimeout(() => { + loadNightlightStatus(); + }, 200); + } catch (e) { + showToast('Failed to start nightlight', 'error'); + // Reset toggle on error + const toggle = document.getElementById('nightlightToggle'); + updatingNightlightUI = true; + toggle.checked = false; + updatingNightlightUI = false; + } + } + + async function stopNightlight() { + try { + await api('/nightlight/stop', 'POST', {}); + showToast('Nightlight stopped', 'success'); + // Stop polling immediately + if (nightlightPollInterval) { + clearInterval(nightlightPollInterval); + nightlightPollInterval = null; + } + // Update UI immediately without waiting for server + const toggle = document.getElementById('nightlightToggle'); + const controls = document.getElementById('nightlightControls'); + updatingNightlightUI = true; + toggle.checked = false; + updatingNightlightUI = false; + controls.classList.remove('expanded'); + document.getElementById('startNightlightBtn').style.display = ''; + document.getElementById('stopNightlightBtn').style.display = 'none'; + document.getElementById('nightlightProgress').style.display = 'none'; + } catch (e) { + showToast('Failed to stop nightlight', 'error'); + } + } + + // Load config + async function loadConfig() { + try { + const config = await api('/config'); + + document.getElementById('wifiSSID').value = config.wifiSSID || ''; + document.getElementById('ledCount').value = config.ledCount || 160; + + // AI settings + document.getElementById('aiApiKey').value = config.aiApiKey && config.aiApiKey !== '****' ? '' : ''; + document.getElementById('aiModel').value = config.aiModel || 'claude-3-5-sonnet-20241022'; + + // sACN settings + const sacnEnabled = config.sacnEnabled || false; + document.getElementById('sacnEnabled').checked = sacnEnabled; + document.getElementById('sacnUniverse').value = config.sacnUniverse || 1; + document.getElementById('sacnStartChannel').value = config.sacnStartChannel || 1; + toggleSettings('sacnSettings', sacnEnabled); + + // MQTT settings + const mqttEnabled = config.mqttEnabled || false; + document.getElementById('mqttEnabled').checked = mqttEnabled; + document.getElementById('mqttBroker').value = config.mqttBroker || ''; + document.getElementById('mqttPort').value = config.mqttPort || 1883; + document.getElementById('mqttUsername').value = config.mqttUsername && config.mqttUsername !== '****' ? config.mqttUsername : ''; + document.getElementById('mqttPassword').value = config.mqttPassword && config.mqttPassword !== '****' ? '' : ''; + document.getElementById('mqttTopicPrefix').value = config.mqttTopicPrefix || 'lume'; + toggleSettings('mqttSettings', mqttEnabled); + } catch (e) { + console.error('Failed to load config:', e); + } + } + + // Load segments for config modal + async function loadSegmentsConfig() { + try { + const data = await apiV2('/segments'); + const container = document.getElementById('segmentsList'); + + if (!data.segments || data.segments.length === 0) { + container.innerHTML = '

No segments configured

'; + return; + } + + container.innerHTML = data.segments.map(seg => { + const customName = getSegmentName(seg.id); + const displayName = customName || `Segment ${seg.id}`; + return ` +
+
+
${displayName}
+
+ LEDs ${seg.start}-${seg.stop} (${seg.length} LEDs) + ${seg.effect ? `• ${seg.effect}` : ''} +
+
+ + ${seg.length > 1 ? `` : ''} + +
+ `; + }).join(''); + } catch (e) { + console.error('Failed to load segments:', e); + } + } + + async function editSegmentName(id) { + const currentName = getSegmentName(id) || ''; + const newName = prompt(`Enter name for segment ${id}:`, currentName); + + if (newName !== null) { + setSegmentName(id, newName); + await loadSegmentsConfig(); // Refresh config list + await loadSegments(); // Refresh dropdown + } + } + + async function splitSegment(id, start, length) { + const splitAt = prompt(`Split segment ${id} at LED position? (${start + 1} to ${start + length - 1})`); + if (!splitAt) return; + + const splitPos = parseInt(splitAt); + if (isNaN(splitPos) || splitPos <= start || splitPos >= start + length) { + showToast('Invalid split position', 'error'); + return; + } + + try { + // Calculate new segment lengths + const firstLength = splitPos - start; + const secondLength = length - firstLength; + + // Delete original segment + await fetch('/api/v2/segments/' + id, { method: 'DELETE' }); + + // Create first segment + await apiV2('/segments', 'POST', { + start: start, + length: firstLength + }); + + // Create second segment + await apiV2('/segments', 'POST', { + start: splitPos, + length: secondLength + }); + + showToast('Segment split successfully!', 'success'); + loadSegmentsConfig(); + } catch (e) { + showToast('Failed to split segment', 'error'); + console.error(e); + } + } + + async function addSegment() { + const start = parseInt(document.getElementById('newSegmentStart').value); + const length = parseInt(document.getElementById('newSegmentLength').value); + + if (isNaN(start) || isNaN(length) || start < 0 || length < 1) { + showToast('Invalid segment range', 'error'); + return; + } + + try { + await apiV2('/segments', 'POST', { + start: start, + length: length + }); + showToast('Segment created!', 'success'); + loadSegmentsConfig(); + + // Update start field for next segment + document.getElementById('newSegmentStart').value = start + length; + } catch (e) { + showToast('Failed to create segment', 'error'); + console.error(e); + } + } + + async function deleteSegmentConfig(id) { + if (!confirm(`Delete segment ${id}?`)) { + return; + } + + try { + await fetch('/api/v2/segments/' + id, { method: 'DELETE' }); + showToast('Segment deleted', 'success'); + loadSegmentsConfig(); + } catch (e) { + showToast('Failed to delete segment', 'error'); + console.error(e); + } + } + + // Save config + async function saveConfig() { + const config = { + wifiSSID: document.getElementById('wifiSSID').value, + wifiPassword: document.getElementById('wifiPassword').value, + ledCount: parseInt(document.getElementById('ledCount').value), + aiApiKey: document.getElementById('aiApiKey').value, + aiModel: document.getElementById('aiModel').value, + sacnEnabled: document.getElementById('sacnEnabled').checked, + sacnUniverse: parseInt(document.getElementById('sacnUniverse').value), + sacnStartChannel: parseInt(document.getElementById('sacnStartChannel').value), + mqttEnabled: document.getElementById('mqttEnabled').checked, + mqttBroker: document.getElementById('mqttBroker').value, + mqttPort: parseInt(document.getElementById('mqttPort').value), + mqttUsername: document.getElementById('mqttUsername').value, + mqttPassword: document.getElementById('mqttPassword').value, + mqttTopicPrefix: document.getElementById('mqttTopicPrefix').value + }; + + // Don't send masked password/key + if (config.wifiPassword === '') delete config.wifiPassword; + if (config.mqttPassword === '') delete config.mqttPassword; + if (config.aiApiKey === '') delete config.aiApiKey; + + try { + await api('/config', 'POST', config); + showToast('Configuration saved!', 'success'); + loadConfig(); + } catch (e) { + showToast('Failed to save configuration', 'error'); + } + } + + // Scene management + async function loadScenes() { + try { + const scenes = await api('/scenes'); + const container = document.getElementById('scenesList'); + + if (!scenes || scenes.length === 0) { + container.innerHTML = '

No saved scenes yet

'; + return; + } + + container.innerHTML = scenes.map(scene => ` +
+ ${escapeHtml(scene.name)} +
+ + +
+
+ `).join(''); + } catch (e) { + console.error('Failed to load scenes:', e); + } + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + async function applyScene(id) { + try { + const response = await fetch('/api/scenes/' + id + '/apply', { + method: 'POST' + }); + + if (!response.ok) { + const result = await response.json(); + showToast('Failed: ' + (result.error || response.status), 'error'); + return; + } + + showToast('Scene applied!', 'success'); + loadLedState(); + } catch (e) { + showToast('Failed to apply scene: ' + e.message, 'error'); + } + } + + async function deleteScene(id) { + if (!confirm('Delete this scene?')) { + return; + } + + try { + await fetch('/api/scenes/' + id, { method: 'DELETE' }); + showToast('Scene deleted', 'success'); + loadScenes(); + } catch (e) { + showToast('Failed to delete scene', 'error'); + } + } + + // Color helpers + function hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? [ + parseInt(result[1], 16), + parseInt(result[2], 16), + parseInt(result[3], 16) + ] : [0, 0, 255]; + } + + function rgbToHex(r, g, b) { + return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join(''); + } + + // Toggle settings visibility + function toggleSettings(settingsId, show) { + const settings = document.getElementById(settingsId); + if (!settings) return; + settings.style.maxHeight = show ? '500px' : '0'; + } + + // Event listeners - sliders handled by SliderBinding helpers + + // Color pickers auto-apply on change (when picker closes) + document.getElementById('primaryColor').addEventListener('change', function() { + applyLedState(); + }); + + document.getElementById('secondaryColor').addEventListener('change', function() { + applyLedState(); + }); + + document.getElementById('powerToggle').addEventListener('change', function() { + applyLedState(); + }); + + // Nightlight slider listeners + document.getElementById('nightlightDuration').addEventListener('input', function() { + document.getElementById('nightlightDurationValue').textContent = this.value + ' min'; + }); + + document.getElementById('nightlightTarget').addEventListener('input', function() { + const val = parseInt(this.value); + document.getElementById('nightlightTargetValue').textContent = val === 0 ? '0 (off)' : val; + }); + + document.getElementById('nightlightToggle').addEventListener('change', async function() { + // Ignore programmatic changes + if (updatingNightlightUI) return; + + if (this.checked) { + // Toggle ON - expand controls to show settings + toggleNightlightControls(true); + } else { + // Toggle OFF - stop if running, otherwise just hide + await stopNightlight(); + } + }); + + // AI Prompt functions + async function sendAIPrompt() { + const prompt = document.getElementById('aiPrompt').value.trim(); + if (!prompt) { + showToast('Please enter a prompt', 'error'); + return; + } + + const statusDiv = document.getElementById('aiStatus'); + const statusText = document.getElementById('aiStatusText'); + + statusDiv.style.display = 'block'; + statusText.textContent = 'Processing your request...'; + + try { + const result = await api('/prompt', 'POST', { prompt: prompt }); + + if (result.success) { + showToast('✨ Lights updated!', 'success'); + statusText.textContent = result.message || 'Applied successfully!'; + setTimeout(() => { + statusDiv.style.display = 'none'; + }, 3000); + + // Reload LED state to show changes + await loadLedState(); + } else { + showToast(result.error || 'Failed to process prompt', 'error'); + statusDiv.style.display = 'none'; + } + } catch (e) { + showToast('Error: ' + (e.message || 'Network error'), 'error'); + statusDiv.style.display = 'none'; + console.error('AI prompt error:', e); + } + } + + // sACN and MQTT toggle handlers + document.getElementById('sacnEnabled').addEventListener('change', function() { + toggleSettings('sacnSettings', this.checked); + }); + + document.getElementById('mqttEnabled').addEventListener('change', function() { + toggleSettings('mqttSettings', this.checked); + }); + + // Initialize + async function initialize() { + loadStatus(); + await loadEffectMetadata(); // Load effect metadata FIRST + await loadLedState(); // Then load LED state (which needs metadata) + loadConfig(); + loadScenes(); + loadNightlightStatus(); + setInterval(loadStatus, 10000); + } + + initialize(); + + + // Start WebSocket (optional) + connectWebSocket(); diff --git a/data/index.html b/data/index.html new file mode 100644 index 0000000..aee8cb5 --- /dev/null +++ b/data/index.html @@ -0,0 +1,502 @@ + + + + + + + LUME + + + +
+
+ + +

LUME

+

AI-Powered LED Control

+
+ +
+
+
+ Connecting... +
+
+ -- +
+
+ -- +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ Power & Brightness + +
+ +
+
+ + 128 +
+ +
+
+ + +
+
+ Segment +
+ +
+ + +
+
+ + +
+
+ Effect +
+ +
+ +
+
+ + Solid +
+
+ 🌈 + Rainbow +
+
+ 🎊 + Confetti +
+
+ 🔥 + Fire +
+
+ 🔥 + Fire Up +
+
+ 🌊 + Waves +
+
+ 〰️ + Wave +
+
+ 🎭 + Theater +
+
+ + Gradient +
+
+ + Sparkle +
+
+ 💓 + Pulse +
+
+ 🌬️ + Breathe +
+
+ 📺 + Noise +
+
+ ☄️ + Meteor +
+
+ 💫 + Comet +
+
+ 🌧️ + Rain +
+
+ + Twinkle +
+
+ + Strobe +
+
+ 🎯 + Sinelon +
+
+ 👁️ + Scanner +
+
+ 🕯️ + Candle +
+
+ 🏳️‍🌈 + Pride +
+
+ 🐚 + Pacifica +
+
+ +
+ +
+ +
+
+ Rainbow +
+
+
+ Lava +
+
+
+ Ocean +
+
+
+ Party +
+
+
+ Forest +
+
+
+ Cloud +
+
+
+ Heat +
+
+
+ Sunset +
+
+
+ Autumn +
+
+
+ Retro +
+
+
+ Ice +
+
+
+ Pink +
+
+
+ Custom +
+
+
+ +
+ +
+
+ + 100 +
+ +
+ +
+
+ + 128 +
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ 🌙 Nightlight + +
+ +
+
+
+ + 15 min +
+ +
+ +
+
+ + 0 (off) +
+ +
+ + + +
+ + +
+
+
+ + + +
+ +
+ + + + diff --git a/docs/ADDING_EFFECTS.md b/docs/ADDING_EFFECTS.md new file mode 100644 index 0000000..fafb0e7 --- /dev/null +++ b/docs/ADDING_EFFECTS.md @@ -0,0 +1,213 @@ + +# Copilot Prompt for New Effects (in VS Code) + +Want to add a new effect or port one from WLED? Paste this prompt into GitHub Copilot (in VS Code), or any AI assistant (ChatGPT, Claude, etc), to generate a LUME-compatible effect: + +--- +**Prompt:** + +Write a new LED effect for the LUME firmware. Use the following template and conventions: + +- The effect function signature must be: + void effectNAME(SegmentView& view, const EffectParams& params, uint32_t frame, bool firstFrame) +- Do not use static/global variables; use the segment scratchpad for state. +- Use only FastLED-compatible code and LUME's SegmentView API. +- Register the effect with the correct macro (see table below). +- Example effect name: "fireup", "rainbowtwinkle", etc. +- If porting from WLED, convert millis() to frame, and replace global state with scratchpad. + +Example template: +```cpp +void effectMyEffect(SegmentView& view, const EffectParams& params, uint32_t frame, bool firstFrame) { + // Your effect code here +} +REGISTER_EFFECT_PALETTE(effectMyEffect, "myeffect", "My Effect"); +``` + +--- + +See the rest of this file for effect registration macros and parameter details. +# Adding New Effects + +Quick guide to creating custom LED effects for LUME. + +## File Structure + +Create a new file in `src/effects/youreffect.cpp`: + +```cpp +#include "../core/effect_registry.h" + +namespace lume { + +void effectYourEffect(SegmentView& view, const EffectParams& params, + uint32_t frame, bool firstFrame) { + // Your effect code here + for (uint16_t i = 0; i < view.size(); i++) { + view[i] = CRGB::Blue; // Example: set all LEDs to blue + } +} + +REGISTER_EFFECT_PALETTE(effectYourEffect, "youreffect", "Your Effect"); + +} // namespace lume +``` + +## Registration Macros + +Choose the macro that matches your effect's needs: + +| Macro | Primary Color | Secondary Color | Palette | Speed | Intensity | Use Case | +|-------|---------------|-----------------|---------|-------|-----------|----------| +| `REGISTER_EFFECT_SOLID` | ✅ | ❌ | ❌ | ❌ | ❌ | Static color effects (no animation) | +| `REGISTER_EFFECT_SIMPLE_NAMED` | ❌ | ❌ | ❌ | ✅ | ❌ | Basic animated effects (speed only) | +| `REGISTER_EFFECT_PALETTE` | ❌ | ❌ | ✅ | ✅ | ❌ | Palette-based animations | +| `REGISTER_EFFECT_COLORS` | ✅ | ✅ | ❌ | ✅ | ❌ | Two-color effects (primary + secondary) | +| `REGISTER_EFFECT_ANIMATED` | ❌ | ❌ | ❌ | ✅ | ✅ | Animated with speed + intensity | +| `REGISTER_EFFECT_MOVING` | ❌ | ❌ | ❌ | ✅ | ✅ | Moving/traveling effects | + +**Important:** Match your registration to what parameters your effect actually uses! If your effect code uses `params.primaryColor`, you must set `usesPrimaryColor=true` in the registration (use `REGISTER_EFFECT_FULL` or an appropriate convenience macro). + +### Advanced Registration + +For full control, use `REGISTER_EFFECT_FULL`: + +```cpp +REGISTER_EFFECT_FULL( + effectCustom, // Function name + "custom", // ID (lowercase, no spaces) + "Custom Effect", // Display name + Animated, // Category: Solid, Animated, Moving, Special + true, // usesPalette + true, // usesPrimaryColor + true, // usesSecondaryColor + true, // usesSpeed + true, // usesIntensity + 0, // State size (0 if stateless) + 1 // Minimum LEDs +); +``` + +## Available Parameters + +```cpp +void effectYourEffect(SegmentView& view, const EffectParams& params, + uint32_t frame, bool firstFrame) { + + // Colors + CRGB primary = params.primaryColor; // Primary color + CRGB secondary = params.secondaryColor; // Secondary color + + // Animation controls + uint8_t speed = params.speed; // 1-200 (default: 100) + uint8_t intensity = params.intensity; // 0-255 (default: 128) + + // Palette (if registered with palette support) + CRGBPalette16 palette = params.palette; + + // Timing + uint32_t currentFrame = frame; // Use for beatsin8(), etc. + bool isFirstFrame = firstFrame; // True on effect change + + // LED access + uint16_t ledCount = view.size(); + view[0] = CRGB::Red; // Set individual LED + view.fill(CRGB::Blue); // Fill all LEDs + view.gradient(primary, secondary); // Create gradient +} +``` + +## Examples + +### Static Two-Color Gradient + +```cpp +void effectGradient(SegmentView& view, const EffectParams& params, + uint32_t frame, bool firstFrame) { + view.gradient(params.primaryColor, params.secondaryColor); +} + +REGISTER_EFFECT_COLORS(effectGradient, "gradient", "Gradient"); +``` + +### Animated Rainbow + +```cpp +void effectRainbow(SegmentView& view, const EffectParams& params, + uint32_t frame, bool firstFrame) { + uint8_t hue = (frame * params.speed) / 100; + for (uint16_t i = 0; i < view.size(); i++) { + view[i] = CHSV(hue + (i * 255 / view.size()), 255, 255); + } +} + +REGISTER_EFFECT_SIMPLE_NAMED(effectRainbow, "rainbow", "Rainbow"); +``` + +### Palette-Based with Speed + +```cpp +void effectFire(SegmentView& view, const EffectParams& params, + uint32_t frame, bool firstFrame) { + uint8_t cooling = params.intensity > 0 ? params.intensity : 55; + + for (uint16_t i = 0; i < view.size(); i++) { + uint8_t colorIndex = (frame + i * 10) % 255; + view[i] = ColorFromPalette(params.palette, colorIndex); + } +} + +REGISTER_EFFECT_PALETTE(effectFire, "fire", "Fire"); +``` + +## Adding to Build + +1. Create your effect file in `src/effects/` +2. Add to `src/effects/effects.h`: + ```cpp + #include "youreffect.cpp" + ``` +3. Compile: `pio run -t upload` + +## UI Integration + +The web UI automatically adapts based on your registration macro: + +- Palette selector appears if `usesPalette = true` +- Speed slider hidden if `usesSpeed = false` +- Intensity slider appears if `usesIntensity = true` +- Secondary color picker shows if `usesSecondaryColor = true` + +**No frontend changes needed!** The metadata drives the UI. + +## Best Practices + +✅ **DO:** +- Use `frame` for timing (not `millis()`) +- Keep effects deterministic (same inputs = same output) +- Use `firstFrame` to initialize state +- Test with different LED counts + +❌ **DON'T:** +- Use global variables or `static` state +- Call `delay()` or blocking functions +- Assume a specific LED count +- Use `millis()` for animation timing + +## Debugging + +Enable logging in your effect: + +```cpp +#include "../logging.h" + +void effectDebug(SegmentView& view, const EffectParams& params, + uint32_t frame, bool firstFrame) { + if (firstFrame) { + LOG_INFO(LogTag::LED, "Effect started, %d LEDs", view.size()); + } + // Effect code... +} +``` + +Monitor output: `pio device monitor` diff --git a/docs/API_V2.md b/docs/API_V2.md new file mode 100644 index 0000000..371c140 --- /dev/null +++ b/docs/API_V2.md @@ -0,0 +1,699 @@ +# LUME v2 API Documentation + +**Base URL:** `http://lume.local` (or device IP address) + +The v2 API provides multi-segment LED control with full access to the segment-based architecture. + +## Authentication + +All endpoints support optional token-based authentication: +- Header: `Authorization: Bearer YOUR_TOKEN` +- Header: `X-API-Key: YOUR_TOKEN` +- Query param: `?token=YOUR_TOKEN` + +--- + +## Update Semantics + +All PUT endpoints use partial-update semantics. Any field you omit from the request body keeps its prior value on the device. This matches the current embedded UI behavior and avoids the need for a separate PATCH verb in constrained environments. + +--- + +## Error Handling + +All endpoints return JSON on failure so clients can safely introspect errors: + +```json +{ + "error": "validation_error", + "field": "speed", + "message": "Must be between 1 and 255" +} +``` + +| Status | Usage | Notes | +| --- | --- | --- | +| `200` | Successful reads and updates | Responses include the full resource payload. | +| `201` | Successful creates | Returns the created segment object. | +| `400` | Validation or JSON parsing errors | `field` points to the offending attribute when applicable. | +| `404` | Unknown segment IDs | Returned when a referenced segment does not exist. | +| `413` | Payload too large | Body exceeded `MAX_REQUEST_BODY_SIZE` (16KB). | +| `500` | Internal creation failures | Rare; indicates controller could not allocate a segment. | + +Authentication failures continue to return `401` via the shared `sendUnauthorized()` helper. + +--- + +## Payload Schemas & Examples + +### Controller Payload + +| Field | Type / Range | Required | Notes | +| --- | --- | --- | --- | +| `power` | `bool` | optional | `true` enables LED output. | +| `brightness` | `uint8 (0-255)` | optional | Global brightness applied across all segments. | +| `ledCount` | `uint16 (1-1024)` | read-only | Returned by GET responses; not writable via API. | + +**Response shape (GET / PUT):** +```json +{ + "power": true, + "brightness": 128, + "ledCount": 160 +} +``` + +### Segment Payload + +| Field | Type / Range | Required (POST) | Notes | +| --- | --- | --- | --- | +| `id` | `uint8 (0-7)` | auto | Assigned sequentially, returned in responses. | +| `start` | `uint16` | ✅ | Starting LED index. | +| `length` | `uint16` | ✅ | Number of LEDs in the segment. | +| `stop` | `uint16` | auto | Derived from `start + length - 1` in responses. | +| `effect` | `string` | optional | Must match an ID from `/api/v2/effects`. | +| `speed` | `uint8 (1-255)` | optional | Animation speed scalar. | +| `intensity` | `uint8 (0-255)` | optional | Effect-specific secondary scalar. | +| `primaryColor` | `[uint8,uint8,uint8]` | optional | RGB triplet. | +| `secondaryColor` | `[uint8,uint8,uint8]` | optional | RGB triplet. | +| `palette` | `uint8 (0-6)` | optional | Palette preset index; omitted in responses due to storage limitation. | +| `reverse` | `bool` | optional | Only honored at creation time. | + +**Segment response shape (GET / POST / PUT):** +```json +{ + "id": 0, + "start": 0, + "stop": 79, + "length": 80, + "effect": "fire", + "speed": 180, + "intensity": 150, + "primaryColor": [255, 80, 0], + "secondaryColor": [255, 0, 0], + "reverse": false +} +``` + +--- + +## Controller Endpoints + +### GET /api/v2/controller + +Get controller-level state (power, brightness, LED count). + +**Response:** +```json +{ + "power": true, + "brightness": 200, + "ledCount": 160 +} +``` + +### PUT /api/v2/controller + +Update controller-level state. + +**Request:** +```json +{ + "power": true, + "brightness": 200 +} +``` + +**Response:** Same as GET - returns updated state. + +--- + +## Segment Endpoints + +### GET /api/v2/segments + +List all segments with controller state. + +**Response:** +```json +{ + "power": true, + "brightness": 128, + "ledCount": 160, + "segments": [ + { + "id": 0, + "start": 0, + "stop": 159, + "length": 160, + "effect": "rainbow", + "speed": 128, + "intensity": 128, + "primaryColor": [0, 0, 255], + "secondaryColor": [128, 0, 128], + "reverse": false + } + ] +} +``` + +**Notes:** +- `stop` is calculated as `start + length - 1` (inclusive end position) +- `palette` field is omitted (see limitations below) + +### POST /api/v2/segments + +Create a new segment. + +**Request:** +```json +{ + "start": 0, + "length": 80, + "effect": "fire", + "speed": 180, + "intensity": 150, + "primaryColor": [255, 80, 0], + "secondaryColor": [255, 0, 0], + "palette": 1, + "reverse": false +} +``` + +**Required fields:** +- `start` (uint16) - Starting LED index +- `length` (uint16) - Number of LEDs in segment + +**Optional fields:** +- `effect` (string) - Effect ID (default: none) +- `speed` (uint8, 1-255) - Animation speed +- `intensity` (uint8, 0-255) - Effect intensity +- `primaryColor` (array [r,g,b]) - Primary color +- `secondaryColor` (array [r,g,b]) - Secondary color +- `palette` (int 0-6) - Palette preset (0=Rainbow, 1=Lava, 2=Ocean, 3=Party, 4=Forest, 5=Cloud, 6=Heat) +- `reverse` (bool) - Reverse LED direction (set at creation only) + +**Response:** Returns created segment object with assigned `id`. + +### GET /api/v2/segments/{id} + +Get a specific segment by ID. + +**Response:** +```json +{ + "id": 0, + "start": 0, + "stop": 79, + "length": 80, + "effect": "fire", + "speed": 180, + "intensity": 150, + "primaryColor": [255, 80, 0], + "secondaryColor": [255, 0, 0], + "reverse": false +} +``` + +### PUT /api/v2/segments/{id} + +Update an existing segment. + +**Partial update example:** +```bash +curl -X PUT http://lume.local/api/v2/segments/0 \ + -H "Content-Type: application/json" \ + -d '{ + "effect": "rainbow", + "speed": 120 + }' +``` + +**Behavior:** +- `primaryColor`, `secondaryColor`, `palette`, and any omitted numeric fields remain unchanged. +- `reverse` cannot be updated (creation-time only); supplying it on PUT is ignored. +- Response returns the full segment object reflecting the new values. + +**Response:** Returns updated segment object (see schema above). + +### DELETE /api/v2/segments/{id} + +Delete a segment by ID. + +--- + +## Metadata Endpoints + +### GET /api/v2/effects + +List all available effects with metadata. + +**Response:** +```json +{ + "effects": [ + { + "id": "fire", + "name": "Fire", + "category": 1, + "usesPalette": true, + "usesPrimaryColor": false, + "usesSecondaryColor": false, + "usesSpeed": true, + "usesIntensity": true + }, + { + "id": "gradient", + "name": "Gradient", + "category": 1, + "usesPalette": false, + "usesPrimaryColor": true, + "usesSecondaryColor": true, + "usesSpeed": true, + "usesIntensity": false + } + ] +} +``` + +**Categories:** +- `0` = Solid (static, no animation) +- `1` = Animated (motion/animation) +- `2` = Moving (positional movement) +- `3` = Special (complex or unique) + +**Parameter Flags:** +- `usesPalette` - Effect responds to palette changes +- `usesPrimaryColor` - Effect uses primary color picker +- `usesSecondaryColor` - Effect uses secondary color picker +- `usesSpeed` - Effect responds to speed parameter +- `usesIntensity` - Effect responds to intensity parameter + +These flags enable dynamic UI - only showing controls that are relevant for each effect. + +### GET /api/v2/palettes + +List all available color palettes. + +**Response:** +```json +{ + "palettes": [ + {"id": 0, "name": "Rainbow"}, + {"id": 1, "name": "Lava"}, + {"id": 2, "name": "Ocean"}, + {"id": 3, "name": "Party"}, + {"id": 4, "name": "Forest"}, + {"id": 5, "name": "Cloud"}, + {"id": 6, "name": "Heat"} + ] +} +``` + +### GET /api/v2/info + +Lightweight metadata endpoint for UIs to discover firmware details and capability limits. + +**Response:** +```json +{ + "firmware": { + "name": "LUME", + "version": "1.0.0", + "buildHash": "dev", + "buildTimestamp": "2025-12-30 18:42:10" + }, + "limits": { + "maxLeds": 300, + "maxSegments": 8, + "maxRequestBody": 16384 + }, + "features": { + "segmentsV2": true, + "directPixels": true, + "sacn": true, + "mqtt": true, + "aiPrompts": true, + "ota": true + }, + "controller": { + "ledCount": 160, + "power": true + } +} +``` + +--- + +## Known Limitations + +### 1. Palette Retrieval + +**Issue:** Segments store converted `CRGBPalette16` objects, not the `PalettePreset` enum value that was set. + +**Impact:** +- ✅ Can SET palette via preset ID +- ❌ Cannot GET which preset is currently active + +**Workaround:** Track palette preset client-side if needed. + +**Future Fix:** Add palette preset tracking field to Segment class. + +### 2. Reverse Flag Immutable + +**Issue:** The `reverse` flag can only be set during segment creation via `createSegment(start, length, reversed)`. + +**Impact:** +- ✅ Can set reverse during creation +- ❌ Cannot change reverse after creation + +**Workaround:** Delete and recreate segment with new reverse value. + +**Architectural:** By design - reverse is part of the SegmentView setup. + +--- + +## System & Status Endpoints + +### GET /health + +Quick health check endpoint. + +**Response:** +```json +{ + "status": "ok", + "uptime": 12345, + "freeHeap": 234567, + "wifiRSSI": -45 +} +``` + +### GET /api/status + +Full system status. + +**Response:** +```json +{ + "online": true, + "ip": "192.168.1.100", + "uptime": 12345, + "wifi": { + "ssid": "MyNetwork", + "rssi": -45, + "connected": true + }, + "led": { + "count": 160, + "power": true, + "brightness": 200, + "fps": 60 + }, + "protocols": { + "sacn": {"enabled": false}, + "mqtt": {"enabled": true, "connected": true} + } +} +``` + +### GET /api/config + +Get device configuration (passwords/API keys masked). + +**Response:** +```json +{ + "wifiSSID": "MyNetwork", + "ledCount": 160, + "aiApiKey": "****", + "aiApiKeySet": true, + "aiModel": "claude-3-5-sonnet-20241022", + "sacnEnabled": false, + "mqttEnabled": true, + "mqttBroker": "192.168.1.10", + "mqttPort": 1883 +} +``` + +### POST /api/config + +Update device configuration. + +**Request:** +```json +{ + "wifiSSID": "NewNetwork", + "wifiPassword": "newpass", + "ledCount": 160, + "aiApiKey": "sk-ant-...", + "aiModel": "claude-3-5-sonnet-20241022", + "sacnEnabled": true, + "mqttEnabled": true, + "mqttBroker": "192.168.1.10" +} +``` + +**Notes:** +- Omit password fields to leave unchanged +- Device restarts after WiFi changes + +### POST /api/pixels + +Direct pixel control (bypasses effects). + +**Request:** +```json +{ + "pixels": [ + [255, 0, 0], + [0, 255, 0], + [0, 0, 255] + ] +} +``` + +**Notes:** +- Array of [r,g,b] triplets +- Maps directly to LED positions +- Overrides active effects until next effect update + +--- + +## AI & Automation Endpoints + +### POST /api/prompt + +Send natural language prompt to AI for LED control. + +**Request:** +```json +{ + "prompt": "cozy warm fireplace" +} +``` + +**Response:** +```json +{ + "success": true, + "message": "Lights updated successfully!", + "spec": { + "effect": "fire", + "speed": 180, + "intensity": 200, + "primaryColor": [255, 80, 0], + "secondaryColor": [255, 40, 0] + } +} +``` + +**Notes:** +- Requires AI API key configured in settings +- Uses Anthropic Claude API +- Automatically selects best effect and colors from natural language +- Applied to segment 0 + +### GET /api/nightlight + +Get nightlight status. + +**Response:** +```json +{ + "active": false, + "progress": 0.0 +} +``` + +**Response (active):** +```json +{ + "active": true, + "progress": 45.5 +} +``` + +### POST /api/nightlight + +Start nightlight fade timer. + +**Request:** +```json +{ + "duration": 900, + "targetBrightness": 0 +} +``` + +**Parameters:** +- `duration` (uint16, 1-3600) - Fade duration in seconds +- `targetBrightness` (uint8, 0-255) - Target brightness (0 = turn off) + +**Response:** +```json +{ + "success": true, + "duration": 900, + "targetBrightness": 0, + "startBrightness": 200 +} +``` + +### POST /api/nightlight/stop + +Cancel active nightlight. + +**Response:** +```json +{ + "success": true +} +``` + +--- + +## Examples + +### Create Two Segments with Different Effects + +```bash +# First half - fire effect +curl -X POST http://lume.local/api/v2/segments \ + -H "Content-Type: application/json" \ + -d '{ + "start": 0, + "length": 80, + "effect": "fire", + "speed": 200, + "primaryColor": [255, 60, 0] + }' + +# Second half - rainbow effect +curl -X POST http://lume.local/api/v2/segments \ + -H "Content-Type: application/json" \ + -d '{ + "start": 80, + "length": 80, + "effect": "rainbow", + "speed": 150 + }' +``` + +### Update Controller Brightness + +```bash +curl -X PUT http://lume.local/api/v2/controller \ + -H "Content-Type: application/json" \ + -d '{"brightness": 200}' +``` + +### List All Available Effects + +```bash +curl http://lume.local/api/v2/effects | jq '.effects[] | .name' +``` + +--- + +## Implementation Notes + +- All JSON uses ArduinoJson library +- Request bodies limited to 16KB (`MAX_REQUEST_BODY_SIZE`) +- Async body handling with chunk accumulation +- All endpoints return JSON (except 404s from routing issues) +- Segment IDs are stable (0-7, assigned sequentially) +- Non-overlapping segments enforced by controller +- Color arrays are `[r, g, b]` format, values 0-255 + +**Routing Implementation:** + +ESPAsyncWebServer has **very limited regex support**. Patterns like `^\\/api\\/v2\\/segments\\/([0-9]+)$` don't work as expected. + +**Solution:** Manual path inspection using lambda handlers: + +```cpp +// GET - handles both /api/v2/segments and /api/v2/segments/{id} +server.on("/api/v2/segments", HTTP_GET, [](AsyncWebServerRequest* request) { + String path = request->url(); + if (path.startsWith("/api/v2/segments/") && path.length() > 17) { + handleApiV2SegmentGet(request); // Has ID + } else { + handleApiV2SegmentsList(request); // No ID + } +}); + +// PUT - validates path before delegating to body handler +server.on("/api/v2/segments", HTTP_PUT, + [](AsyncWebServerRequest* request) { + String path = request->url(); + if (!path.startsWith("/api/v2/segments/") || path.length() <= 17) { + request->send(400, "application/json", "{\"error\":\"Segment ID required\"}"); + } + }, + NULL, + [](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + String path = request->url(); + if (path.startsWith("/api/v2/segments/") && path.length() > 17) { + handleApiV2SegmentUpdate(request, data, len, index, total); + } + } +); +``` + +The handler functions then extract the ID using `lastIndexOf('/')` and `substring()`. + +--- + +## Migration from v1 API + +The v1 API (`/api/led`) remains available for backward compatibility with the existing web UI: + +| v1 Endpoint | v2 Equivalent | +|-------------|---------------| +| `GET /api/led` | `GET /api/v2/segments` (segment 0) | +| `POST /api/led` | `PUT /api/v2/segments/0` (when fixed) | +| `GET /api/segments` | `GET /api/v2/segments` | + +**Key Differences:** +- v2 supports multiple segments natively +- v2 exposes effect metadata and capabilities +- v2 has controller-level endpoints separate from segments +- v1 uses compatibility layer that translates to segment 0 + +--- + +## Status: Stable + +✅ **Working:** +- Controller state management +- Listing all segments +- Creating new segments +- Getting individual segments by ID +- Updating segments by ID +- Deleting segments by ID +- Effects and palettes metadata + +⚠️ **Known Limitations:** +- Palette preset retrieval (architectural - stores converted palettes) +- Reverse flag immutable after creation (by design) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 77022b0..d72ca50 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -9,9 +9,7 @@ This guide covers architecture, building, debugging, and contributing. ``` src/ ├── main.cpp # WiFi, web server, OTA, event loop -├── anthropic_client.* # FreeRTOS task for async LLM calls ├── storage.* # NVS persistence layer -├── web_ui.h # Embedded HTML/CSS/JS (PROGMEM) ├── constants.h # All configurable values ├── logging.h # Structured logging system ├── secrets.h # Your credentials (gitignored) @@ -23,6 +21,17 @@ src/ │ ├── effect_registry.h # Effect function registry with metadata │ ├── effect_params.h # Common effect parameters │ └── command_queue.h # Thread-safe command queue +├── api/ +│ ├── segments.* # v2 multi-segment API handlers +│ ├── config.* # Configuration endpoints +│ ├── status.* # Status and health endpoints +│ ├── pixels.* # Direct pixel control +│ ├── prompt.* # AI natural language control (Anthropic) +│ └── nightlight.* # Nightlight mode endpoints +├── network/ +│ ├── server.* # Web server setup and route registration +│ ├── wifi.* # WiFi connection management +│ └── ota.* # Over-the-air update handling ├── effects/ │ ├── effects.h # All effect declarations │ ├── solid.cpp # Solid color effect @@ -38,7 +47,14 @@ src/ │ └── ... (23 total) # See effects.h for full list └── protocols/ ├── protocol.h # Protocol interface + ProtocolBuffer - └── sacn.* # Self-contained sACN/E1.31 implementation + ├── sacn.* # Self-contained sACN/E1.31 implementation + └── mqtt.* # MQTT protocol support + +data/ # LittleFS web UI (uploaded separately) +├── index.html # Main web interface +└── assets/ + ├── app.js # Client-side JavaScript + └── app.css # Styles ``` ### Architecture Overview (v2) @@ -65,9 +81,10 @@ src/ **Data Flow:** ``` -Web UI → JSON POST → main.cpp handler → lume::controller → Segment → Effect -AI Prompt → anthropic_client task → JSON spec → applyEffectSpec() → Segment +Web UI → JSON POST → api/* handlers → lume::controller → Segment → Effect +AI Prompt → api/prompt → Anthropic API → JSON spec → controller → Segment sACN network → SacnProtocol (UDP, multicast, E1.31) → ProtocolBuffer → controller.update() → FastLED +MQTT → MqttProtocol → controller commands → Segment → Effect ``` ### Concurrency & Single-Writer Model @@ -102,6 +119,9 @@ pio run # Build and upload via USB (required for first flash) pio run -t upload + +# Upload web UI filesystem (do this after first firmware flash) +pio run -t uploadfs ``` ### Over-The-Air (OTA) Updates @@ -262,73 +282,7 @@ if (index + len >= total) { ## Adding a New Effect -Effects are pure functions registered with metadata. Each effect lives in its own `.cpp` file: - -1. Create a new file `src/effects/meteor.cpp`: - ```cpp - #include "effects.h" - #include "../core/effect_registry.h" - - namespace lume { - - void effectMeteor(SegmentView& view, const EffectParams& params, - uint32_t frame, bool firstFrame) { - // Access segment parameters - uint8_t speed = params.speed; - CRGB color = params.colors[0]; - - // For stateful effects, use segment scratchpad - // (available via the segment, not shown here) - - // Write to LEDs via the view - for (uint16_t i = 0; i < view.length; i++) { - view[i] = color; // Handles reversal automatically - } - } - - // Register the effect with metadata - REGISTER_EFFECT("meteor", "Meteor", EffectCategory::Moving, effectMeteor); - - } // namespace lume - ``` - -2. Add the include to `src/effects/effects.h`: - ```cpp - #include "meteor.cpp" - ``` - -**Effect function signature:** -- `SegmentView& view` - Virtual LED range (use `view[i]` to access LEDs) -- `const EffectParams& params` - Speed, intensity, colors, palette -- `uint32_t frame` - Frame counter for timing -- `bool firstFrame` - True when effect just started (initialize state) - -**Using scratchpad for state:** -```cpp -struct MeteorState { - uint16_t position; - uint8_t trail[64]; -}; - -void effectMeteor(SegmentView& view, const EffectParams& params, - uint32_t frame, bool firstFrame) { - // Validate scratchpad size (checked at registration time) - static_assert(sizeof(MeteorState) <= 512, "State too large"); - - // Get typed pointer to scratchpad - auto* state = reinterpret_cast(params.scratchpad); - - if (firstFrame) { - state->position = 0; - memset(state->trail, 0, sizeof(state->trail)); - } - - // Use state->position, state->trail, etc. -} - -// Register with state size validation -REGISTER_EFFECT("meteor", "Meteor", EffectCategory::Moving, effectMeteor); -``` +See [ADDING_EFFECTS.md](ADDING_EFFECTS.md) for the complete guide to creating custom LED effects with registration macros, parameter usage, and best practices. --- @@ -408,14 +362,16 @@ logMemoryStats(LogTag::MAIN, "after wifi connect"); --- -## FreeRTOS Tasks +## AI Integration + +The AI prompt feature uses Anthropic's Claude API via synchronous HTTPS requests: -| Task | Core | Priority | Stack | Purpose | -|------|------|----------|-------|---------| -| Main loop | 1 | 1 | Default | LED updates, web server | -| Anthropic | 0 | 5 | 16KB | Async HTTPS calls | +- Configure API key and model in web UI settings +- Requests are handled directly in the web handler (no background tasks) +- System prompt includes available effects and their parameters +- Response is parsed as JSON and applied to the controller -The AI client runs on Core 0 to avoid blocking LED updates on Core 1. +See [api/prompt.cpp](../src/api/prompt.cpp) for implementation. --- @@ -471,4 +427,4 @@ curl -X POST http://lume.local/api/pixels \ - [ESPAsyncWebServer](https://github.com/me-no-dev/ESPAsyncWebServer) - [ArduinoJson](https://arduinojson.org/) - [E1.31/sACN Specification](https://tsp.esta.org/tsp/documents/docs/ANSI_E1-31-2018.pdf) -- [OpenRouter API](https://openrouter.ai/docs) +- [Anthropic API](https://docs.anthropic.com/) diff --git a/docs/MQTT.md b/docs/MQTT.md index 7d05bf0..1a5d70f 100644 --- a/docs/MQTT.md +++ b/docs/MQTT.md @@ -198,7 +198,7 @@ client.disconnect() All 23 built-in effects can be set via MQTT: -`solid`, `rainbow`, `confetti`, `fire`, `colorwaves`, `theater`, `gradient`, `sparkle`, `pulse`, `noise`, `meteor`, `twinkle`, `sinelon`, `candle`, `breathe`, `dots`, `juggle`, `bpm`, `larson`, `cylon`, `lightning`, `ripple`, `pacifica` +`solid`, `rainbow`, `confetti`, `fire`, `fireup`, `colorwaves`, `wave`, `theater`, `gradient`, `sparkle`, `pulse`, `noise`, `meteor`, `comet`, `rain`, `twinkle`, `strobe`, `sinelon`, `scanner`, `candle`, `breathe`, `pride`, `pacifica` --- diff --git a/docs/API.md b/docs/archive/API.md similarity index 99% rename from docs/API.md rename to docs/archive/API.md index 43e1580..b416a20 100644 --- a/docs/API.md +++ b/docs/archive/API.md @@ -38,6 +38,7 @@ curl "http://lume.local/api/led?token=YOUR_TOKEN" -X POST -d '...' | `/health` | GET | System health & diagnostics | | `/api/status` | GET | Device status | | `/api/config` | GET/POST | Configuration | +| `/api/v2/info` | GET | Firmware metadata & limits | | `/api/segments` | GET | List segments & effects | | `/api/led` | GET/POST | LED state control (legacy) | | `/api/prompt` | POST | AI effect generation | diff --git a/src/anthropic_client.cpp b/docs/archive/anthropic_client.cpp similarity index 100% rename from src/anthropic_client.cpp rename to docs/archive/anthropic_client.cpp diff --git a/src/anthropic_client.h b/docs/archive/anthropic_client.h similarity index 100% rename from src/anthropic_client.h rename to docs/archive/anthropic_client.h diff --git a/docs/archive/led.cpp b/docs/archive/led.cpp new file mode 100644 index 0000000..1a9090e --- /dev/null +++ b/docs/archive/led.cpp @@ -0,0 +1,156 @@ +#include "led.h" +#include "../main.h" +#include "../constants.h" +#include "../logging.h" +#include "../storage.h" +#include "../lume.h" + +// External globals +extern Config config; +extern Storage storage; + +// Static body buffer for async request handling +static String ledBodyBuffer; + +// =========================================================================== +// v1 API Compatibility Helpers (local to this file) +// =========================================================================== + +// Get/create main segment (segment 0) +static lume::Segment* getMainSegment() { + lume::Segment* seg = lume::controller.getSegment(0); + if (!seg) { + seg = lume::controller.createFullStrip(); + } + return seg; +} + +// Map v1 palette names to v2 enum +static lume::PalettePreset mapPaletteName(const char* name) { + if (!name) return lume::PalettePreset::Rainbow; + String pal = name; + pal.toLowerCase(); + if (pal == "lava") return lume::PalettePreset::Lava; + if (pal == "ocean") return lume::PalettePreset::Ocean; + if (pal == "party") return lume::PalettePreset::Party; + if (pal == "forest") return lume::PalettePreset::Forest; + if (pal == "cloud") return lume::PalettePreset::Cloud; + if (pal == "heat") return lume::PalettePreset::Heat; + return lume::PalettePreset::Rainbow; +} + +// Serialize segment 0 to v1 API format +static void segmentToV1Json(JsonDocument& doc) { + doc["power"] = lume::controller.getPower(); + doc["brightness"] = lume::controller.getBrightness(); + + lume::Segment* seg = getMainSegment(); + if (seg) { + doc["effect"] = seg->getEffectId(); + doc["speed"] = seg->getSpeed(); + + CRGB primary = seg->getPrimaryColor(); + CRGB secondary = seg->getSecondaryColor(); + + JsonArray primaryArr = doc["primaryColor"].to(); + primaryArr.add(primary.r); + primaryArr.add(primary.g); + primaryArr.add(primary.b); + + JsonArray secondaryArr = doc["secondaryColor"].to(); + secondaryArr.add(secondary.r); + secondaryArr.add(secondary.g); + secondaryArr.add(secondary.b); + + doc["palette"] = "rainbow"; // v2 doesn't track preset name + } +} + +// Apply v1 JSON to segment 0 +static void v1JsonToSegment(const JsonDocument& doc) { + lume::Segment* seg = getMainSegment(); + if (!seg) return; + + if (doc["power"].is()) { + lume::controller.setPower(doc["power"].as()); + } + if (doc["brightness"].is()) { + lume::controller.setBrightness(constrain(doc["brightness"].as(), 0, 255)); + } + if (doc["effect"].is()) { + seg->setEffect(doc["effect"].as()); + } + if (doc["speed"].is()) { + seg->setSpeed(constrain(doc["speed"].as(), 1, 255)); + } + if (doc["palette"].is()) { + seg->setPalette(mapPaletteName(doc["palette"].as())); + } + + // Primary color + if (doc["primaryColor"].is()) { + JsonArrayConst arr = doc["primaryColor"].as(); + if (arr.size() >= 3) { + seg->setPrimaryColor(CRGB(arr[0].as(), arr[1].as(), arr[2].as())); + } + } + + // Secondary color + if (doc["secondaryColor"].is()) { + JsonArrayConst arr = doc["secondaryColor"].as(); + if (arr.size() >= 3) { + seg->setSecondaryColor(CRGB(arr[0].as(), arr[1].as(), arr[2].as())); + } + } +} + +// =========================================================================== +// v1 API Endpoints (deprecated - use /api/v2/segments instead) +// =========================================================================== + +void handleApiLed(AsyncWebServerRequest* request) { + JsonDocument doc; + segmentToV1Json(doc); + + String response; + serializeJson(doc, response); + request->send(200, "application/json", response); +} + +void handleApiLedPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + // Auth check at start of request + if (index == 0 && !checkAuth(request)) { + sendUnauthorized(request); + return; + } + + if (index == 0) { + ledBodyBuffer = ""; + if (total > MAX_REQUEST_BODY_SIZE) { + request->send(413, "application/json", "{\"error\":\"Request body too large\"}"); + return; + } + } + + ledBodyBuffer += String((char*)data).substring(0, len); + + if (index + len >= total) { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, ledBodyBuffer); + + if (err) { + request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); + return; + } + + // Apply state to segment 0 + v1JsonToSegment(doc); + + // Save state to storage + JsonDocument saveDoc; + segmentToV1Json(saveDoc); + storage.saveLedState(saveDoc); + + request->send(200, "application/json", "{\"success\":true}"); + } +} diff --git a/docs/archive/led.h b/docs/archive/led.h new file mode 100644 index 0000000..9508386 --- /dev/null +++ b/docs/archive/led.h @@ -0,0 +1,7 @@ +#pragma once + +#include + +// LED state control - backward compatibility with v1 API +void handleApiLed(AsyncWebServerRequest* request); +void handleApiLedPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); diff --git a/docs/archive/prompt.cpp b/docs/archive/prompt.cpp new file mode 100644 index 0000000..87a927c --- /dev/null +++ b/docs/archive/prompt.cpp @@ -0,0 +1,228 @@ +/** + * prompt.cpp - AI Prompt API implementation + */ + +#include "prompt.h" +#include "../main.h" +#include "../constants.h" +#include "../logging.h" +#include "../storage.h" +#include "../anthropic_client.h" +#include "../core/controller.h" +#include + +// Request body buffers for async handling +static String promptBodyBuffer; +static String applyBodyBuffer; + +void handleApiPrompt(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + // Auth check at start of request + if (index == 0 && !checkAuth(request)) { + sendUnauthorized(request); + return; + } + + if (index == 0) { + promptBodyBuffer = ""; + if (total > MAX_REQUEST_BODY_SIZE) { + request->send(413, "application/json", "{\"error\":\"Request body too large\"}"); + return; + } + } + + // Rate limiting for prompt endpoint + static unsigned long lastPromptRequest = 0; + if (index == 0 && millis() - lastPromptRequest < PROMPT_RATE_LIMIT_MS) { + request->send(429, "application/json", "{\"error\":\"Rate limited. Please wait before submitting another prompt.\"}"); + return; + } + + promptBodyBuffer += String((char*)data).substring(0, len); + + if (index + len >= total) { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, promptBodyBuffer); + + if (err) { + request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); + return; + } + + String prompt = doc["prompt"] | ""; + if (prompt.length() == 0) { + request->send(400, "application/json", "{\"error\":\"Missing prompt\"}"); + return; + } + + // Check if job already running + if (openRouterClient.isJobRunning()) { + request->send(409, "application/json", "{\"error\":\"Job already running\"}"); + return; + } + + // Get API key (from request or config) + String apiKey = doc["apiKey"] | config.apiKey; + if (apiKey.length() == 0 || apiKey.startsWith("****")) { + apiKey = config.apiKey; + } + + if (apiKey.length() == 0) { + request->send(400, "application/json", "{\"error\":\"API key not configured\"}"); + return; + } + + // Build request + PromptRequest req; + req.prompt = prompt; + req.apiKey = apiKey; + req.model = doc["model"] | config.openRouterModel; + + // Include current LED state for context + JsonDocument ledDoc; + controllerStateToJson(ledDoc); // v2 adapter + serializeJson(ledDoc, req.currentLedStateJson); + + // Submit job + if (openRouterClient.submitPrompt(req)) { + lastPromptRequest = millis(); // Update rate limit timestamp + request->send(200, "application/json", "{\"success\":true,\"message\":\"Job started\"}"); + } else { + request->send(500, "application/json", "{\"error\":\"Failed to start job\"}"); + } + } +} + +void handleApiPromptStatus(AsyncWebServerRequest* request) { + JsonDocument doc; + + PromptJobResult& result = openRouterClient.getJobResult(); + + switch (result.state) { + case PromptJobState::IDLE: doc["state"] = "idle"; break; + case PromptJobState::QUEUED: doc["state"] = "queued"; break; + case PromptJobState::RUNNING: doc["state"] = "running"; break; + case PromptJobState::DONE: doc["state"] = "done"; break; + case PromptJobState::ERROR: doc["state"] = "error"; break; + } + + doc["message"] = result.message; + + // Debug info + if (result.prompt.length() > 0) { + doc["prompt"] = result.prompt; + } + if (result.rawResponse.length() > 0) { + doc["rawResponse"] = result.rawResponse; + } + + if (result.state == PromptJobState::DONE && result.effectSpec.length() > 0) { + doc["lastSpec"] = result.effectSpec; + } + + if (result.startTime > 0) { + unsigned long elapsed = (result.endTime > 0 ? result.endTime : millis()) - result.startTime; + doc["elapsed"] = elapsed; + } + + String response; + serializeJson(doc, response); + request->send(200, "application/json", response); +} + +void handleApiPromptApply(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + // Auth check at start of request + if (index == 0 && !checkAuth(request)) { + sendUnauthorized(request); + return; + } + + if (index == 0) { + applyBodyBuffer = ""; + if (total > MAX_REQUEST_BODY_SIZE) { + request->send(413, "application/json", "{\"error\":\"Request body too large\"}"); + return; + } + } + + // Properly append data with explicit length (safer than substring) + for (size_t i = 0; i < len; i++) { + applyBodyBuffer += (char)data[i]; + } + + if (index + len >= total) { + LOG_DEBUG(LogTag::WEB, "Apply body received (%d bytes)", applyBodyBuffer.length()); + + String specJson; + + // Check if spec provided in body + if (applyBodyBuffer.length() > 2) { + JsonDocument bodyDoc; + DeserializationError err = deserializeJson(bodyDoc, applyBodyBuffer); + + if (err) { + LOG_WARN(LogTag::WEB, "Failed to parse apply body: %s", err.c_str()); + } + + if (err == DeserializationError::Ok && bodyDoc["spec"].is()) { + specJson = bodyDoc["spec"].as(); + LOG_DEBUG(LogTag::WEB, "Extracted spec from body (%d chars)", specJson.length()); + } else if (err == DeserializationError::Ok) { + // Maybe spec is an object, not a string - try to serialize it + if (bodyDoc["spec"].is()) { + LOG_DEBUG(LogTag::WEB, "Spec is an object, serializing..."); + serializeJson(bodyDoc["spec"], specJson); + } + } + } + + // If no spec in body, use last generated + if (specJson.length() == 0) { + PromptJobResult& result = openRouterClient.getJobResult(); + if (result.state == PromptJobState::DONE && result.effectSpec.length() > 0) { + specJson = result.effectSpec; + } + } + + if (specJson.length() == 0) { + request->send(400, "application/json", "{\"error\":\"No effect specification to apply\"}"); + return; + } + + // Parse and apply + JsonDocument specDoc; + DeserializationError err = deserializeJson(specDoc, specJson); + + if (err) { + LOG_WARN(LogTag::LED, "Failed to parse spec JSON: %s", err.c_str()); + request->send(400, "application/json", "{\"error\":\"Invalid effect specification\"}"); + return; + } + + LOG_DEBUG(LogTag::LED, "Attempting to apply effect spec"); + if (LOG_LEVEL <= LogLevel::DEBUG) { + serializeJsonPretty(specDoc, Serial); + Serial.println(); + } + + String errorMsg; + if (applyEffectSpec(specDoc, errorMsg)) { // Use local adapter function + // Save to storage + PromptSpec spec; + spec.jsonSpec = specJson; + spec.timestamp = millis(); + spec.valid = true; + storage.savePromptSpec(spec); + + // Also save LED state + JsonDocument saveDoc; + controllerStateToJson(saveDoc); + storage.saveLedState(saveDoc); + + request->send(200, "application/json", "{\"success\":true}"); + } else { + LOG_WARN(LogTag::LED, "Failed to apply effect: %s", errorMsg.c_str()); + String error = "{\"error\":\"" + errorMsg + "\"}"; + request->send(400, "application/json", error); + } + } +} diff --git a/docs/archive/prompt.h b/docs/archive/prompt.h new file mode 100644 index 0000000..31d0a83 --- /dev/null +++ b/docs/archive/prompt.h @@ -0,0 +1,12 @@ +/** + * prompt.h - AI Prompt API handlers + */ + +#pragma once + +#include + +// AI Prompt API handlers +void handleApiPrompt(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); +void handleApiPromptStatus(AsyncWebServerRequest* request); +void handleApiPromptApply(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); diff --git a/docs/archive/scenes.cpp b/docs/archive/scenes.cpp new file mode 100644 index 0000000..454c83e --- /dev/null +++ b/docs/archive/scenes.cpp @@ -0,0 +1,236 @@ +/** + * scenes.cpp - Scene Management API implementation + * + * Note: Scenes are deprecated in v2 architecture. They stored v1 API format. + * Future implementation should store segment configurations directly. + * This is kept for backward compatibility but marked for redesign. + */ + +#include "scenes.h" +#include "../main.h" +#include "../constants.h" +#include "../logging.h" +#include "../storage.h" +#include "../lume.h" +#include + +// Request body buffer for async handling +static String sceneBodyBuffer; + +// Helper: Apply v1 scene spec to segment 0 (temporary - needs v2 redesign) +static bool applyV1SceneSpec(const JsonDocument& spec) { + lume::Segment* seg = lume::controller.getSegment(0); + if (!seg) { + seg = lume::controller.createFullStrip(); + if (!seg) return false; + } + + // Apply basic properties to segment 0 + if (spec["power"].is()) { + lume::controller.setPower(spec["power"].as()); + } + if (spec["brightness"].is()) { + lume::controller.setBrightness(constrain(spec["brightness"].as(), 0, 255)); + } + if (spec["effect"].is()) { + seg->setEffect(spec["effect"].as()); + } + if (spec["speed"].is()) { + seg->setSpeed(constrain(spec["speed"].as(), 1, 255)); + } + + // Colors + if (spec["primaryColor"].is()) { + JsonArrayConst arr = spec["primaryColor"].as(); + if (arr.size() >= 3) { + seg->setPrimaryColor(CRGB(arr[0].as(), arr[1].as(), arr[2].as())); + } + } + if (spec["secondaryColor"].is()) { + JsonArrayConst arr = spec["secondaryColor"].as(); + if (arr.size() >= 3) { + seg->setSecondaryColor(CRGB(arr[0].as(), arr[1].as(), arr[2].as())); + } + } + + return true; +} + +void handleApiScenesGet(AsyncWebServerRequest* request) { + JsonDocument doc; + storage.listScenes(doc); + + String response; + serializeJson(doc, response); + request->send(200, "application/json", response); +} + +void handleApiSceneGet(AsyncWebServerRequest* request) { + // Extract ID from URL path + String path = request->url(); + int lastSlash = path.lastIndexOf('/'); + int slotId = path.substring(lastSlash + 1).toInt(); + + Scene scene; + if (storage.loadScene(slotId, scene) && !scene.isEmpty()) { + JsonDocument doc; + doc["id"] = slotId; + doc["name"] = scene.name; + doc["spec"] = scene.jsonSpec; + + String response; + serializeJson(doc, response); + request->send(200, "application/json", response); + } else { + request->send(404, "application/json", "{\"error\":\"Scene not found\"}"); + } +} + +void handleApiScenePost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + // Auth check at start of request + if (index == 0 && !checkAuth(request)) { + sendUnauthorized(request); + return; + } + + // Accumulate body data + if (index == 0) { + sceneBodyBuffer = ""; + if (total > MAX_REQUEST_BODY_SIZE) { + request->send(413, "application/json", "{\"error\":\"Request body too large\"}"); + return; + } + } + + for (size_t i = 0; i < len; i++) { + sceneBodyBuffer += (char)data[i]; + } + + // Process when complete + if (index + len >= total) { + JsonDocument doc; + DeserializationError error = deserializeJson(doc, sceneBodyBuffer); + + if (error) { + request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); + return; + } + + String name = doc["name"] | ""; + String spec = doc["spec"] | ""; + + if (name.length() == 0) { + request->send(400, "application/json", "{\"error\":\"Scene name required\"}"); + return; + } + + if (spec.length() == 0) { + request->send(400, "application/json", "{\"error\":\"Scene spec required\"}"); + return; + } + + // Find empty slot or slot specified by 'id' + int slot = -1; + if (doc["id"].is()) { + slot = doc["id"].as(); + if (slot < 0 || slot >= MAX_SCENES) { + request->send(400, "application/json", "{\"error\":\"Invalid slot ID\"}"); + return; + } + } else { + // Find first empty slot + for (int i = 0; i < MAX_SCENES; i++) { + Scene existing; + if (!storage.loadScene(i, existing) || existing.isEmpty()) { + slot = i; + break; + } + } + + if (slot < 0) { + request->send(400, "application/json", "{\"error\":\"No empty slots. Delete a scene first.\"}"); + return; + } + } + + Scene scene; + scene.name = name; + scene.jsonSpec = spec; + + if (storage.saveScene(slot, scene)) { + JsonDocument response; + response["success"] = true; + response["id"] = slot; + response["name"] = name; + + String responseStr; + serializeJson(response, responseStr); + request->send(200, "application/json", responseStr); + } else { + request->send(500, "application/json", "{\"error\":\"Failed to save scene\"}"); + } + } +} + +void handleApiSceneDelete(AsyncWebServerRequest* request) { + if (!checkAuth(request)) { + sendUnauthorized(request); + return; + } + + // Extract ID from URL path + String path = request->url(); + int lastSlash = path.lastIndexOf('/'); + int slotId = path.substring(lastSlash + 1).toInt(); + + if (slotId < 0 || slotId >= MAX_SCENES) { + request->send(400, "application/json", "{\"error\":\"Invalid slot ID\"}"); + return; + } + + if (storage.deleteScene(slotId)) { + request->send(200, "application/json", "{\"success\":true}"); + } else { + request->send(500, "application/json", "{\"error\":\"Failed to delete scene\"}"); + } +} + +void handleApiSceneApply(AsyncWebServerRequest* request) { + if (!checkAuth(request)) { + sendUnauthorized(request); + return; + } + + // Extract ID from URL path /api/scenes/{id}/apply + String path = request->url(); + + // Remove "/apply" from end + path = path.substring(0, path.lastIndexOf('/')); + int lastSlash = path.lastIndexOf('/'); + int slotId = path.substring(lastSlash + 1).toInt(); + + Scene scene; + if (!storage.loadScene(slotId, scene) || scene.isEmpty()) { + request->send(404, "application/json", "{\"error\":\"Scene not found\"}"); + return; + } + + // Parse and apply the scene spec + JsonDocument doc; + DeserializationError error = deserializeJson(doc, scene.jsonSpec); + + if (error) { + request->send(400, "application/json", "{\"error\":\"Invalid scene spec\"}"); + return; + } + + String errorMsg; + if (!applyV1SceneSpec(doc)) { + request->send(500, "application/json", "{\"error\":\"Failed to apply scene\"}"); + return; + } + + // Save the new LED state + // TODO: v2 should save segment configurations, not v1 format + request->send(200, "application/json", "{\"success\":true}"); +} diff --git a/docs/archive/scenes.h b/docs/archive/scenes.h new file mode 100644 index 0000000..e00dc98 --- /dev/null +++ b/docs/archive/scenes.h @@ -0,0 +1,14 @@ +/** + * scenes.h - Scene Management API handlers + */ + +#pragma once + +#include + +// Scene Management API handlers +void handleApiScenesGet(AsyncWebServerRequest* request); +void handleApiSceneGet(AsyncWebServerRequest* request); +void handleApiScenePost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); +void handleApiSceneDelete(AsyncWebServerRequest* request); +void handleApiSceneApply(AsyncWebServerRequest* request); diff --git a/docs/archive/web_ui_v1.h b/docs/archive/web_ui_v1.h new file mode 100644 index 0000000..71b648a --- /dev/null +++ b/docs/archive/web_ui_v1.h @@ -0,0 +1,1731 @@ +#ifndef WEB_UI_H +#define WEB_UI_H + +#include + +// Embedded HTML/CSS/JS for the web interface +// Modern, shadcn-inspired design with card-based layout + +const char INDEX_HTML[] PROGMEM = R"rawliteral( + + + + + + LUME + + + +
+
+ + +

LUME

+

AI-Powered LED Control

+
+ +
+
+
+ Connecting... +
+
+ -- +
+
+ -- +
+
+ + +
+
+ Power & Brightness + +
+ +
+
+ + 128 +
+ +
+
+ + +
+
+ Effect +
+ +
+ +
+
+ + Solid +
+
+ 🌈 + Rainbow +
+
+ 🎊 + Confetti +
+
+ 🔥 + Fire +
+
+ 🔥 + Fire Up +
+
+ 🌊 + Waves +
+
+ 〰️ + Wave +
+
+ 🎭 + Theater +
+
+ + Gradient +
+
+ + Sparkle +
+
+ 💓 + Pulse +
+
+ 🌬️ + Breathe +
+
+ 📺 + Noise +
+
+ ☄️ + Meteor +
+
+ 💫 + Comet +
+
+ 🌧️ + Rain +
+
+ + Twinkle +
+
+ + Strobe +
+
+ 🎯 + Sinelon +
+
+ 👁️ + Scanner +
+
+ 🕯️ + Candle +
+
+ 🏳️‍🌈 + Pride +
+
+ 🐚 + Pacifica +
+
+ +
+ +
+ +
+
+ Rainbow +
+
+
+ Lava +
+
+
+ Ocean +
+
+
+ Party +
+
+
+ Forest +
+
+
+ Cloud +
+
+
+ Heat +
+
+
+ Sunset +
+
+
+ Autumn +
+
+
+ Retro +
+
+
+ Ice +
+
+
+ Pink +
+
+
+ Custom +
+
+
+ +
+ +
+
+ + 100 +
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ 🌙 Nightlight + +
+ +
+
+
+ + 15 min +
+ +
+ +
+
+ + 0 (off) +
+ +
+ + + +
+ + +
+
+
+ + +
+
+ ✨ AI Effect Generator +
+ +
+ + +
+ + + +
+ + + +
+ +
+ +
No effect generated yet
+
+
+ + +
+
+ 🎬 Saved Scenes +
+ +
+

No saved scenes yet

+
+ +
+ +
+
+ + + +
+ +
+ + + + +)rawliteral"; + +#endif // WEB_UI_H diff --git a/src/web_ui.h b/docs/archive/web_ui_v2.h similarity index 100% rename from src/web_ui.h rename to docs/archive/web_ui_v2.h diff --git a/platformio.ini b/platformio.ini index f135b37..ba7deed 100644 --- a/platformio.ini +++ b/platformio.ini @@ -14,6 +14,7 @@ default_envs = lilygo-t-display-s3 platform = espressif32@6.9.0 board = lilygo-t-display-s3 framework = arduino +board_build.filesystem = littlefs ; Build flags build_flags = @@ -60,6 +61,7 @@ lib_deps = platform = espressif32@6.9.0 board = esp32-s3-devkitc-1 framework = arduino +board_build.filesystem = littlefs build_flags = -DCORE_DEBUG_LEVEL=1 diff --git a/src/api/config.cpp b/src/api/config.cpp new file mode 100644 index 0000000..80a869f --- /dev/null +++ b/src/api/config.cpp @@ -0,0 +1,94 @@ +#include "config.h" +#include "../main.h" +#include "../constants.h" +#include "../logging.h" +#include "../storage.h" +#include "../lume.h" +#include "../protocols/sacn.h" +#include "../protocols/mqtt.h" + +// External globals +extern Config config; +extern Storage storage; +extern bool wifiConnected; + +// Static body buffer for async request handling +static String configBodyBuffer; + +void handleApiConfig(AsyncWebServerRequest* request) { + JsonDocument doc; + storage.configToJson(config, doc, true); + + String response; + serializeJson(doc, response); + request->send(200, "application/json", response); +} + +void handleApiConfigPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + // Auth check at start of request + if (index == 0 && !checkAuth(request)) { + sendUnauthorized(request); + return; + } + + if (index == 0) { + configBodyBuffer = ""; + // Validate total size + if (total > MAX_REQUEST_BODY_SIZE) { + request->send(413, "application/json", "{\"error\":\"Request body too large\"}"); + return; + } + } + + configBodyBuffer += String((char*)data).substring(0, len); + + if (index + len >= total) { + // Body complete, process + JsonDocument doc; + DeserializationError err = deserializeJson(doc, configBodyBuffer); + + if (err) { + request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); + return; + } + + // Update config + storage.configFromJson(config, doc); + + // Save to storage + if (storage.saveConfig(config)) { + // Apply changes that can be applied without restart + lume::controller.setLedCount(config.ledCount); + + // Handle sACN enable/disable (using new protocol system) + if (config.sacnEnabled && wifiConnected) { + lume::sacnProtocol.stop(); + lume::sacnProtocol.configure(config.sacnUniverse, config.sacnUniverseCount, + config.sacnUnicast, config.sacnStartChannel); + lume::sacnProtocol.begin(); + } else { + lume::sacnProtocol.stop(); + } + + // Handle MQTT enable/disable + if (config.mqttEnabled && config.mqttBroker.length() > 0 && wifiConnected) { + lume::MqttConfig mqttConfig; + mqttConfig.enabled = config.mqttEnabled; + mqttConfig.broker = config.mqttBroker; + mqttConfig.port = config.mqttPort; + mqttConfig.username = config.mqttUsername; + mqttConfig.password = config.mqttPassword; + mqttConfig.topicPrefix = config.mqttTopicPrefix; + lume::mqtt.setConfig(mqttConfig); + } else { + lume::MqttConfig disabledConfig; + disabledConfig.enabled = false; + lume::mqtt.setConfig(disabledConfig); + } + + request->send(200, "application/json", "{\"success\":true}"); + } else { + request->send(500, "application/json", "{\"error\":\"Failed to save\"}"); + } + } +} diff --git a/src/api/config.h b/src/api/config.h new file mode 100644 index 0000000..ecbc86d --- /dev/null +++ b/src/api/config.h @@ -0,0 +1,7 @@ +#pragma once + +#include + +// System configuration management +void handleApiConfig(AsyncWebServerRequest* request); +void handleApiConfigPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); diff --git a/src/api/nightlight.cpp b/src/api/nightlight.cpp new file mode 100644 index 0000000..6c96b56 --- /dev/null +++ b/src/api/nightlight.cpp @@ -0,0 +1,91 @@ +/** + * nightlight.cpp - Nightlight API implementation + */ + +#include "nightlight.h" +#include "../main.h" +#include "../constants.h" +#include "../logging.h" +#include "../core/controller.h" +#include + +// Request body buffer for async handling +static String nightlightBodyBuffer; + +void handleApiNightlightGet(AsyncWebServerRequest* request) { + JsonDocument doc; + doc["active"] = lume::controller.isNightlightActive(); + doc["progress"] = lume::controller.getNightlightProgress(); + + String response; + serializeJson(doc, response); + request->send(200, "application/json", response); +} + +void handleApiNightlightPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + // Auth check at start of request + if (index == 0 && !checkAuth(request)) { + sendUnauthorized(request); + return; + } + + // Body size validation + if (index == 0) { + if (total > MAX_REQUEST_BODY_SIZE) { + request->send(413, "application/json", "{\"error\":\"Request too large\"}"); + return; + } + nightlightBodyBuffer = ""; + nightlightBodyBuffer.reserve(total); + } + + // Accumulate body chunks + nightlightBodyBuffer += String((char*)data, len); + + // Only process when complete + if (index + len < total) { + return; + } + + LOG_DEBUG(LogTag::WEB, "Nightlight request: %s", nightlightBodyBuffer.c_str()); + + JsonDocument doc; + DeserializationError error = deserializeJson(doc, nightlightBodyBuffer); + + if (error) { + request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); + return; + } + + // Get duration in seconds (default: 15 minutes = 900 seconds) + uint16_t duration = doc["duration"] | NIGHTLIGHT_DEFAULT_DURATION; + + // Validate duration (between 1 second and max) + if (duration < 1 || duration > NIGHTLIGHT_MAX_DURATION) { + JsonDocument response; + response["error"] = "Duration must be between 1 and " + String(NIGHTLIGHT_MAX_DURATION) + " seconds"; + String responseStr; + serializeJson(response, responseStr); + request->send(400, "application/json", responseStr); + return; + } + + // Get target brightness (default: 0 = fade to off) + uint8_t targetBrightness = doc["targetBrightness"] | NIGHTLIGHT_DEFAULT_TARGET; + + // Start nightlight + lume::controller.startNightlight(duration, targetBrightness); + + // Return status + JsonDocument response; + response["success"] = true; + response["duration"] = duration; + response["targetBrightness"] = targetBrightness; + response["startBrightness"] = lume::controller.getBrightness(); + + String responseStr; + serializeJson(response, responseStr); + request->send(200, "application/json", responseStr); + + LOG_INFO(LogTag::WEB, "Nightlight started: %ds fade to %d", duration, targetBrightness); +} diff --git a/src/api/nightlight.h b/src/api/nightlight.h new file mode 100644 index 0000000..56f791d --- /dev/null +++ b/src/api/nightlight.h @@ -0,0 +1,11 @@ +/** + * nightlight.h - Nightlight API handlers + */ + +#pragma once + +#include + +// Nightlight API handlers +void handleApiNightlightGet(AsyncWebServerRequest* request); +void handleApiNightlightPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); diff --git a/src/api/pixels.cpp b/src/api/pixels.cpp new file mode 100644 index 0000000..44e76a5 --- /dev/null +++ b/src/api/pixels.cpp @@ -0,0 +1,136 @@ +#include "pixels.h" +#include "../main.h" +#include "../constants.h" +#include "../logging.h" +#include "../lume.h" + +// Static body buffer for async request handling +static String pixelsBodyBuffer; + +// Direct pixel control handler +// Accepts: { "pixels": [[r,g,b], [r,g,b], ...], "brightness": 255 } +// Or compact: { "rgb": [r,g,b,r,g,b,...], "brightness": 255 } +void handleApiPixels(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + // Auth check at start of request + if (index == 0 && !checkAuth(request)) { + sendUnauthorized(request); + return; + } + + if (index == 0) { + pixelsBodyBuffer = ""; + if (total > MAX_REQUEST_BODY_SIZE) { + request->send(413, "application/json", "{\"error\":\"Request body too large\"}"); + return; + } + } + + // Append data safely + for (size_t i = 0; i < len; i++) { + pixelsBodyBuffer += (char)data[i]; + } + + if (index + len >= total) { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, pixelsBodyBuffer); + + if (err) { + LOG_WARN(LogTag::WEB, "Pixels JSON parse error: %s", err.c_str()); + request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); + return; + } + + CRGB* leds = lume::controller.getLeds(); + uint16_t ledCount = lume::controller.getLedCount(); + + // Handle brightness if provided + if (doc["brightness"].is()) { + lume::controller.setBrightness(constrain(doc["brightness"].as(), 0, 255)); + } + + // Method 1: Array of [r,g,b] arrays + if (doc["pixels"].is()) { + JsonArray pixels = doc["pixels"].as(); + uint16_t count = min((uint16_t)pixels.size(), ledCount); + + for (uint16_t i = 0; i < count; i++) { + JsonArray pixel = pixels[i].as(); + if (pixel.size() >= 3) { + leds[i].r = pixel[0].as(); + leds[i].g = pixel[1].as(); + leds[i].b = pixel[2].as(); + } + } + + FastLED.show(); + + JsonDocument response; + response["success"] = true; + response["pixelsSet"] = count; + String responseStr; + serializeJson(response, responseStr); + request->send(200, "application/json", responseStr); + return; + } + + // Method 2: Flat array [r,g,b,r,g,b,...] + if (doc["rgb"].is()) { + JsonArray rgb = doc["rgb"].as(); + uint16_t count = min((uint16_t)(rgb.size() / 3), ledCount); + + for (uint16_t i = 0; i < count; i++) { + leds[i].r = rgb[i * 3].as(); + leds[i].g = rgb[i * 3 + 1].as(); + leds[i].b = rgb[i * 3 + 2].as(); + } + + FastLED.show(); + + JsonDocument response; + response["success"] = true; + response["pixelsSet"] = count; + String responseStr; + serializeJson(response, responseStr); + request->send(200, "application/json", responseStr); + return; + } + + // Method 3: Fill all with single color + if (doc["fill"].is()) { + JsonArray fill = doc["fill"].as(); + if (!validateRgbArray(fill)) { + request->send(400, "application/json", "{\"error\":\"Fill requires array of [r,g,b] with 3 integer values (0-255)\"}"); + return; + } + CRGB color(fill[0].as(), fill[1].as(), fill[2].as()); + fill_solid(leds, ledCount, color); + FastLED.show(); + + request->send(200, "application/json", "{\"success\":true,\"filled\":true}"); + return; + } + + // Method 4: Gradient between two colors + if (doc["gradient"].is()) { + JsonObject grad = doc["gradient"].as(); + JsonArray from = grad["from"].as(); + JsonArray to = grad["to"].as(); + + if (!validateRgbArray(from) || !validateRgbArray(to)) { + request->send(400, "application/json", "{\"error\":\"Gradient requires 'from' and 'to' with [r,g,b] arrays\"}"); + return; + } + + CRGB startColor(from[0].as(), from[1].as(), from[2].as()); + CRGB endColor(to[0].as(), to[1].as(), to[2].as()); + + fill_gradient_RGB(leds, 0, startColor, ledCount - 1, endColor); + FastLED.show(); + + request->send(200, "application/json", "{\"success\":true,\"gradient\":true}"); + return; + } + + request->send(400, "application/json", "{\"error\":\"No valid pixel data. Use 'pixels', 'rgb', 'fill', or 'gradient'\"}"); + } +} diff --git a/src/api/pixels.h b/src/api/pixels.h new file mode 100644 index 0000000..1486a93 --- /dev/null +++ b/src/api/pixels.h @@ -0,0 +1,6 @@ +#pragma once + +#include + +// Direct pixel control - set individual LED colors via API +void handleApiPixels(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); diff --git a/src/api/prompt.cpp b/src/api/prompt.cpp new file mode 100644 index 0000000..fe07c82 --- /dev/null +++ b/src/api/prompt.cpp @@ -0,0 +1,276 @@ +/** + * prompt.cpp - AI prompt API implementation using Anthropic Claude + */ + +#include "prompt.h" +#include "../main.h" +#include "../constants.h" +#include "../logging.h" +#include "../storage.h" +#include "../core/controller.h" +#include "../core/effect_registry.h" +#include +#include +#include + +// Request body buffer for async handling +static String promptBodyBuffer; + +extern Config config; +extern bool checkAuth(AsyncWebServerRequest* request); +extern void sendUnauthorized(AsyncWebServerRequest* request); + +namespace { + +// Anthropic API endpoint +const char* ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"; +const char* ANTHROPIC_API_VERSION = "2023-06-01"; + +// Build system prompt with available effects and current state +String buildSystemPrompt() { + String prompt = "You are an LED lighting controller assistant. You control LED strips by selecting effects, colors, and parameters.\n\n"; + + prompt += "Available effects:\n"; + lume::EffectRegistry& registry = lume::effects(); + for (uint8_t i = 0; i < registry.getCount(); i++) { + const lume::EffectInfo* info = registry.getByIndex(i); + if (info) { + prompt += "- " + String(info->id) + ": " + String(info->displayName) + "\n"; + } + } + + prompt += "\nYour task: Parse the user's natural language request and respond with a JSON object that specifies:\n"; + prompt += "{\n"; + prompt += " \"effect\": \"effect_id\",\n"; + prompt += " \"speed\": 100, // 1-200\n"; + prompt += " \"intensity\": 128, // 0-255\n"; + prompt += " \"primaryColor\": [255, 0, 0], // RGB\n"; + prompt += " \"secondaryColor\": [0, 0, 255], // RGB\n"; + prompt += " \"brightness\": 128 // 0-255, optional\n"; + prompt += "}\n\n"; + prompt += "Match user intent to the most appropriate effect. For colors, interpret descriptions like 'warm', 'cool', 'cozy' into RGB values.\n"; + prompt += "Respond ONLY with the JSON object, no other text."; + + return prompt; +} + +// Call Anthropic API +bool callAnthropicAPI(const String& userPrompt, String& response, String& error) { + if (config.aiApiKey.length() == 0) { + error = "AI API key not configured"; + return false; + } + + WiFiClientSecure client; + client.setInsecure(); // For simplicity - in production, should verify cert + + HTTPClient http; + http.begin(client, ANTHROPIC_API_URL); + http.addHeader("Content-Type", "application/json"); + http.addHeader("x-api-key", config.aiApiKey); + http.addHeader("anthropic-version", ANTHROPIC_API_VERSION); + http.setTimeout(30000); // 30 second timeout + + // Build request + JsonDocument requestDoc; + requestDoc["model"] = config.aiModel; + requestDoc["max_tokens"] = 1024; + + JsonArray messages = requestDoc["messages"].to(); + JsonObject message = messages.add(); + message["role"] = "user"; + + String systemPrompt = buildSystemPrompt(); + String fullPrompt = systemPrompt + "\n\nUser request: " + userPrompt; + message["content"] = fullPrompt; + + String requestBody; + serializeJson(requestDoc, requestBody); + + LOG_DEBUG(LogTag::WEB, "Calling Anthropic API..."); + + int httpCode = http.POST(requestBody); + + if (httpCode == 200) { + String payload = http.getString(); + + JsonDocument responseDoc; + DeserializationError parseError = deserializeJson(responseDoc, payload); + + if (parseError) { + error = "Failed to parse API response"; + LOG_ERROR(LogTag::WEB, "JSON parse error: %s", parseError.c_str()); + http.end(); + return false; + } + + // Extract text from response + if (responseDoc["content"][0]["text"].is()) { + response = responseDoc["content"][0]["text"].as(); + http.end(); + return true; + } else { + error = "Invalid response format"; + http.end(); + return false; + } + } else { + error = "API error: " + String(httpCode); + if (httpCode > 0) { + String payload = http.getString(); + LOG_ERROR(LogTag::WEB, "API error %d: %s", httpCode, payload.c_str()); + } + http.end(); + return false; + } +} + +// Apply the AI-generated spec to the controller +bool applySpec(const JsonDocument& spec, String& error) { + lume::Segment* seg = lume::controller.getSegment(0); + if (!seg) { + error = "No active segment"; + return false; + } + + // Apply effect + if (spec["effect"].is()) { + const char* effectId = spec["effect"].as(); + if (!seg->setEffect(effectId)) { + LOG_WARN(LogTag::WEB, "Unknown effect: %s", effectId); + } + } + + // Apply speed + if (spec["speed"].is()) { + seg->setSpeed(constrain(spec["speed"].as(), 1, 200)); + } + + // Apply intensity + if (spec["intensity"].is()) { + seg->setIntensity(constrain(spec["intensity"].as(), 0, 255)); + } + + // Apply colors + if (spec["primaryColor"].is()) { + JsonArrayConst arr = spec["primaryColor"].as(); + if (arr.size() >= 3) { + seg->setPrimaryColor(CRGB(arr[0].as(), arr[1].as(), arr[2].as())); + } + } + + if (spec["secondaryColor"].is()) { + JsonArrayConst arr = spec["secondaryColor"].as(); + if (arr.size() >= 3) { + seg->setSecondaryColor(CRGB(arr[0].as(), arr[1].as(), arr[2].as())); + } + } + + // Apply brightness + if (spec["brightness"].is()) { + lume::controller.setBrightness(constrain(spec["brightness"].as(), 0, 255)); + } + + return true; +} + +} // namespace + +void handleApiPromptPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + // Auth check at start of request + if (index == 0 && !checkAuth(request)) { + sendUnauthorized(request); + return; + } + + // Body size validation + if (index == 0) { + if (total > MAX_REQUEST_BODY_SIZE) { + request->send(413, "application/json", "{\"error\":\"Request too large\"}"); + return; + } + promptBodyBuffer = ""; + promptBodyBuffer.reserve(total); + } + + // Accumulate body chunks + promptBodyBuffer += String((char*)data, len); + + // Only process when complete + if (index + len < total) { + return; + } + + JsonDocument doc; + DeserializationError error = deserializeJson(doc, promptBodyBuffer); + + if (error) { + request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); + return; + } + + if (!doc["prompt"].is()) { + request->send(400, "application/json", "{\"error\":\"Missing 'prompt' field\"}"); + return; + } + + String userPrompt = doc["prompt"].as(); + LOG_INFO(LogTag::WEB, "AI Prompt: %s", userPrompt.c_str()); + + // Call Anthropic API + String aiResponse; + String apiError; + + if (!callAnthropicAPI(userPrompt, aiResponse, apiError)) { + JsonDocument response; + response["success"] = false; + response["error"] = apiError; + + String output; + serializeJson(response, output); + request->send(500, "application/json", output); + return; + } + + LOG_DEBUG(LogTag::WEB, "AI Response: %s", aiResponse.c_str()); + + // Parse AI response as JSON spec + JsonDocument specDoc; + DeserializationError specError = deserializeJson(specDoc, aiResponse); + + if (specError) { + JsonDocument response; + response["success"] = false; + response["error"] = "AI returned invalid format"; + + String output; + serializeJson(response, output); + request->send(500, "application/json", output); + return; + } + + // Apply the spec + String applyError; + if (!applySpec(specDoc, applyError)) { + JsonDocument response; + response["success"] = false; + response["error"] = applyError; + + String output; + serializeJson(response, output); + request->send(500, "application/json", output); + return; + } + + // Success + JsonDocument response; + response["success"] = true; + response["message"] = "Lights updated successfully!"; + response["spec"] = specDoc; + + String output; + serializeJson(response, output); + request->send(200, "application/json", output); + + LOG_INFO(LogTag::WEB, "AI prompt applied successfully"); +} diff --git a/src/api/prompt.h b/src/api/prompt.h new file mode 100644 index 0000000..19f1221 --- /dev/null +++ b/src/api/prompt.h @@ -0,0 +1,10 @@ +/** + * prompt.h - AI prompt API handlers + */ + +#pragma once + +#include + +// AI prompt API handlers +void handleApiPromptPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); diff --git a/src/api/segments.cpp b/src/api/segments.cpp new file mode 100644 index 0000000..fd4b47a --- /dev/null +++ b/src/api/segments.cpp @@ -0,0 +1,526 @@ +#include "segments.h" +#include "../main.h" +#include "../storage.h" +#include "../logging.h" +#include "../constants.h" +#include "../core/controller.h" +#include "../core/effect_registry.h" +#include + +// External globals +extern Config config; +extern bool checkAuth(AsyncWebServerRequest* request); +extern void sendUnauthorized(AsyncWebServerRequest* request); + +// Request body buffers +static String segmentCreateBuffer; +static String segmentUpdateBuffer; +static String controllerUpdateBuffer; + +namespace { + +void sendJsonError(AsyncWebServerRequest* request, int status, const char* code, const char* message, const char* field = nullptr) { + JsonDocument doc; + doc["error"] = code; + doc["message"] = message; + if (field && field[0] != '\0') { + doc["field"] = field; + } + + String output; + serializeJson(doc, output); + request->send(status, "application/json", output); +} + +} // namespace + +// =========================================================================== +// Helper: Serialize segment to JSON +// =========================================================================== +void segmentToJson(JsonObject& obj, lume::Segment* segment, uint8_t id) { + obj["id"] = id; + obj["start"] = segment->getStart(); + obj["stop"] = segment->getStart() + segment->getLength() - 1; // Calculate stop from start + length + obj["length"] = segment->getLength(); + obj["effect"] = segment->getEffectId(); + obj["speed"] = segment->getSpeed(); + obj["intensity"] = segment->getIntensity(); + + // Colors + CRGB primary = segment->getPrimaryColor(); + CRGB secondary = segment->getSecondaryColor(); + + JsonArray primaryArr = obj["primaryColor"].to(); + primaryArr.add(primary.r); + primaryArr.add(primary.g); + primaryArr.add(primary.b); + + JsonArray secondaryArr = obj["secondaryColor"].to(); + secondaryArr.add(secondary.r); + secondaryArr.add(secondary.g); + secondaryArr.add(secondary.b); + + // Note: Palette preset is not included in response because segments store the + // converted CRGBPalette16, not the PalettePreset enum. Once converted, we can't + // determine which preset was originally used. Could be fixed by adding preset + // tracking to Segment class, but current implementation allows custom palettes. + + // Reverse flag + obj["reverse"] = segment->isReversed(); +} + +// =========================================================================== +// GET /api/v2/segments - List all segments +// =========================================================================== +void handleApiV2SegmentsList(AsyncWebServerRequest* request) { + if (!checkAuth(request)) { + sendUnauthorized(request); + return; + } + + JsonDocument doc; + + // Controller state + doc["power"] = lume::controller.getPower(); + doc["brightness"] = lume::controller.getBrightness(); + doc["ledCount"] = lume::controller.getLedCount(); + + // List all segments + JsonArray segments = doc["segments"].to(); + for (uint8_t i = 0; i < 8; i++) { // Max 8 segments + lume::Segment* seg = lume::controller.getSegment(i); + if (seg) { + JsonObject segObj = segments.add(); + segmentToJson(segObj, seg, i); + } + } + + String output; + serializeJson(doc, output); + request->send(200, "application/json", output); +} + +// =========================================================================== +// GET /api/v2/segments/{id} - Get specific segment +// =========================================================================== +void handleApiV2SegmentGet(AsyncWebServerRequest* request) { + if (!checkAuth(request)) { + sendUnauthorized(request); + return; + } + + // Parse segment ID from URL path + String path = request->url(); + int lastSlash = path.lastIndexOf('/'); + uint8_t id = path.substring(lastSlash + 1).toInt(); + + if (id > 7) { + sendJsonError(request, 400, "validation_error", "Segment ID must be between 0 and 7", "id"); + return; + } + + lume::Segment* seg = lume::controller.getSegment(id); + if (!seg) { + sendJsonError(request, 404, "not_found", "Segment not found", "id"); + return; + } + + JsonDocument doc; + JsonObject obj = doc.to(); + segmentToJson(obj, seg, id); + + String output; + serializeJson(doc, output); + request->send(200, "application/json", output); +} + +// =========================================================================== +// POST /api/v2/segments - Create new segment +// =========================================================================== +void handleApiV2SegmentCreate(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + if (!checkAuth(request)) { + sendUnauthorized(request); + return; + } + + // Validate size at first chunk + if (index == 0) { + segmentCreateBuffer = ""; + if (total > MAX_REQUEST_BODY_SIZE) { + sendJsonError(request, 413, "payload_too_large", "Request body exceeds MAX_REQUEST_BODY_SIZE"); + return; + } + } + + // Accumulate chunks + for (size_t i = 0; i < len; i++) { + segmentCreateBuffer += (char)data[i]; + } + + // Process when complete + if (index + len >= total) { + JsonDocument doc; + DeserializationError error = deserializeJson(doc, segmentCreateBuffer); + + if (error) { + LOG_ERROR(LogTag::WEB, "JSON parse error: %s", error.c_str()); + sendJsonError(request, 400, "invalid_json", "Unable to parse JSON payload"); + return; + } + + // Validate required fields + if (!doc["start"].is() || !doc["length"].is()) { + sendJsonError(request, 400, "validation_error", "Fields 'start' and 'length' are required", "start"); + return; + } + + uint16_t start = doc["start"].as(); + uint16_t length = doc["length"].as(); + bool reversed = doc["reverse"].is() ? doc["reverse"].as() : false; + + // Create segment + lume::Segment* seg = lume::controller.createSegment(start, length, reversed); + if (!seg) { + sendJsonError(request, 500, "creation_failed", "Failed to create segment"); + return; + } + + // Find segment ID + uint8_t segmentId = 0; + for (uint8_t i = 0; i < 8; i++) { + if (lume::controller.getSegment(i) == seg) { + segmentId = i; + break; + } + } + + // Apply optional settings + if (doc["effect"].is()) { + seg->setEffect(doc["effect"].as()); + } + if (doc["speed"].is()) { + seg->setSpeed(doc["speed"].as()); + } + if (doc["intensity"].is()) { + seg->setIntensity(doc["intensity"].as()); + } + // Note: Reverse flag is set at creation time via setRange(), not changeable after + + // Colors + if (doc["primaryColor"].is()) { + JsonArrayConst arr = doc["primaryColor"].as(); + if (arr.size() >= 3) { + seg->setPrimaryColor(CRGB(arr[0].as(), arr[1].as(), arr[2].as())); + } + } + if (doc["secondaryColor"].is()) { + JsonArrayConst arr = doc["secondaryColor"].as(); + if (arr.size() >= 3) { + seg->setSecondaryColor(CRGB(arr[0].as(), arr[1].as(), arr[2].as())); + } + } + + // Palette - use enum value or preset name + if (doc["palette"].is()) { + seg->setPalette(static_cast(doc["palette"].as())); + } + + // Return created segment + JsonDocument responseDoc; + JsonObject obj = responseDoc.to(); + segmentToJson(obj, seg, segmentId); + + String output; + serializeJson(responseDoc, output); + request->send(201, "application/json", output); + + LOG_INFO(LogTag::LED, "Created segment %d: start=%d length=%d", segmentId, start, length); + } +} + +// =========================================================================== +// PUT /api/v2/segments/{id} - Update segment +// =========================================================================== +void handleApiV2SegmentUpdate(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + if (!checkAuth(request)) { + sendUnauthorized(request); + return; + } + + // Parse segment ID from URL path + String path = request->url(); + int lastSlash = path.lastIndexOf('/'); + uint8_t id = path.substring(lastSlash + 1).toInt(); + + if (id > 7) { + sendJsonError(request, 400, "validation_error", "Segment ID must be between 0 and 7", "id"); + return; + } + + // Validate size at first chunk + if (index == 0) { + segmentUpdateBuffer = ""; + if (total > MAX_REQUEST_BODY_SIZE) { + sendJsonError(request, 413, "payload_too_large", "Request body exceeds MAX_REQUEST_BODY_SIZE"); + return; + } + } + + // Accumulate chunks + for (size_t i = 0; i < len; i++) { + segmentUpdateBuffer += (char)data[i]; + } + + // Process when complete + if (index + len >= total) { + lume::Segment* seg = lume::controller.getSegment(id); + if (!seg) { + sendJsonError(request, 404, "not_found", "Segment not found", "id"); + return; + } + + JsonDocument doc; + DeserializationError error = deserializeJson(doc, segmentUpdateBuffer); + + if (error) { + sendJsonError(request, 400, "invalid_json", "Unable to parse JSON payload"); + return; + } + + // Update fields if present + if (doc["effect"].is()) { + seg->setEffect(doc["effect"].as()); + } + if (doc["speed"].is()) { + seg->setSpeed(doc["speed"].as()); + } + if (doc["intensity"].is()) { + seg->setIntensity(doc["intensity"].as()); + } + // Note: Reverse flag is set at creation time, not changeable after + + if (doc["primaryColor"].is()) { + JsonArrayConst arr = doc["primaryColor"].as(); + if (arr.size() >= 3) { + seg->setPrimaryColor(CRGB(arr[0].as(), arr[1].as(), arr[2].as())); + } + } + if (doc["secondaryColor"].is()) { + JsonArrayConst arr = doc["secondaryColor"].as(); + if (arr.size() >= 3) { + seg->setSecondaryColor(CRGB(arr[0].as(), arr[1].as(), arr[2].as())); + } + } + // Palette - use enum value + if (doc["palette"].is()) { + seg->setPalette(static_cast(doc["palette"].as())); + } + + // Return updated segment + JsonDocument responseDoc; + JsonObject obj = responseDoc.to(); + segmentToJson(obj, seg, id); + + String output; + serializeJson(responseDoc, output); + request->send(200, "application/json", output); + + LOG_INFO(LogTag::LED, "Updated segment %d", id); + } +} + +// =========================================================================== +// DELETE /api/v2/segments/{id} - Remove segment +// =========================================================================== +void handleApiV2SegmentDelete(AsyncWebServerRequest* request) { + if (!checkAuth(request)) { + sendUnauthorized(request); + return; + } + + // Parse segment ID from URL path + String path = request->url(); + int lastSlash = path.lastIndexOf('/'); + uint8_t id = path.substring(lastSlash + 1).toInt(); + + if (id > 7) { + sendJsonError(request, 400, "validation_error", "Segment ID must be between 0 and 7", "id"); + return; + } + + if (!lume::controller.getSegment(id)) { + sendJsonError(request, 404, "not_found", "Segment not found", "id"); + return; + } + + lume::controller.removeSegment(id); + request->send(200, "application/json", "{\"success\":true}"); + LOG_INFO(LogTag::LED, "Deleted segment %d", id); +} + +// =========================================================================== +// GET /api/v2/effects - List available effects +// =========================================================================== +void handleApiV2EffectsList(AsyncWebServerRequest* request) { + JsonDocument doc; + JsonArray effects = doc["effects"].to(); + + // Iterate through effect registry + lume::EffectRegistry& registry = lume::effects(); + for (uint8_t i = 0; i < registry.getCount(); i++) { + const lume::EffectInfo* info = registry.getByIndex(i); + if (!info) continue; + + JsonObject effect = effects.add(); + effect["id"] = info->id; + effect["name"] = info->displayName; + effect["category"] = static_cast(info->category); + effect["usesPalette"] = info->usesPalette; + effect["usesPrimaryColor"] = info->usesPrimaryColor; + effect["usesSecondaryColor"] = info->usesSecondaryColor; + effect["usesSpeed"] = info->usesSpeed; + effect["usesIntensity"] = info->usesIntensity; + } + + String output; + serializeJson(doc, output); + request->send(200, "application/json", output); +} + +// =========================================================================== +// GET /api/v2/palettes - List available palettes +// =========================================================================== +void handleApiV2PalettesList(AsyncWebServerRequest* request) { + JsonDocument doc; + JsonArray palettes = doc["palettes"].to(); + + // Map palette enum to names + const char* paletteNames[] = { + "Rainbow", "Lava", "Ocean", "Party", "Forest", "Cloud", "Heat" + }; + + for (int i = 0; i < 7; i++) { + JsonObject pal = palettes.add(); + pal["id"] = i; + pal["name"] = paletteNames[i]; + } + + String output; + serializeJson(doc, output); + request->send(200, "application/json", output); +} + +// =========================================================================== +// GET /api/v2/info - Firmware & capability metadata +// =========================================================================== +void handleApiV2Info(AsyncWebServerRequest* request) { + if (!checkAuth(request)) { + sendUnauthorized(request); + return; + } + + JsonDocument doc; + JsonObject firmware = doc["firmware"].to(); + firmware["name"] = FIRMWARE_NAME; + firmware["version"] = FIRMWARE_VERSION; + firmware["buildHash"] = FIRMWARE_BUILD_HASH; + firmware["buildTimestamp"] = FIRMWARE_BUILD_TIMESTAMP; + + JsonObject limits = doc["limits"].to(); + limits["maxLeds"] = MAX_LED_COUNT; + limits["maxSegments"] = lume::MAX_SEGMENTS; + limits["maxRequestBody"] = MAX_REQUEST_BODY_SIZE; + + JsonObject features = doc["features"].to(); + features["segmentsV2"] = true; + features["directPixels"] = true; + features["sacn"] = config.sacnEnabled; + features["mqtt"] = config.mqttEnabled; + features["aiPrompts"] = true; + features["ota"] = true; + + JsonObject controllerInfo = doc["controller"].to(); + controllerInfo["ledCount"] = lume::controller.getLedCount(); + controllerInfo["power"] = lume::controller.getPower(); + + String output; + serializeJson(doc, output); + request->send(200, "application/json", output); +} + +// =========================================================================== +// GET /api/v2/controller - Get controller state +// =========================================================================== +void handleApiV2ControllerGet(AsyncWebServerRequest* request) { + if (!checkAuth(request)) { + sendUnauthorized(request); + return; + } + + JsonDocument doc; + doc["power"] = lume::controller.getPower(); + doc["brightness"] = lume::controller.getBrightness(); + doc["ledCount"] = lume::controller.getLedCount(); + + String output; + serializeJson(doc, output); + request->send(200, "application/json", output); +} + +// =========================================================================== +// PUT /api/v2/controller - Update controller state +// =========================================================================== +void handleApiV2ControllerUpdate(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + if (!checkAuth(request)) { + sendUnauthorized(request); + return; + } + + // Validate size at first chunk + if (index == 0) { + controllerUpdateBuffer = ""; + if (total > MAX_REQUEST_BODY_SIZE) { + sendJsonError(request, 413, "payload_too_large", "Request body exceeds MAX_REQUEST_BODY_SIZE"); + return; + } + } + + // Accumulate chunks + for (size_t i = 0; i < len; i++) { + controllerUpdateBuffer += (char)data[i]; + } + + // Process when complete + if (index + len >= total) { + JsonDocument doc; + DeserializationError error = deserializeJson(doc, controllerUpdateBuffer); + + if (error) { + sendJsonError(request, 400, "invalid_json", "Unable to parse JSON payload"); + return; + } + + // Update power + if (doc["power"].is()) { + lume::controller.setPower(doc["power"].as()); + LOG_INFO(LogTag::LED, "Power set to %s", doc["power"].as() ? "ON" : "OFF"); + } + + // Update brightness + if (doc["brightness"].is()) { + uint8_t bri = constrain(doc["brightness"].as(), 0, 255); + lume::controller.setBrightness(bri); + LOG_INFO(LogTag::LED, "Brightness set to %d", bri); + } + + // Return updated state + JsonDocument responseDoc; + responseDoc["power"] = lume::controller.getPower(); + responseDoc["brightness"] = lume::controller.getBrightness(); + responseDoc["ledCount"] = lume::controller.getLedCount(); + + String output; + serializeJson(responseDoc, output); + request->send(200, "application/json", output); + } +} diff --git a/src/api/segments.h b/src/api/segments.h new file mode 100644 index 0000000..afd7ad7 --- /dev/null +++ b/src/api/segments.h @@ -0,0 +1,25 @@ +#ifndef API_SEGMENTS_H +#define API_SEGMENTS_H + +#include + +// =========================================================================== +// V2 Segment API - Multi-segment LED control +// =========================================================================== +// Modern API supporting multiple LED segments with independent effects +// Replaces the v1 single-strip /api/led compatibility layer + +void handleApiV2SegmentsList(AsyncWebServerRequest* request); +void handleApiV2SegmentGet(AsyncWebServerRequest* request); +void handleApiV2SegmentCreate(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); +void handleApiV2SegmentUpdate(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); +void handleApiV2SegmentDelete(AsyncWebServerRequest* request); + +void handleApiV2EffectsList(AsyncWebServerRequest* request); +void handleApiV2PalettesList(AsyncWebServerRequest* request); +void handleApiV2Info(AsyncWebServerRequest* request); + +void handleApiV2ControllerGet(AsyncWebServerRequest* request); +void handleApiV2ControllerUpdate(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); + +#endif // API_SEGMENTS_H diff --git a/src/api/status.cpp b/src/api/status.cpp new file mode 100644 index 0000000..3ea2dc1 --- /dev/null +++ b/src/api/status.cpp @@ -0,0 +1,58 @@ +#include "status.h" +#include "../constants.h" +#include "../logging.h" +#include "../storage.h" +#include "../lume.h" +#include "../protocols/sacn.h" +#include "../protocols/mqtt.h" +#include +#include + +// External globals +extern Config config; +extern bool wifiConnected; +extern bool webUiAvailable; + +void handleRoot(AsyncWebServerRequest* request) { + if (webUiAvailable && LittleFS.exists("/index.html")) { + request->send(LittleFS, "/index.html", "text/html; charset=utf-8"); + return; + } + request->send(503, "text/plain", "Web UI not available"); +} + +void handleApiStatus(AsyncWebServerRequest* request) { + JsonDocument doc; + + doc["uptime"] = millis() / 1000; + doc["wifi"] = wifiConnected ? "Connected" : "AP Mode"; + doc["ip"] = wifiConnected ? WiFi.localIP().toString() : WiFi.softAPIP().toString(); + doc["heap"] = ESP.getFreeHeap(); + doc["ledCount"] = lume::controller.getLedCount(); + doc["power"] = lume::controller.getPower(); + + // sACN status (using new protocol system) + JsonObject sacn = doc["sacn"].to(); + sacn["enabled"] = config.sacnEnabled; + sacn["universe"] = config.sacnUniverse; + sacn["universeCount"] = config.sacnUniverseCount; + sacn["startChannel"] = config.sacnStartChannel; + sacn["unicast"] = config.sacnUnicast; + sacn["receiving"] = lume::sacnProtocol.isActive(); + sacn["packets"] = lume::sacnProtocol.getPacketCount(); + sacn["source"] = lume::sacnProtocol.getActiveSourceName(); + sacn["priority"] = lume::sacnProtocol.getActivePriority(); + if (lume::sacnProtocol.isActive()) { + sacn["lastPacketMs"] = millis() - lume::sacnProtocol.getLastPacketTime(); + } + + // MQTT status + JsonObject mqtt = doc["mqtt"].to(); + mqtt["enabled"] = config.mqttEnabled; + mqtt["broker"] = config.mqttBroker; + mqtt["connected"] = lume::mqtt.isConnected(); + + String response; + serializeJson(doc, response); + request->send(200, "application/json", response); +} diff --git a/src/api/status.h b/src/api/status.h new file mode 100644 index 0000000..4b0aa31 --- /dev/null +++ b/src/api/status.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +// Root route - serves main web UI +void handleRoot(AsyncWebServerRequest* request); + +// System status endpoint - returns JSON with uptime, WiFi, memory, etc. +void handleApiStatus(AsyncWebServerRequest* request); diff --git a/src/constants.h b/src/constants.h index 94fe8ba..056a2c9 100644 --- a/src/constants.h +++ b/src/constants.h @@ -1,6 +1,9 @@ #ifndef CONSTANTS_H #define CONSTANTS_H +#include +#include + // ============================================ // Project-wide Constants // ============================================ @@ -23,6 +26,7 @@ constexpr uint16_t SACN_PORT = 5568; // --- LED Configuration --- #define LED_DATA_PIN 21 // GPIO pin for LED data (change this for your wiring) + constexpr uint16_t MAX_LED_COUNT = 300; constexpr uint16_t LEDS_PER_UNIVERSE = 170; @@ -54,4 +58,12 @@ constexpr uint32_t WATCHDOG_TIMEOUT_SEC = 30; // Watchdog timeout in seconds #define FIRMWARE_VERSION "1.0.0" #define FIRMWARE_NAME "LUME" +#ifndef FIRMWARE_BUILD_HASH +#define FIRMWARE_BUILD_HASH "dev" +#endif + +#ifndef FIRMWARE_BUILD_TIMESTAMP +#define FIRMWARE_BUILD_TIMESTAMP __DATE__ " " __TIME__ +#endif + #endif // CONSTANTS_H diff --git a/src/core/controller.cpp b/src/core/controller.cpp index fb28526..616f361 100644 --- a/src/core/controller.cpp +++ b/src/core/controller.cpp @@ -17,6 +17,11 @@ LumeController::LumeController() , nextSegmentId(0) , power(true) , globalBrightness(255) + , nightlightActive(false) + , nightlightStartTime(0) + , nightlightDuration(0) + , nightlightStartBrightness(0) + , nightlightTargetBrightness(0) , protocolCount_(0) , protocolActive_(false) , activeProtocol_(nullptr) @@ -88,6 +93,27 @@ void LumeController::update() { fpsUpdateTime = now; } + // Update nightlight if active + if (nightlightActive) { + uint32_t elapsed = (now - nightlightStartTime) / 1000; // Convert to seconds + if (elapsed >= nightlightDuration) { + // Nightlight complete - set target brightness and stop + setBrightness(nightlightTargetBrightness); + if (nightlightTargetBrightness == 0) { + setPower(false); + } + nightlightActive = false; + LOG_INFO(LogTag::LED, "Nightlight complete"); + } else { + // Calculate current brightness based on progress + float progress = (float)elapsed / (float)nightlightDuration; + // Use int16_t to handle negative differences (fade down) + int16_t diff = (int16_t)nightlightTargetBrightness - (int16_t)nightlightStartBrightness; + int16_t newBri = (int16_t)nightlightStartBrightness + (int16_t)(diff * progress); + setBrightness((uint8_t)max((int16_t)0, min((int16_t)255, newBri))); + } + } + // Clear or handle power off if (!power) { FastLED.clear(); @@ -233,10 +259,27 @@ Segment* LumeController::createSegment(uint16_t start, uint16_t length, bool rev return nullptr; } - // Find first inactive slot or use next slot + // Find lowest available ID (reuse deleted IDs) + bool usedIds[MAX_SEGMENTS] = {false}; + for (uint8_t i = 0; i < segmentCount; i++) { + uint8_t id = segments[i].getId(); + if (id < MAX_SEGMENTS) { + usedIds[id] = true; + } + } + + uint8_t newId = 0; + for (uint8_t i = 0; i < MAX_SEGMENTS; i++) { + if (!usedIds[i]) { + newId = i; + break; + } + } + + // Add segment to next available slot Segment* seg = &segments[segmentCount]; seg->setRange(leds, start, actualLength, reversed); - seg->id = nextSegmentId++; + seg->id = newId; segmentCount++; return seg; @@ -362,4 +405,35 @@ void LumeController::processProtocols() { } } +void LumeController::startNightlight(uint16_t durationSeconds, uint8_t targetBrightness) { + nightlightActive = true; + nightlightStartTime = millis(); + nightlightDuration = durationSeconds; + nightlightStartBrightness = globalBrightness; + nightlightTargetBrightness = targetBrightness; + + LOG_INFO(LogTag::LED, "Nightlight started: %ds fade from %d to %d", + durationSeconds, nightlightStartBrightness, targetBrightness); +} + +void LumeController::stopNightlight() { + if (nightlightActive) { + nightlightActive = false; + LOG_INFO(LogTag::LED, "Nightlight stopped"); + } +} + +float LumeController::getNightlightProgress() const { + if (!nightlightActive) { + return 0.0f; + } + + uint32_t elapsed = (millis() - nightlightStartTime) / 1000; + if (elapsed >= nightlightDuration) { + return 1.0f; + } + + return (float)elapsed / (float)nightlightDuration; +} + } // namespace lume diff --git a/src/core/controller.h b/src/core/controller.h index 5509044..b8d0bc8 100644 --- a/src/core/controller.h +++ b/src/core/controller.h @@ -89,6 +89,13 @@ class LumeController { void setTargetFps(uint16_t fps) { targetFps = fps; } uint16_t getTargetFps() const { return targetFps; } + // --- Nightlight --- + + void startNightlight(uint16_t durationSeconds, uint8_t targetBrightness); + void stopNightlight(); + bool isNightlightActive() const { return nightlightActive; } + float getNightlightProgress() const; + // --- FastLED passthrough --- void setColorCorrection(CRGB correction) { @@ -155,6 +162,13 @@ class LumeController { bool power; uint8_t globalBrightness; + // Nightlight state + bool nightlightActive; + uint32_t nightlightStartTime; + uint16_t nightlightDuration; // in seconds + uint8_t nightlightStartBrightness; + uint8_t nightlightTargetBrightness; + // Protocol handling static constexpr uint8_t MAX_PROTOCOLS = 4; Protocol* protocols_[MAX_PROTOCOLS]; diff --git a/src/core/effect_registry.h b/src/core/effect_registry.h index 6b0eb0d..73be76b 100644 --- a/src/core/effect_registry.h +++ b/src/core/effect_registry.h @@ -46,6 +46,7 @@ struct EffectInfo { // Parameter support flags (enables smart UI) bool usesPalette; // Responds to palette changes + bool usesPrimaryColor; // Uses params.colors[0] bool usesSecondaryColor; // Uses params.colors[1] bool usesSpeed; // Responds to speed param bool usesIntensity; // Responds to intensity param @@ -167,47 +168,47 @@ class EffectRegistrar { * * Usage: * REGISTER_EFFECT_FULL(effectFire, "fire", "Fire", Animated, - * false, false, true, true, sizeof(FireState), 10); - * // usesPal, usesSecColor, usesSpd, usesInt, stateSize, minLeds + * false, false, false, true, true, sizeof(FireState), 10); + * // usesPal, usesPrimColor, usesSecColor, usesSpd, usesInt, stateSize, minLeds */ -#define REGISTER_EFFECT_FULL(fn, idStr, dispName, cat, usesPal, usesSecColor, usesSpd, usesInt, stateSz, minL) \ +#define REGISTER_EFFECT_FULL(fn, idStr, dispName, cat, usesPal, usesPrimColor, usesSecColor, usesSpd, usesInt, stateSz, minL) \ static lume::EffectRegistrar _registrar_##fn({ \ idStr, dispName, lume::EffectCategory::cat, \ - usesPal, usesSecColor, usesSpd, usesInt, \ + usesPal, usesPrimColor, usesSecColor, usesSpd, usesInt, \ stateSz, minL, fn \ }) // Simple effect: animated, uses speed only #define REGISTER_EFFECT_SIMPLE(fn, idStr) \ - REGISTER_EFFECT_FULL(fn, idStr, idStr, Animated, false, false, true, false, 0, 1) + REGISTER_EFFECT_FULL(fn, idStr, idStr, Animated, false, false, false, true, false, 0, 1) // Simple effect with display name #define REGISTER_EFFECT_SIMPLE_NAMED(fn, idStr, dispName) \ - REGISTER_EFFECT_FULL(fn, idStr, dispName, Animated, false, false, true, false, 0, 1) + REGISTER_EFFECT_FULL(fn, idStr, dispName, Animated, false, false, false, true, false, 0, 1) // Static solid-type effect: no animation #define REGISTER_EFFECT_SOLID(fn, idStr, dispName) \ - REGISTER_EFFECT_FULL(fn, idStr, dispName, Solid, false, false, false, false, 0, 1) + REGISTER_EFFECT_FULL(fn, idStr, dispName, Solid, false, true, false, false, false, 0, 1) // Animated effect with speed + intensity #define REGISTER_EFFECT_ANIMATED(fn, idStr, dispName) \ - REGISTER_EFFECT_FULL(fn, idStr, dispName, Animated, false, false, true, true, 0, 1) + REGISTER_EFFECT_FULL(fn, idStr, dispName, Animated, false, false, false, true, true, 0, 1) // Moving effect with speed + intensity #define REGISTER_EFFECT_MOVING(fn, idStr, dispName) \ - REGISTER_EFFECT_FULL(fn, idStr, dispName, Moving, false, false, true, true, 0, 1) + REGISTER_EFFECT_FULL(fn, idStr, dispName, Moving, false, false, false, true, true, 0, 1) // Palette-based effect: uses palette and speed #define REGISTER_EFFECT_PALETTE(fn, idStr, dispName) \ - REGISTER_EFFECT_FULL(fn, idStr, dispName, Animated, true, false, true, false, 0, 1) + REGISTER_EFFECT_FULL(fn, idStr, dispName, Animated, true, false, false, true, false, 0, 1) // Effect using primary + secondary colors #define REGISTER_EFFECT_COLORS(fn, idStr, dispName) \ - REGISTER_EFFECT_FULL(fn, idStr, dispName, Animated, false, true, true, false, 0, 1) + REGISTER_EFFECT_FULL(fn, idStr, dispName, Animated, false, true, true, true, false, 0, 1) // Stateful effect: requires scratchpad state #define REGISTER_EFFECT_STATEFUL(fn, idStr, dispName, stateType) \ - REGISTER_EFFECT_FULL(fn, idStr, dispName, Animated, false, false, true, true, sizeof(stateType), 1) + REGISTER_EFFECT_FULL(fn, idStr, dispName, Animated, false, false, false, true, true, sizeof(stateType), 1) // Convenience function to get registry inline EffectRegistry& effects() { diff --git a/src/effects/breathe.cpp b/src/effects/breathe.cpp index 98b2481..f6b5402 100644 --- a/src/effects/breathe.cpp +++ b/src/effects/breathe.cpp @@ -25,6 +25,7 @@ void effectBreathe(SegmentView& view, const EffectParams& params, uint32_t frame view.fill(color); } -REGISTER_EFFECT_SIMPLE_NAMED(effectBreathe, "breathe", "Breathe"); +// Animated effect using primary color and speed +REGISTER_EFFECT_FULL(effectBreathe, "breathe", "Breathe", Animated, false, true, false, true, false, 0, 1); } // namespace lume diff --git a/src/effects/candle.cpp b/src/effects/candle.cpp index a936904..2411805 100644 --- a/src/effects/candle.cpp +++ b/src/effects/candle.cpp @@ -70,6 +70,6 @@ void effectCandle(SegmentView& view, const EffectParams& params, uint32_t frame, } } -REGISTER_EFFECT_ANIMATED(effectCandle, "candle", "Candle"); +REGISTER_EFFECT_FULL(effectCandle, "candle", "Candle", Animated, false, true, false, true, true, 0, 1); } // namespace lume diff --git a/src/effects/meteor.cpp b/src/effects/meteor.cpp index d840175..07db95f 100644 --- a/src/effects/meteor.cpp +++ b/src/effects/meteor.cpp @@ -36,6 +36,6 @@ void effectMeteor(SegmentView& view, const EffectParams& params, uint32_t frame, } } -REGISTER_EFFECT_MOVING(effectMeteor, "meteor", "Meteor"); +REGISTER_EFFECT_FULL(effectMeteor, "meteor", "Meteor", Moving, false, true, false, true, true, 0, 1); } // namespace lume diff --git a/src/effects/pulse.cpp b/src/effects/pulse.cpp index f5db7fb..899f032 100644 --- a/src/effects/pulse.cpp +++ b/src/effects/pulse.cpp @@ -23,6 +23,7 @@ void effectPulse(SegmentView& view, const EffectParams& params, uint32_t frame, view.fill(color); } -REGISTER_EFFECT_SIMPLE_NAMED(effectPulse, "pulse", "Pulse"); +// Animated effect using primary color and speed +REGISTER_EFFECT_FULL(effectPulse, "pulse", "Pulse", Animated, false, true, false, true, false, 0, 1); } // namespace lume diff --git a/src/effects/rain.cpp b/src/effects/rain.cpp index 2db37b1..706b009 100644 --- a/src/effects/rain.cpp +++ b/src/effects/rain.cpp @@ -61,6 +61,6 @@ void effectRain(SegmentView& view, const EffectParams& params, uint32_t frame, b } } -REGISTER_EFFECT_MOVING(effectRain, "rain", "Rain"); +REGISTER_EFFECT_FULL(effectRain, "rain", "Rain", Moving, false, true, false, true, true, 0, 1); } // namespace lume diff --git a/src/effects/scanner.cpp b/src/effects/scanner.cpp index 0473166..db43bc7 100644 --- a/src/effects/scanner.cpp +++ b/src/effects/scanner.cpp @@ -14,6 +14,9 @@ namespace lume { static int16_t scannerPos = 0; static int8_t scannerDir = 1; +// Static for frame skip timing +static uint8_t scannerFrameCount = 0; + void effectScanner(SegmentView& view, const EffectParams& params, uint32_t frame, bool firstFrame) { (void)frame; @@ -24,10 +27,15 @@ void effectScanner(SegmentView& view, const EffectParams& params, uint32_t frame if (firstFrame) { scannerPos = 0; scannerDir = 1; + scannerFrameCount = 0; } uint8_t tailLength = params.intensity > 0 ? params.intensity / 4 : 20; + // Speed controls movement rate (higher = faster) + // At speed 255, move every frame. At speed 1, move every ~8 frames + uint8_t frameSkip = map(params.speed, 1, 255, 8, 1); + // Fade existing view.fade(40); @@ -47,8 +55,12 @@ void effectScanner(SegmentView& view, const EffectParams& params, uint32_t frame } } - // Move scanner - scannerPos += scannerDir; + // Move scanner based on speed + scannerFrameCount++; + if (scannerFrameCount >= frameSkip) { + scannerFrameCount = 0; + scannerPos += scannerDir; + } // Bounce at edges if (scannerPos >= (int16_t)len || scannerPos < 0) { @@ -57,6 +69,6 @@ void effectScanner(SegmentView& view, const EffectParams& params, uint32_t frame } } -REGISTER_EFFECT_MOVING(effectScanner, "scanner", "Scanner"); +REGISTER_EFFECT_FULL(effectScanner, "scanner", "Scanner", Moving, false, true, false, true, true, 0, 1); } // namespace lume diff --git a/src/effects/sparkle.cpp b/src/effects/sparkle.cpp index 94e00ac..e664388 100644 --- a/src/effects/sparkle.cpp +++ b/src/effects/sparkle.cpp @@ -22,6 +22,6 @@ void effectSparkle(SegmentView& view, const EffectParams& params, uint32_t frame } } -REGISTER_EFFECT_SIMPLE_NAMED(effectSparkle, "sparkle", "Sparkle"); +REGISTER_EFFECT_FULL(effectSparkle, "sparkle", "Sparkle", Animated, false, true, false, true, false, 0, 1); } // namespace lume diff --git a/src/effects/strobe.cpp b/src/effects/strobe.cpp index 28b0d70..c4826bc 100644 --- a/src/effects/strobe.cpp +++ b/src/effects/strobe.cpp @@ -23,6 +23,6 @@ void effectStrobe(SegmentView& view, const EffectParams& params, uint32_t frame, } } -REGISTER_EFFECT_SIMPLE_NAMED(effectStrobe, "strobe", "Strobe"); +REGISTER_EFFECT_FULL(effectStrobe, "strobe", "Strobe", Animated, false, true, false, true, false, 0, 1); } // namespace lume diff --git a/src/effects/theater.cpp b/src/effects/theater.cpp index cc94fe2..e4618c3 100644 --- a/src/effects/theater.cpp +++ b/src/effects/theater.cpp @@ -11,13 +11,16 @@ void effectTheaterChase(SegmentView& view, const EffectParams& params, uint32_t uint16_t len = view.size(); + // Speed controls chase rate + uint16_t scaledFrame = (frame * params.speed) >> 6; + // Fade background view.fade(100); - // Draw every 3rd LED, offset by frame + // Draw every 3rd LED, offset by scaled frame for (uint16_t i = 0; i < len; i += 3) { - uint16_t idx = (i + frame) % len; - uint8_t hue = (frame + i * 4) & 0xFF; + uint16_t idx = (i + scaledFrame) % len; + uint8_t hue = (scaledFrame + i * 4) & 0xFF; view[idx] = ColorFromPalette(params.palette, hue, 255, LINEARBLEND); } } diff --git a/src/effects/twinkle.cpp b/src/effects/twinkle.cpp index e83c9c3..696da98 100644 --- a/src/effects/twinkle.cpp +++ b/src/effects/twinkle.cpp @@ -56,6 +56,6 @@ void effectTwinkle(SegmentView& view, const EffectParams& params, uint32_t frame } } -REGISTER_EFFECT_SIMPLE_NAMED(effectTwinkle, "twinkle", "Twinkle"); +REGISTER_EFFECT_FULL(effectTwinkle, "twinkle", "Twinkle", Animated, false, true, false, true, false, 0, 1); } // namespace lume diff --git a/src/effects/wave.cpp b/src/effects/wave.cpp index c38e3e2..be0a818 100644 --- a/src/effects/wave.cpp +++ b/src/effects/wave.cpp @@ -69,6 +69,6 @@ void effectWave(SegmentView& view, const EffectParams& params, uint32_t frame, b } } -REGISTER_EFFECT_MOVING(effectWave, "wave", "Wave"); +REGISTER_EFFECT_FULL(effectWave, "wave", "Wave", Moving, false, true, false, true, true, 0, 1); } // namespace lume diff --git a/src/main.cpp b/src/main.cpp index 6a94f80..1a800bf 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,10 +20,10 @@ #include #include #include +#include +#include "main.h" #include "storage.h" -#include "anthropic_client.h" -#include "web_ui.h" #include "constants.h" #include "logging.h" #include @@ -34,6 +34,18 @@ #include "protocols/sacn.h" #include "protocols/mqtt.h" +// API handlers (modular route implementations) +#include "api/nightlight.h" // Nightlight fade-to-sleep handlers +#include "api/pixels.h" // Direct pixel control +#include "api/config.h" // System configuration management +#include "api/status.h" // Root & system status endpoints +#include "api/prompt.h" // AI prompt processing + +// Network setup +#include "network/server.h" // Web server route registration +#include "network/ota.h" // OTA updates and mDNS +#include "network/wifi.h" // WiFi AP+STA setup + // Optional development secrets (create src/secrets.h from secrets.h.example) #ifdef __has_include # if __has_include("secrets.h") @@ -57,6 +69,9 @@ bool wifiConnected = false; unsigned long lastWifiAttempt = 0; const unsigned long WIFI_RETRY_INTERVAL = 30000; +// Web UI filesystem state +bool webUiAvailable = false; + // Auth helper - returns true if request is authorized bool checkAuth(AsyncWebServerRequest* request) { // If no auth token configured, allow all requests @@ -98,37 +113,6 @@ void sendUnauthorized(AsyncWebServerRequest* request) { request->send(401, "application/json", "{\"error\":\"Unauthorized\"}"); } -// Forward declarations -void setupWiFi(); -void setupOTA(); -void setupServer(); -void handleRoot(AsyncWebServerRequest* request); -void handleApiStatus(AsyncWebServerRequest* request); -void handleApiConfig(AsyncWebServerRequest* request); -void handleApiConfigPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); -void handleApiLed(AsyncWebServerRequest* request); -void handleApiLedPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); -void handleApiPrompt(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); -void handleApiPromptStatus(AsyncWebServerRequest* request); -void handleApiPromptApply(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); -void handleApiPixels(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); -void handleApiScenesGet(AsyncWebServerRequest* request); -void handleApiSceneGet(AsyncWebServerRequest* request); -void handleApiScenePost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); -void handleApiSceneDelete(AsyncWebServerRequest* request); -void handleApiSceneApply(AsyncWebServerRequest* request); -void handleApiNightlightGet(AsyncWebServerRequest* request); -void handleApiNightlightPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); - -// Request body buffers (for async body handling) -String configBodyBuffer; -String ledBodyBuffer; -String promptBodyBuffer; -String applyBodyBuffer; -String nightlightBodyBuffer; -String pixelsBodyBuffer; -String sceneBodyBuffer; - // Helper function: Validate RGB color array bool validateRgbArray(JsonArrayConst arr) { return arr.size() >= 3 && @@ -137,232 +121,6 @@ bool validateRgbArray(JsonArrayConst arr) { arr[2].is(); } -// =========================================================================== -// V2 Controller Adapter - Bridge old API format to new segment-based controller -// =========================================================================== - -// Get primary segment (segment 0) - creates if needed -lume::Segment* getMainSegment() { - lume::Segment* seg = lume::controller.getSegment(0); - if (!seg) { - seg = lume::controller.createFullStrip(); - } - return seg; -} - -// Map old effect names to new effect IDs -const char* mapOldEffectToNew(const char* oldEffect) { - if (!oldEffect) return "rainbow"; - - // The new effect IDs are the same as old ones, so pass through directly. - // The segment.setEffect() will validate if the effect exists. - return oldEffect; -} - -// Map old palette names to new PalettePreset -lume::PalettePreset mapOldPaletteToNew(const char* oldPalette) { - if (!oldPalette) return lume::PalettePreset::Rainbow; - - String pal = oldPalette; - pal.toLowerCase(); - - if (pal == "rainbow") return lume::PalettePreset::Rainbow; - if (pal == "lava") return lume::PalettePreset::Lava; - if (pal == "ocean") return lume::PalettePreset::Ocean; - if (pal == "party") return lume::PalettePreset::Party; - if (pal == "forest") return lume::PalettePreset::Forest; - if (pal == "cloud") return lume::PalettePreset::Cloud; - if (pal == "heat") return lume::PalettePreset::Heat; - - return lume::PalettePreset::Rainbow; -} - -// Serialize controller state to old API format (for backward compat with web UI) -void controllerStateToJson(JsonDocument& doc) { - doc["power"] = lume::controller.getPower(); - doc["brightness"] = lume::controller.getBrightness(); - - lume::Segment* seg = getMainSegment(); - if (seg) { - // Effect name - doc["effect"] = seg->getEffectId(); - - // Speed/intensity as "speed" for old API - doc["speed"] = seg->getSpeed(); - - // Colors - CRGB primary = seg->getPrimaryColor(); - CRGB secondary = seg->getSecondaryColor(); - - JsonArray primaryArr = doc["primaryColor"].to(); - primaryArr.add(primary.r); - primaryArr.add(primary.g); - primaryArr.add(primary.b); - - JsonArray secondaryArr = doc["secondaryColor"].to(); - secondaryArr.add(secondary.r); - secondaryArr.add(secondary.g); - secondaryArr.add(secondary.b); - - // Palette - convert enum to string - doc["palette"] = "rainbow"; // TODO: Add palette name tracking - } - - // Nightlight - not implemented in v2 yet - JsonObject nightlight = doc["nightlight"].to(); - nightlight["active"] = false; -} - -// Apply old API format to new controller -void controllerStateFromJson(const JsonDocument& doc) { - lume::Segment* seg = getMainSegment(); - if (!seg) return; - - // Power - if (doc["power"].is()) { - lume::controller.setPower(doc["power"].as()); - } - - // Brightness - if (doc["brightness"].is()) { - lume::controller.setBrightness(constrain(doc["brightness"].as(), 0, 255)); - } - - // Effect - if (doc["effect"].is()) { - const char* effectId = mapOldEffectToNew(doc["effect"].as()); - seg->setEffect(effectId); - } - - // Speed (maps to segment speed) - if (doc["speed"].is()) { - seg->setSpeed(constrain(doc["speed"].as(), 1, 255)); - } - - // Palette - if (doc["palette"].is()) { - lume::PalettePreset preset = mapOldPaletteToNew(doc["palette"].as()); - seg->setPalette(preset); - } - - // Primary color - JsonVariantConst primaryVar = doc["primaryColor"]; - if (primaryVar.is()) { - JsonArrayConst arr = primaryVar.as(); - if (arr.size() >= 3) { - seg->setPrimaryColor(CRGB(arr[0].as(), arr[1].as(), arr[2].as())); - } - } else if (primaryVar.is()) { - String hex = primaryVar.as(); - if (hex.startsWith("#") && hex.length() == 7) { - long color = strtol(hex.substring(1).c_str(), NULL, 16); - seg->setPrimaryColor(CRGB((color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF)); - } - } - - // Secondary color - JsonVariantConst secondaryVar = doc["secondaryColor"]; - if (secondaryVar.is()) { - JsonArrayConst arr = secondaryVar.as(); - if (arr.size() >= 3) { - seg->setSecondaryColor(CRGB(arr[0].as(), arr[1].as(), arr[2].as())); - } - } else if (secondaryVar.is()) { - String hex = secondaryVar.as(); - if (hex.startsWith("#") && hex.length() == 7) { - long color = strtol(hex.substring(1).c_str(), NULL, 16); - seg->setSecondaryColor(CRGB((color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF)); - } - } -} - -// Apply effect spec from AI/prompts - supports both "effect" and "pixels" modes -bool applyEffectSpec(const JsonDocument& spec, String& errorMsg) { - // Check for mode field - String mode = "effect"; // default - if (spec["mode"].is()) { - mode = spec["mode"].as(); - mode.toLowerCase(); - } - - CRGB* leds = lume::controller.getLeds(); - uint16_t ledCount = lume::controller.getLedCount(); - - // Handle pixels mode - direct LED control (bypass effects) - if (mode == "pixels") { - if (!spec["pixels"].is()) { - errorMsg = "Mode 'pixels' requires 'pixels' object"; - return false; - } - JsonObjectConst pixels = spec["pixels"].as(); - - // Fill all with single color - if (pixels["fill"].is()) { - JsonArrayConst fill = pixels["fill"].as(); - if (fill.size() >= 3) { - CRGB color(fill[0].as(), fill[1].as(), fill[2].as()); - fill_solid(leds, ledCount, color); - FastLED.show(); - lume::controller.setPower(true); - errorMsg = ""; - return true; - } - } - - // Gradient - if (pixels["gradient"].is()) { - JsonObjectConst grad = pixels["gradient"].as(); - JsonArrayConst from = grad["from"].as(); - JsonArrayConst to = grad["to"].as(); - - if (from.size() >= 3 && to.size() >= 3) { - CRGB startColor(from[0].as(), from[1].as(), from[2].as()); - CRGB endColor(to[0].as(), to[1].as(), to[2].as()); - fill_gradient_RGB(leds, 0, startColor, ledCount - 1, endColor); - FastLED.show(); - lume::controller.setPower(true); - errorMsg = ""; - return true; - } - } - - // Individual pixels array - if (pixels["pixels"].is()) { - JsonArrayConst arr = pixels["pixels"].as(); - uint16_t count = min((uint16_t)arr.size(), ledCount); - - for (uint16_t i = 0; i < count; i++) { - JsonArrayConst pixel = arr[i].as(); - if (pixel.size() >= 3) { - leds[i].r = pixel[0].as(); - leds[i].g = pixel[1].as(); - leds[i].b = pixel[2].as(); - } - } - FastLED.show(); - lume::controller.setPower(true); - errorMsg = ""; - return true; - } - - errorMsg = "No valid pixel data in 'pixels' object"; - return false; - } - - // Effect mode - validate required fields - if (!spec["effect"].is()) { - errorMsg = "Missing 'effect' field"; - return false; - } - - // Apply the spec using our adapter - controllerStateFromJson(spec); - lume::controller.setPower(true); - - errorMsg = ""; - return true; -} - void setup() { Serial.begin(115200); delay(1000); @@ -371,6 +129,16 @@ void setup() { // Initialize storage storage.begin(); + + // Mount LittleFS for serving the web UI + if (LittleFS.begin(true)) { + webUiAvailable = true; + size_t total = LittleFS.totalBytes(); + size_t used = LittleFS.usedBytes(); + LOG_INFO(LogTag::WEB, "LittleFS mounted (%u KB free)", static_cast((total - used) / 1024)); + } else { + LOG_ERROR(LogTag::WEB, "Failed to mount LittleFS; web UI unavailable"); + } // Load configuration if (!storage.loadConfig(config)) { @@ -386,12 +154,12 @@ void setup() { LOG_DEBUG(LogTag::MAIN, "Using development secrets"); String devSsid = DEV_WIFI_SSID; String devPass = DEV_WIFI_PASSWORD; - String devKey = DEV_API_KEY; + String devKey = DEV_AI_API_KEY; if (devSsid.length() > 0) config.wifiSSID = devSsid; if (devPass.length() > 0) config.wifiPassword = devPass; - if (devKey.length() > 0) config.apiKey = devKey; - config.openRouterModel = DEV_OPENROUTER_MODEL; + if (devKey.length() > 0) config.aiApiKey = devKey; + config.aiModel = DEV_AI_MODEL; // Note: LED pin is configured in constants.h (LED_DATA_PIN) config.ledCount = DEV_LED_COUNT; config.defaultBrightness = DEV_DEFAULT_BRIGHTNESS; @@ -424,9 +192,6 @@ void setup() { LOG_INFO(LogTag::LED, "Created main segment (0-%d) with rainbow effect", config.ledCount - 1); } - // Initialize OpenRouter client - openRouterClient.begin(); - // Setup WiFi setupWiFi(); @@ -445,41 +210,6 @@ void setup() { LOG_INFO(LogTag::MAIN, "Watchdog initialized (%ds timeout)", WATCHDOG_TIMEOUT_SEC); } -// Helper function for WiFi reconnection and status monitoring -void handleWifiMaintenance() { - // WiFi reconnection logic - if (!wifiConnected && config.wifiSSID.length() > 0) { - if (millis() - lastWifiAttempt > WIFI_RETRY_INTERVAL) { - lastWifiAttempt = millis(); - LOG_INFO(LogTag::WIFI, "Attempting WiFi reconnection..."); - WiFi.begin(config.wifiSSID.c_str(), config.wifiPassword.c_str()); - } - } - - // Check WiFi status change - static bool lastWifiState = false; - bool currentWifiState = (WiFi.status() == WL_CONNECTED); - if (currentWifiState != lastWifiState) { - lastWifiState = currentWifiState; - wifiConnected = currentWifiState; - if (currentWifiState) { - LOG_INFO(LogTag::WIFI, "Connected! IP: %s", WiFi.localIP().toString().c_str()); - // Setup OTA when WiFi connects - setupOTA(); - // Start sACN protocol if enabled - if (config.sacnEnabled) { - lume::sacnProtocol.configure(config.sacnUniverse, config.sacnUniverseCount, - config.sacnUnicast, config.sacnStartChannel); - lume::sacnProtocol.begin(); - } - // MQTT will auto-reconnect in its update() cycle - } else { - LOG_WARN(LogTag::WIFI, "WiFi disconnected"); - lume::sacnProtocol.stop(); - } - } -} - void loop() { // Handle OTA updates ArduinoOTA.handle(); @@ -490,6 +220,9 @@ void loop() { // MQTT update (handles reconnection and message processing) lume::mqtt.update(); + + // Web server maintenance (WebSocket broadcasts/cleanup) + loopServer(); // WiFi maintenance (reconnection, status monitoring) handleWifiMaintenance(); @@ -501,1109 +234,3 @@ void loop() { yield(); } -void setupWiFi() { - // Always start AP mode for initial access - WiFi.mode(WIFI_AP_STA); - - // Start Access Point - WiFi.softAP(AP_SSID, AP_PASSWORD); - LOG_INFO(LogTag::WIFI, "AP started: %s", AP_SSID); - LOG_DEBUG(LogTag::WIFI, "AP IP: %s", WiFi.softAPIP().toString().c_str()); - - // Try to connect to configured WiFi - if (config.wifiSSID.length() > 0) { - LOG_INFO(LogTag::WIFI, "Connecting to WiFi: %s", config.wifiSSID.c_str()); - WiFi.begin(config.wifiSSID.c_str(), config.wifiPassword.c_str()); - - // Wait for connection (with timeout) - int attempts = 0; - while (WiFi.status() != WL_CONNECTED && attempts < 20) { - delay(500); - Serial.print("."); // Keep dots for visual feedback - attempts++; - } - Serial.println(); - - if (WiFi.status() == WL_CONNECTED) { - wifiConnected = true; - LOG_INFO(LogTag::WIFI, "Connected! IP: %s", WiFi.localIP().toString().c_str()); - } else { - LOG_WARN(LogTag::WIFI, "Connection failed, AP mode active"); - } - } else { - LOG_INFO(LogTag::WIFI, "No WiFi configured, AP mode only"); - } - - lastWifiAttempt = millis(); -} - -void setupOTA() { - // Only enable OTA if WiFi is connected - if (WiFi.status() != WL_CONNECTED) { - LOG_DEBUG(LogTag::OTA, "Waiting for WiFi connection"); - return; - } - - // Start mDNS service for OTA discovery and easy access - if (!MDNS.begin(MDNS_HOSTNAME)) { - LOG_ERROR(LogTag::OTA, "Error starting mDNS"); - return; - } - // Add HTTP service for service discovery - MDNS.addService("http", "tcp", 80); - LOG_INFO(LogTag::OTA, "mDNS started: %s.local", MDNS_HOSTNAME); - - // Set OTA hostname - ArduinoOTA.setHostname(MDNS_HOSTNAME); - - // Set OTA port (default is 3232) - ArduinoOTA.setPort(3232); - - // Set OTA password (use auth token if set, otherwise fall back to AP password) - if (config.authToken.length() > 0) { - ArduinoOTA.setPassword(config.authToken.c_str()); - } else { - ArduinoOTA.setPassword(AP_PASSWORD); - } - - // OTA callbacks for status updates - ArduinoOTA.onStart([]() { - String type; - if (ArduinoOTA.getCommand() == U_FLASH) { - type = "sketch"; - } else { // U_SPIFFS - type = "filesystem"; - } - LOG_INFO(LogTag::OTA, "Starting update (%s)", type.c_str()); - // Disable watchdog during OTA (can take >30s) - esp_task_wdt_delete(NULL); - // Turn off LEDs during update - lume::controller.setPower(false); - }); - - ArduinoOTA.onEnd([]() { - LOG_INFO(LogTag::OTA, "Update complete!"); - // Re-enable watchdog after OTA - esp_task_wdt_add(NULL); - }); - - ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { - static uint8_t lastPercent = 255; - uint8_t percent = progress / (total / 100); - if (percent != lastPercent) { - lastPercent = percent; - Serial.printf("OTA Progress: %u%%\r", percent); // Keep inline progress - } - }); - - ArduinoOTA.onError([](ota_error_t error) { - const char* errStr = "Unknown"; - if (error == OTA_AUTH_ERROR) errStr = "Auth Failed"; - else if (error == OTA_BEGIN_ERROR) errStr = "Begin Failed"; - else if (error == OTA_CONNECT_ERROR) errStr = "Connect Failed"; - else if (error == OTA_RECEIVE_ERROR) errStr = "Receive Failed"; - else if (error == OTA_END_ERROR) errStr = "End Failed"; - LOG_ERROR(LogTag::OTA, "Error[%u]: %s", error, errStr); - // Re-enable watchdog after OTA error - esp_task_wdt_add(NULL); - }); - - ArduinoOTA.begin(); - - // Add mDNS service for OTA - MDNS.addService("arduino", "tcp", 3232); - - LOG_INFO(LogTag::OTA, "Ready (Hostname: lume.local, Port: 3232)"); -} - -void setupServer() { - // Serve main page - server.on("/", HTTP_GET, handleRoot); - - // Health check endpoint - lightweight for monitoring - server.on("/health", HTTP_GET, [](AsyncWebServerRequest* request) { - JsonDocument doc; - - // Core health indicators - doc["status"] = "healthy"; - doc["uptime"] = millis() / 1000; - doc["version"] = FIRMWARE_VERSION; - - // Memory health - JsonObject memory = doc["memory"].to(); - memory["heap_free"] = ESP.getFreeHeap(); - memory["heap_min"] = ESP.getMinFreeHeap(); - memory["heap_max_block"] = ESP.getMaxAllocHeap(); - memory["psram_free"] = ESP.getFreePsram(); - - // Calculate heap fragmentation percentage - uint32_t heapFree = ESP.getFreeHeap(); - uint32_t maxBlock = ESP.getMaxAllocHeap(); - if (heapFree > 0) { - memory["fragmentation"] = 100 - (maxBlock * 100 / heapFree); - } - - // Network health - JsonObject network = doc["network"].to(); - network["wifi_connected"] = wifiConnected; - network["wifi_rssi"] = wifiConnected ? WiFi.RSSI() : 0; - network["ip"] = wifiConnected ? WiFi.localIP().toString() : WiFi.softAPIP().toString(); - network["ap_clients"] = WiFi.softAPgetStationNum(); - - // Component health - JsonObject components = doc["components"].to(); - components["led_controller"] = lume::controller.getLedCount() > 0; - components["storage"] = true; // Would fail at boot if broken - components["sacn_enabled"] = config.sacnEnabled; - components["sacn_receiving"] = lume::sacnProtocol.isActive(); - components["mqtt_enabled"] = config.mqttEnabled; - components["mqtt_connected"] = lume::mqtt.isConnected(); - - // AI client status - PromptJobResult& aiResult = openRouterClient.getJobResult(); - components["ai_last_state"] = - aiResult.state == PromptJobState::IDLE ? "idle" : - aiResult.state == PromptJobState::QUEUED ? "queued" : - aiResult.state == PromptJobState::RUNNING ? "running" : - aiResult.state == PromptJobState::DONE ? "done" : "error"; - - String response; - serializeJson(doc, response); - request->send(200, "application/json", response); - }); - - // API endpoints - server.on("/api/status", HTTP_GET, handleApiStatus); - server.on("/api/config", HTTP_GET, handleApiConfig); - server.on("/api/led", HTTP_GET, handleApiLed); - server.on("/api/prompt/status", HTTP_GET, handleApiPromptStatus); - - // POST handlers with body - server.on("/api/config", HTTP_POST, - [](AsyncWebServerRequest* request) {}, - NULL, - handleApiConfigPost - ); - - server.on("/api/led", HTTP_POST, - [](AsyncWebServerRequest* request) {}, - NULL, - handleApiLedPost - ); - - // Register more specific route first! - server.on("/api/prompt/apply", HTTP_POST, - [](AsyncWebServerRequest* request) {}, - NULL, - handleApiPromptApply - ); - - server.on("/api/prompt", HTTP_POST, - [](AsyncWebServerRequest* request) {}, - NULL, - handleApiPrompt - ); - - // Direct pixel control endpoint - server.on("/api/pixels", HTTP_POST, - [](AsyncWebServerRequest* request) {}, - NULL, - handleApiPixels - ); - - // Scene endpoints - server.on("/api/scenes", HTTP_GET, handleApiScenesGet); - - // Scene CRUD - use regex-like handling - server.on("^\\/api\\/scenes\\/([0-9]+)$", HTTP_GET, handleApiSceneGet); - server.on("^\\/api\\/scenes\\/([0-9]+)$", HTTP_DELETE, handleApiSceneDelete); - server.on("^\\/api\\/scenes\\/([0-9]+)\\/apply$", HTTP_POST, handleApiSceneApply); - - server.on("/api/scenes", HTTP_POST, - [](AsyncWebServerRequest* request) {}, - NULL, - handleApiScenePost - ); - - // Segment management endpoints (v2 architecture) - server.on("/api/segments", HTTP_GET, [](AsyncWebServerRequest* request) { - JsonDocument doc; - - // Global state - doc["power"] = lume::controller.getPower(); - doc["brightness"] = lume::controller.getBrightness(); - doc["ledCount"] = lume::controller.getLedCount(); - - // List all segments - JsonArray segArr = doc["segments"].to(); - uint8_t segCount = lume::controller.getSegmentCount(); - - for (uint8_t i = 0; i < segCount; i++) { - lume::Segment* seg = lume::controller.getSegment(i); - if (!seg) continue; - - JsonObject segObj = segArr.add(); - segObj["id"] = seg->getId(); - segObj["start"] = seg->getStart(); - segObj["length"] = seg->getLength(); - segObj["reverse"] = seg->isReversed(); - segObj["brightness"] = seg->getBrightness(); - segObj["speed"] = seg->getSpeed(); - segObj["intensity"] = seg->getParams().intensity; - - // Effect info - JsonObject effectObj = segObj["effect"].to(); - effectObj["id"] = seg->getEffectId(); - effectObj["name"] = seg->getEffectName(); - if (seg->getEffect()) { - effectObj["category"] = seg->getEffect()->categoryName(); - } - - // Colors - CRGB primary = seg->getPrimaryColor(); - CRGB secondary = seg->getSecondaryColor(); - - JsonArray colors = segObj["colors"].to(); - char hex1[8], hex2[8]; - snprintf(hex1, sizeof(hex1), "#%02x%02x%02x", primary.r, primary.g, primary.b); - snprintf(hex2, sizeof(hex2), "#%02x%02x%02x", secondary.r, secondary.g, secondary.b); - colors.add(hex1); - colors.add(hex2); - - // Capabilities - const lume::SegmentCapabilities& caps = seg->getCapabilities(); - JsonObject capsObj = segObj["capabilities"].to(); - capsObj["hasSpeed"] = caps.hasSpeed; - capsObj["hasIntensity"] = caps.hasIntensity; - capsObj["hasPalette"] = caps.hasPalette; - capsObj["hasSecondaryColor"] = caps.hasSecondaryColor; - } - - // Available effects - JsonArray effectsArr = doc["effects"].to(); - auto& registry = lume::effects(); - for (uint8_t i = 0; i < registry.getCount(); i++) { - const lume::EffectInfo* info = registry.getByIndex(i); - if (!info) continue; - - JsonObject effObj = effectsArr.add(); - effObj["id"] = info->id; - effObj["name"] = info->displayName; - effObj["category"] = info->categoryName(); // Use helper method - effObj["usesSpeed"] = info->usesSpeed; - effObj["usesIntensity"] = info->usesIntensity; - effObj["usesPalette"] = info->usesPalette; - } - - String response; - serializeJson(doc, response); - request->send(200, "application/json", response); - }); - - // Nightlight endpoints - server.on("/api/nightlight", HTTP_GET, handleApiNightlightGet); - server.on("/api/nightlight", HTTP_POST, - [](AsyncWebServerRequest* request) {}, - NULL, - handleApiNightlightPost - ); - server.on("/api/nightlight/stop", HTTP_POST, [](AsyncWebServerRequest* request) { - if (!checkAuth(request)) { - sendUnauthorized(request); - return; - } - // TODO: Implement nightlight in v2 controller - request->send(200, "application/json", "{\"success\":true}"); - }); - - // Handle CORS preflight - server.on("/api/*", HTTP_OPTIONS, [](AsyncWebServerRequest* request) { - AsyncWebServerResponse* response = request->beginResponse(200); - response->addHeader("Access-Control-Allow-Origin", "*"); - response->addHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); - response->addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key"); - request->send(response); - }); - - // 404 handler - server.onNotFound([](AsyncWebServerRequest* request) { - request->send(404, "application/json", "{\"error\":\"Not found\"}"); - }); - - // Start server - DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*"); - server.begin(); - LOG_INFO(LogTag::WEB, "Web server started on port 80"); -} - -void handleRoot(AsyncWebServerRequest* request) { - request->send_P(200, "text/html", INDEX_HTML); -} - -void handleApiStatus(AsyncWebServerRequest* request) { - JsonDocument doc; - - doc["uptime"] = millis() / 1000; - doc["wifi"] = wifiConnected ? "Connected" : "AP Mode"; - doc["ip"] = wifiConnected ? WiFi.localIP().toString() : WiFi.softAPIP().toString(); - doc["heap"] = ESP.getFreeHeap(); - doc["ledCount"] = lume::controller.getLedCount(); - doc["power"] = lume::controller.getPower(); - - // sACN status (using new protocol system) - JsonObject sacn = doc["sacn"].to(); - sacn["enabled"] = config.sacnEnabled; - sacn["universe"] = config.sacnUniverse; - sacn["universeCount"] = config.sacnUniverseCount; - sacn["startChannel"] = config.sacnStartChannel; - sacn["unicast"] = config.sacnUnicast; - sacn["receiving"] = lume::sacnProtocol.isActive(); - sacn["packets"] = lume::sacnProtocol.getPacketCount(); - sacn["source"] = lume::sacnProtocol.getActiveSourceName(); - sacn["priority"] = lume::sacnProtocol.getActivePriority(); - if (lume::sacnProtocol.isActive()) { - sacn["lastPacketMs"] = millis() - lume::sacnProtocol.getLastPacketTime(); - } - - // MQTT status - JsonObject mqtt = doc["mqtt"].to(); - mqtt["enabled"] = config.mqttEnabled; - mqtt["broker"] = config.mqttBroker; - mqtt["connected"] = lume::mqtt.isConnected(); - - String response; - serializeJson(doc, response); - request->send(200, "application/json", response); -} - -void handleApiConfig(AsyncWebServerRequest* request) { - JsonDocument doc; - storage.configToJson(config, doc, true); - - String response; - serializeJson(doc, response); - request->send(200, "application/json", response); -} - -void handleApiConfigPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { - // Auth check at start of request - if (index == 0 && !checkAuth(request)) { - sendUnauthorized(request); - return; - } - - if (index == 0) { - configBodyBuffer = ""; - // Validate total size - if (total > MAX_REQUEST_BODY_SIZE) { - request->send(413, "application/json", "{\"error\":\"Request body too large\"}"); - return; - } - } - - configBodyBuffer += String((char*)data).substring(0, len); - - if (index + len >= total) { - // Body complete, process - JsonDocument doc; - DeserializationError err = deserializeJson(doc, configBodyBuffer); - - if (err) { - request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); - return; - } - - // Update config - storage.configFromJson(config, doc); - - // Save to storage - if (storage.saveConfig(config)) { - // Apply changes that can be applied without restart - lume::controller.setLedCount(config.ledCount); - - // Handle sACN enable/disable (using new protocol system) - if (config.sacnEnabled && wifiConnected) { - lume::sacnProtocol.stop(); - lume::sacnProtocol.configure(config.sacnUniverse, config.sacnUniverseCount, - config.sacnUnicast, config.sacnStartChannel); - lume::sacnProtocol.begin(); - } else { - lume::sacnProtocol.stop(); - } - - // Handle MQTT enable/disable - if (config.mqttEnabled && config.mqttBroker.length() > 0 && wifiConnected) { - lume::MqttConfig mqttConfig; - mqttConfig.enabled = config.mqttEnabled; - mqttConfig.broker = config.mqttBroker; - mqttConfig.port = config.mqttPort; - mqttConfig.username = config.mqttUsername; - mqttConfig.password = config.mqttPassword; - mqttConfig.topicPrefix = config.mqttTopicPrefix; - lume::mqtt.setConfig(mqttConfig); - } else { - lume::MqttConfig disabledConfig; - disabledConfig.enabled = false; - lume::mqtt.setConfig(disabledConfig); - } - - request->send(200, "application/json", "{\"success\":true}"); - } else { - request->send(500, "application/json", "{\"error\":\"Failed to save\"}"); - } - } -} - -void handleApiLed(AsyncWebServerRequest* request) { - JsonDocument doc; - controllerStateToJson(doc); // Use v2 adapter - - String response; - serializeJson(doc, response); - request->send(200, "application/json", response); -} - -void handleApiLedPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { - // Auth check at start of request - if (index == 0 && !checkAuth(request)) { - sendUnauthorized(request); - return; - } - - if (index == 0) { - ledBodyBuffer = ""; - if (total > MAX_REQUEST_BODY_SIZE) { - request->send(413, "application/json", "{\"error\":\"Request body too large\"}"); - return; - } - } - - ledBodyBuffer += String((char*)data).substring(0, len); - - if (index + len >= total) { - JsonDocument doc; - DeserializationError err = deserializeJson(doc, ledBodyBuffer); - - if (err) { - request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); - return; - } - - // Apply state using v2 adapter - controllerStateFromJson(doc); - - // Save state to storage - JsonDocument saveDoc; - controllerStateToJson(saveDoc); - storage.saveLedState(saveDoc); - - request->send(200, "application/json", "{\"success\":true}"); - } -} - -void handleApiPrompt(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { - // Auth check at start of request - if (index == 0 && !checkAuth(request)) { - sendUnauthorized(request); - return; - } - - if (index == 0) { - promptBodyBuffer = ""; - if (total > MAX_REQUEST_BODY_SIZE) { - request->send(413, "application/json", "{\"error\":\"Request body too large\"}"); - return; - } - } - - // Rate limiting for prompt endpoint - static unsigned long lastPromptRequest = 0; - if (index == 0 && millis() - lastPromptRequest < PROMPT_RATE_LIMIT_MS) { - request->send(429, "application/json", "{\"error\":\"Rate limited. Please wait before submitting another prompt.\"}"); - return; - } - - promptBodyBuffer += String((char*)data).substring(0, len); - - if (index + len >= total) { - JsonDocument doc; - DeserializationError err = deserializeJson(doc, promptBodyBuffer); - - if (err) { - request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); - return; - } - - String prompt = doc["prompt"] | ""; - if (prompt.length() == 0) { - request->send(400, "application/json", "{\"error\":\"Missing prompt\"}"); - return; - } - - // Check if job already running - if (openRouterClient.isJobRunning()) { - request->send(409, "application/json", "{\"error\":\"Job already running\"}"); - return; - } - - // Get API key (from request or config) - String apiKey = doc["apiKey"] | config.apiKey; - if (apiKey.length() == 0 || apiKey.startsWith("****")) { - apiKey = config.apiKey; - } - - if (apiKey.length() == 0) { - request->send(400, "application/json", "{\"error\":\"API key not configured\"}"); - return; - } - - // Build request - PromptRequest req; - req.prompt = prompt; - req.apiKey = apiKey; - req.model = doc["model"] | config.openRouterModel; - - // Include current LED state for context - JsonDocument ledDoc; - controllerStateToJson(ledDoc); // v2 adapter - serializeJson(ledDoc, req.currentLedStateJson); - - // Submit job - if (openRouterClient.submitPrompt(req)) { - lastPromptRequest = millis(); // Update rate limit timestamp - request->send(200, "application/json", "{\"success\":true,\"message\":\"Job started\"}"); - } else { - request->send(500, "application/json", "{\"error\":\"Failed to start job\"}"); - } - } -} - -void handleApiPromptStatus(AsyncWebServerRequest* request) { - JsonDocument doc; - - PromptJobResult& result = openRouterClient.getJobResult(); - - switch (result.state) { - case PromptJobState::IDLE: doc["state"] = "idle"; break; - case PromptJobState::QUEUED: doc["state"] = "queued"; break; - case PromptJobState::RUNNING: doc["state"] = "running"; break; - case PromptJobState::DONE: doc["state"] = "done"; break; - case PromptJobState::ERROR: doc["state"] = "error"; break; - } - - doc["message"] = result.message; - - // Debug info - if (result.prompt.length() > 0) { - doc["prompt"] = result.prompt; - } - if (result.rawResponse.length() > 0) { - doc["rawResponse"] = result.rawResponse; - } - - if (result.state == PromptJobState::DONE && result.effectSpec.length() > 0) { - doc["lastSpec"] = result.effectSpec; - } - - if (result.startTime > 0) { - unsigned long elapsed = (result.endTime > 0 ? result.endTime : millis()) - result.startTime; - doc["elapsed"] = elapsed; - } - - String response; - serializeJson(doc, response); - request->send(200, "application/json", response); -} - -void handleApiPromptApply(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { - // Auth check at start of request - if (index == 0 && !checkAuth(request)) { - sendUnauthorized(request); - return; - } - - if (index == 0) { - applyBodyBuffer = ""; - if (total > MAX_REQUEST_BODY_SIZE) { - request->send(413, "application/json", "{\"error\":\"Request body too large\"}"); - return; - } - } - - // Properly append data with explicit length (safer than substring) - for (size_t i = 0; i < len; i++) { - applyBodyBuffer += (char)data[i]; - } - - if (index + len >= total) { - LOG_DEBUG(LogTag::WEB, "Apply body received (%d bytes)", applyBodyBuffer.length()); - - String specJson; - - // Check if spec provided in body - if (applyBodyBuffer.length() > 2) { - JsonDocument bodyDoc; - DeserializationError err = deserializeJson(bodyDoc, applyBodyBuffer); - - if (err) { - LOG_WARN(LogTag::WEB, "Failed to parse apply body: %s", err.c_str()); - } - - if (err == DeserializationError::Ok && bodyDoc["spec"].is()) { - specJson = bodyDoc["spec"].as(); - LOG_DEBUG(LogTag::WEB, "Extracted spec from body (%d chars)", specJson.length()); - } else if (err == DeserializationError::Ok) { - // Maybe spec is an object, not a string - try to serialize it - if (bodyDoc["spec"].is()) { - LOG_DEBUG(LogTag::WEB, "Spec is an object, serializing..."); - serializeJson(bodyDoc["spec"], specJson); - } - } - } - - // If no spec in body, use last generated - if (specJson.length() == 0) { - PromptJobResult& result = openRouterClient.getJobResult(); - if (result.state == PromptJobState::DONE && result.effectSpec.length() > 0) { - specJson = result.effectSpec; - } - } - - if (specJson.length() == 0) { - request->send(400, "application/json", "{\"error\":\"No effect specification to apply\"}"); - return; - } - - // Parse and apply - JsonDocument specDoc; - DeserializationError err = deserializeJson(specDoc, specJson); - - if (err) { - LOG_WARN(LogTag::LED, "Failed to parse spec JSON: %s", err.c_str()); - request->send(400, "application/json", "{\"error\":\"Invalid effect specification\"}"); - return; - } - - LOG_DEBUG(LogTag::LED, "Attempting to apply effect spec"); - if (LOG_LEVEL <= LogLevel::DEBUG) { - serializeJsonPretty(specDoc, Serial); - Serial.println(); - } - - String errorMsg; - if (applyEffectSpec(specDoc, errorMsg)) { // Use local adapter function - // Save to storage - PromptSpec spec; - spec.jsonSpec = specJson; - spec.timestamp = millis(); - spec.valid = true; - storage.savePromptSpec(spec); - - // Also save LED state - JsonDocument saveDoc; - controllerStateToJson(saveDoc); - storage.saveLedState(saveDoc); - - request->send(200, "application/json", "{\"success\":true}"); - } else { - LOG_WARN(LogTag::LED, "Failed to apply effect: %s", errorMsg.c_str()); - String error = "{\"error\":\"" + errorMsg + "\"}"; - request->send(400, "application/json", error); - } - } -} - -// Direct pixel control handler -// Accepts: { "pixels": [[r,g,b], [r,g,b], ...], "brightness": 255 } -// Or compact: { "rgb": [r,g,b,r,g,b,...], "brightness": 255 } -void handleApiPixels(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { - // Auth check at start of request - if (index == 0 && !checkAuth(request)) { - sendUnauthorized(request); - return; - } - - if (index == 0) { - pixelsBodyBuffer = ""; - if (total > MAX_REQUEST_BODY_SIZE) { - request->send(413, "application/json", "{\"error\":\"Request body too large\"}"); - return; - } - } - - // Append data safely - for (size_t i = 0; i < len; i++) { - pixelsBodyBuffer += (char)data[i]; - } - - if (index + len >= total) { - JsonDocument doc; - DeserializationError err = deserializeJson(doc, pixelsBodyBuffer); - - if (err) { - LOG_WARN(LogTag::WEB, "Pixels JSON parse error: %s", err.c_str()); - request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); - return; - } - - CRGB* leds = lume::controller.getLeds(); - uint16_t ledCount = lume::controller.getLedCount(); - - // Handle brightness if provided - if (doc["brightness"].is()) { - lume::controller.setBrightness(constrain(doc["brightness"].as(), 0, 255)); - } - - // Method 1: Array of [r,g,b] arrays - if (doc["pixels"].is()) { - JsonArray pixels = doc["pixels"].as(); - uint16_t count = min((uint16_t)pixels.size(), ledCount); - - for (uint16_t i = 0; i < count; i++) { - JsonArray pixel = pixels[i].as(); - if (pixel.size() >= 3) { - leds[i].r = pixel[0].as(); - leds[i].g = pixel[1].as(); - leds[i].b = pixel[2].as(); - } - } - - FastLED.show(); - - JsonDocument response; - response["success"] = true; - response["pixelsSet"] = count; - String responseStr; - serializeJson(response, responseStr); - request->send(200, "application/json", responseStr); - return; - } - - // Method 2: Flat array [r,g,b,r,g,b,...] - if (doc["rgb"].is()) { - JsonArray rgb = doc["rgb"].as(); - uint16_t count = min((uint16_t)(rgb.size() / 3), ledCount); - - for (uint16_t i = 0; i < count; i++) { - leds[i].r = rgb[i * 3].as(); - leds[i].g = rgb[i * 3 + 1].as(); - leds[i].b = rgb[i * 3 + 2].as(); - } - - FastLED.show(); - - JsonDocument response; - response["success"] = true; - response["pixelsSet"] = count; - String responseStr; - serializeJson(response, responseStr); - request->send(200, "application/json", responseStr); - return; - } - - // Method 3: Fill all with single color - if (doc["fill"].is()) { - JsonArray fill = doc["fill"].as(); - if (!validateRgbArray(fill)) { - request->send(400, "application/json", "{\"error\":\"Fill requires array of [r,g,b] with 3 integer values (0-255)\"}"); - return; - } - CRGB color(fill[0].as(), fill[1].as(), fill[2].as()); - fill_solid(leds, ledCount, color); - FastLED.show(); - - request->send(200, "application/json", "{\"success\":true,\"filled\":true}"); - return; - } - - // Method 4: Gradient between two colors - if (doc["gradient"].is()) { - JsonObject grad = doc["gradient"].as(); - JsonArray from = grad["from"].as(); - JsonArray to = grad["to"].as(); - - if (!validateRgbArray(from) || !validateRgbArray(to)) { - request->send(400, "application/json", "{\"error\":\"Gradient requires 'from' and 'to' with [r,g,b] arrays\"}"); - return; - } - - CRGB startColor(from[0].as(), from[1].as(), from[2].as()); - CRGB endColor(to[0].as(), to[1].as(), to[2].as()); - - fill_gradient_RGB(leds, 0, startColor, ledCount - 1, endColor); - FastLED.show(); - - request->send(200, "application/json", "{\"success\":true,\"gradient\":true}"); - return; - } - - request->send(400, "application/json", "{\"error\":\"No valid pixel data. Use 'pixels', 'rgb', 'fill', or 'gradient'\"}"); - } -} - -// Scene API handlers -void handleApiScenesGet(AsyncWebServerRequest* request) { - JsonDocument doc; - storage.listScenes(doc); - - String response; - serializeJson(doc, response); - request->send(200, "application/json", response); -} - -void handleApiSceneGet(AsyncWebServerRequest* request) { - // Extract ID from URL path - String path = request->url(); - int lastSlash = path.lastIndexOf('/'); - int slotId = path.substring(lastSlash + 1).toInt(); - - Scene scene; - if (storage.loadScene(slotId, scene) && !scene.isEmpty()) { - JsonDocument doc; - doc["id"] = slotId; - doc["name"] = scene.name; - doc["spec"] = scene.jsonSpec; - - String response; - serializeJson(doc, response); - request->send(200, "application/json", response); - } else { - request->send(404, "application/json", "{\"error\":\"Scene not found\"}"); - } -} - -void handleApiScenePost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { - // Auth check at start of request - if (index == 0 && !checkAuth(request)) { - sendUnauthorized(request); - return; - } - - // Accumulate body data - if (index == 0) { - sceneBodyBuffer = ""; - if (total > MAX_REQUEST_BODY_SIZE) { - request->send(413, "application/json", "{\"error\":\"Request body too large\"}"); - return; - } - } - - for (size_t i = 0; i < len; i++) { - sceneBodyBuffer += (char)data[i]; - } - - // Process when complete - if (index + len >= total) { - JsonDocument doc; - DeserializationError error = deserializeJson(doc, sceneBodyBuffer); - - if (error) { - request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); - return; - } - - String name = doc["name"] | ""; - String spec = doc["spec"] | ""; - - if (name.length() == 0) { - request->send(400, "application/json", "{\"error\":\"Scene name required\"}"); - return; - } - - if (spec.length() == 0) { - request->send(400, "application/json", "{\"error\":\"Scene spec required\"}"); - return; - } - - // Find empty slot or slot specified by 'id' - int slot = -1; - if (doc["id"].is()) { - slot = doc["id"].as(); - if (slot < 0 || slot >= MAX_SCENES) { - request->send(400, "application/json", "{\"error\":\"Invalid slot ID\"}"); - return; - } - } else { - // Find first empty slot - for (int i = 0; i < MAX_SCENES; i++) { - Scene existing; - if (!storage.loadScene(i, existing) || existing.isEmpty()) { - slot = i; - break; - } - } - - if (slot < 0) { - request->send(400, "application/json", "{\"error\":\"No empty slots. Delete a scene first.\"}"); - return; - } - } - - Scene scene; - scene.name = name; - scene.jsonSpec = spec; - - if (storage.saveScene(slot, scene)) { - JsonDocument response; - response["success"] = true; - response["id"] = slot; - response["name"] = name; - - String responseStr; - serializeJson(response, responseStr); - request->send(200, "application/json", responseStr); - } else { - request->send(500, "application/json", "{\"error\":\"Failed to save scene\"}"); - } - } -} - -void handleApiSceneDelete(AsyncWebServerRequest* request) { - if (!checkAuth(request)) { - sendUnauthorized(request); - return; - } - - // Extract ID from URL path - String path = request->url(); - int lastSlash = path.lastIndexOf('/'); - int slotId = path.substring(lastSlash + 1).toInt(); - - if (slotId < 0 || slotId >= MAX_SCENES) { - request->send(400, "application/json", "{\"error\":\"Invalid slot ID\"}"); - return; - } - - if (storage.deleteScene(slotId)) { - request->send(200, "application/json", "{\"success\":true}"); - } else { - request->send(500, "application/json", "{\"error\":\"Failed to delete scene\"}"); - } -} - -void handleApiSceneApply(AsyncWebServerRequest* request) { - if (!checkAuth(request)) { - sendUnauthorized(request); - return; - } - - // Extract ID from URL path /api/scenes/{id}/apply - String path = request->url(); - - // Remove "/apply" from end - path = path.substring(0, path.lastIndexOf('/')); - int lastSlash = path.lastIndexOf('/'); - int slotId = path.substring(lastSlash + 1).toInt(); - - Scene scene; - if (!storage.loadScene(slotId, scene) || scene.isEmpty()) { - request->send(404, "application/json", "{\"error\":\"Scene not found\"}"); - return; - } - - // Parse and apply the scene spec - JsonDocument doc; - DeserializationError error = deserializeJson(doc, scene.jsonSpec); - - if (error) { - request->send(400, "application/json", "{\"error\":\"Invalid scene spec\"}"); - return; - } - - String errorMsg; - if (applyEffectSpec(doc, errorMsg)) { // Use local adapter - // Save the new LED state - JsonDocument stateDoc; - controllerStateToJson(stateDoc); - storage.saveLedState(stateDoc); - - request->send(200, "application/json", "{\"success\":true}"); - } else { - JsonDocument response; - response["error"] = errorMsg; - - String responseStr; - serializeJson(response, responseStr); - request->send(400, "application/json", responseStr); - } -} - -// ============================================================================ -// Nightlight API handlers -// ============================================================================ - -void handleApiNightlightGet(AsyncWebServerRequest* request) { - // TODO: Implement nightlight in v2 controller - JsonDocument doc; - doc["active"] = false; // Nightlight not yet implemented in v2 - doc["progress"] = 0.0; - - String response; - serializeJson(doc, response); - request->send(200, "application/json", response); -} - -void handleApiNightlightPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { - // Auth check at start of request - if (index == 0 && !checkAuth(request)) { - sendUnauthorized(request); - return; - } - - // Body size validation - if (index == 0) { - if (total > MAX_REQUEST_BODY_SIZE) { - request->send(413, "application/json", "{\"error\":\"Request too large\"}"); - return; - } - nightlightBodyBuffer = ""; - nightlightBodyBuffer.reserve(total); - } - - // Accumulate body chunks - nightlightBodyBuffer += String((char*)data, len); - - // Only process when complete - if (index + len < total) { - return; - } - - LOG_DEBUG(LogTag::WEB, "Nightlight request: %s", nightlightBodyBuffer.c_str()); - - JsonDocument doc; - DeserializationError error = deserializeJson(doc, nightlightBodyBuffer); - - if (error) { - request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); - return; - } - - // Get duration in seconds (default: 15 minutes = 900 seconds) - uint16_t duration = doc["duration"] | NIGHTLIGHT_DEFAULT_DURATION; - - // Validate duration (between 1 second and max) - if (duration < 1 || duration > NIGHTLIGHT_MAX_DURATION) { - JsonDocument response; - response["error"] = "Duration must be between 1 and " + String(NIGHTLIGHT_MAX_DURATION) + " seconds"; - String responseStr; - serializeJson(response, responseStr); - request->send(400, "application/json", responseStr); - return; - } - - // Get target brightness (default: 0 = fade to off) - uint8_t targetBrightness = doc["targetBrightness"] | NIGHTLIGHT_DEFAULT_TARGET; - - // TODO: Implement nightlight in v2 controller - // For now, just acknowledge the request - JsonDocument response; - response["success"] = true; - response["duration"] = duration; - response["targetBrightness"] = targetBrightness; - response["startBrightness"] = lume::controller.getBrightness(); - response["note"] = "Nightlight not yet implemented in v2"; - - String responseStr; - serializeJson(response, responseStr); - request->send(200, "application/json", responseStr); -} \ No newline at end of file diff --git a/src/main.h b/src/main.h new file mode 100644 index 0000000..e5a96df --- /dev/null +++ b/src/main.h @@ -0,0 +1,65 @@ +/** + * main.h - Main application header + * + * Provides overview of main.cpp structure: + * - WiFi & Network Setup + * - Web Server & API Handlers + * - Authentication & Security + * - Controller State Management + */ + +#pragma once + +#include +#include +#include +#include "storage.h" +#include "core/controller.h" + +// =========================================================================== +// Global State +// =========================================================================== + +extern AsyncWebServer server; +extern Config config; +extern bool wifiConnected; +extern bool webUiAvailable; + +// =========================================================================== +// Setup Functions +// =========================================================================== + +// =========================================================================== +// Authentication & Security +// =========================================================================== + +bool checkAuth(AsyncWebServerRequest* request); +void sendUnauthorized(AsyncWebServerRequest* request); + +// =========================================================================== +// API Handler Functions - Main Routes +// =========================================================================== +// These handlers remain in main.cpp as they are core to the application + +// =========================================================================== +// API Handler Functions - Segments (v2 Architecture) +// =========================================================================== +// New segment-based control for multi-zone LED management + +void handleApiSegments(AsyncWebServerRequest* request); +void handleApiSegmentsPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); + +// =========================================================================== +// Modular API Handlers (see src/api/ directory) +// =========================================================================== +// Nightlight: api/nightlight.{h,cpp} - Fade-to-sleep timer functionality +// Pixels: api/pixels.{h,cpp} - Direct pixel control +// Config: api/config.{h,cpp} - System configuration management +// Status: api/status.{h,cpp} - Health and status endpoints +// v2 Segments: api/segments.{h,cpp} - Multi-segment control (v2 architecture) + +// =========================================================================== +// Helper Functions - Validation +// =========================================================================== + +bool validateRgbArray(JsonArrayConst arr); diff --git a/src/network/ota.cpp b/src/network/ota.cpp new file mode 100644 index 0000000..8e40659 --- /dev/null +++ b/src/network/ota.cpp @@ -0,0 +1,94 @@ +#include "ota.h" +#include "../constants.h" +#include "../logging.h" +#include "../storage.h" +#include "../lume.h" +#include +#include +#include +#include + +// External globals +extern Config config; + +// Access Point password for OTA fallback +#define AP_PASSWORD "ledcontrol" + +void setupOTA() { + // Only enable OTA if WiFi is connected + if (WiFi.status() != WL_CONNECTED) { + LOG_DEBUG(LogTag::OTA, "Waiting for WiFi connection"); + return; + } + + // Start mDNS service for OTA discovery and easy access + if (!MDNS.begin(MDNS_HOSTNAME)) { + LOG_ERROR(LogTag::OTA, "Error starting mDNS"); + return; + } + // Add HTTP service for service discovery + MDNS.addService("http", "tcp", 80); + LOG_INFO(LogTag::OTA, "mDNS started: %s.local", MDNS_HOSTNAME); + + // Set OTA hostname + ArduinoOTA.setHostname(MDNS_HOSTNAME); + + // Set OTA port (default is 3232) + ArduinoOTA.setPort(3232); + + // Set OTA password (use auth token if set, otherwise fall back to AP password) + if (config.authToken.length() > 0) { + ArduinoOTA.setPassword(config.authToken.c_str()); + } else { + ArduinoOTA.setPassword(AP_PASSWORD); + } + + // OTA callbacks for status updates + ArduinoOTA.onStart([]() { + String type; + if (ArduinoOTA.getCommand() == U_FLASH) { + type = "sketch"; + } else { // U_SPIFFS + type = "filesystem"; + } + LOG_INFO(LogTag::OTA, "Starting update (%s)", type.c_str()); + // Disable watchdog during OTA (can take >30s) + esp_task_wdt_delete(NULL); + // Turn off LEDs during update + lume::controller.setPower(false); + }); + + ArduinoOTA.onEnd([]() { + LOG_INFO(LogTag::OTA, "Update complete!"); + // Re-enable watchdog after OTA + esp_task_wdt_add(NULL); + }); + + ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { + static uint8_t lastPercent = 255; + uint8_t percent = progress / (total / 100); + if (percent != lastPercent) { + lastPercent = percent; + Serial.printf("OTA Progress: %u%%\r", percent); // Keep inline progress + } + }); + + ArduinoOTA.onError([](ota_error_t error) { + const char* errStr = "Unknown"; + if (error == OTA_AUTH_ERROR) errStr = "Auth Failed"; + else if (error == OTA_BEGIN_ERROR) errStr = "Begin Failed"; + else if (error == OTA_CONNECT_ERROR) errStr = "Connect Failed"; + else if (error == OTA_RECEIVE_ERROR) errStr = "Receive Failed"; + else if (error == OTA_END_ERROR) errStr = "End Failed"; + LOG_ERROR(LogTag::OTA, "Error[%u]: %s", error, errStr); + // Re-enable watchdog after OTA error + esp_task_wdt_add(NULL); + }); + + ArduinoOTA.begin(); + + // Add mDNS service for OTA + MDNS.addService("arduino", "tcp", 3232); + + LOG_INFO(LogTag::OTA, "Ready (Hostname: lume.local, Port: 3232)"); +} diff --git a/src/network/ota.h b/src/network/ota.h new file mode 100644 index 0000000..aa348d4 --- /dev/null +++ b/src/network/ota.h @@ -0,0 +1,4 @@ +#pragma once + +// Setup OTA updates and mDNS +void setupOTA(); diff --git a/src/network/server.cpp b/src/network/server.cpp new file mode 100644 index 0000000..52085de --- /dev/null +++ b/src/network/server.cpp @@ -0,0 +1,431 @@ +#include "server.h" +#include "../main.h" +#include "../constants.h" +#include "../logging.h" +#include "../lume.h" +#include "../storage.h" +#include "../protocols/sacn.h" +#include "../protocols/mqtt.h" +#include "../api/status.h" +#include "../api/config.h" +#include "../api/pixels.h" +#include +#include + +// From api/nightlight.h +extern void handleApiNightlightGet(AsyncWebServerRequest* request); +extern void handleApiNightlightPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); + +// From api/prompt.h +extern void handleApiPromptPost(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); + +// From api/segments.h (v2 multi-segment API) +extern void handleApiV2SegmentsList(AsyncWebServerRequest* request); +extern void handleApiV2SegmentGet(AsyncWebServerRequest* request); +extern void handleApiV2SegmentCreate(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); +extern void handleApiV2SegmentUpdate(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); +extern void handleApiV2SegmentDelete(AsyncWebServerRequest* request); +extern void handleApiV2EffectsList(AsyncWebServerRequest* request); +extern void handleApiV2PalettesList(AsyncWebServerRequest* request); +extern void handleApiV2Info(AsyncWebServerRequest* request); +extern void handleApiV2ControllerGet(AsyncWebServerRequest* request); +extern void handleApiV2ControllerUpdate(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total); + +// External globals +extern AsyncWebServer server; +extern Config config; +extern bool wifiConnected; +extern bool webUiAvailable; + +static AsyncWebSocket ws("/ws"); +static unsigned long lastWsBroadcast = 0; +constexpr uint32_t WS_BROADCAST_INTERVAL_MS = 1000; + +static String contentTypeFromPath(const String& path) { + if (path.endsWith(".html")) return "text/html; charset=utf-8"; + if (path.endsWith(".css")) return "text/css; charset=utf-8"; + if (path.endsWith(".js")) return "application/javascript; charset=utf-8"; + if (path.endsWith(".svg")) return "image/svg+xml"; + if (path.endsWith(".png")) return "image/png"; + if (path.endsWith(".ico")) return "image/x-icon"; + if (path.endsWith(".json")) return "application/json; charset=utf-8"; + if (path.endsWith(".txt")) return "text/plain; charset=utf-8"; + return "application/octet-stream"; +} + +static void appendColorArray(JsonArray& arr, const CRGB& color) { + arr.add(color.r); + arr.add(color.g); + arr.add(color.b); +} + +static void buildControllerState(JsonDocument& doc) { + doc["type"] = "state"; + + JsonObject controllerJson = doc["controller"].to(); + controllerJson["power"] = lume::controller.getPower(); + controllerJson["brightness"] = lume::controller.getBrightness(); + controllerJson["ledCount"] = lume::controller.getLedCount(); + + JsonArray segmentsArr = doc["segments"].to(); + uint8_t segCount = lume::controller.getSegmentCount(); + for (uint8_t i = 0; i < segCount; i++) { + lume::Segment* seg = lume::controller.getSegment(i); + if (!seg) { + continue; + } + + JsonObject segObj = segmentsArr.add(); + segObj["id"] = seg->getId(); + segObj["start"] = seg->getStart(); + segObj["length"] = seg->getLength(); + segObj["reverse"] = seg->isReversed(); + segObj["effect"] = seg->getEffectId(); + segObj["speed"] = seg->getSpeed(); + segObj["intensity"] = seg->getIntensity(); + + JsonArray primary = segObj["primaryColor"].to(); + appendColorArray(primary, seg->getPrimaryColor()); + JsonArray secondary = segObj["secondaryColor"].to(); + appendColorArray(secondary, seg->getSecondaryColor()); + } +} + +static bool buildUiStatePayload(String& payload) { + StaticJsonDocument<4096> doc; + buildControllerState(doc); + payload.clear(); + serializeJson(doc, payload); + return payload.length() > 0; +} + +static void sendStateToClient(AsyncWebSocketClient* client) { + if (!client) { + return; + } + String payload; + if (buildUiStatePayload(payload)) { + client->text(payload); + } +} + +static void broadcastUiState() { + if (ws.count() == 0) { + return; + } + String payload; + if (buildUiStatePayload(payload)) { + ws.textAll(payload); + } +} + +static void handleWsEvent(AsyncWebSocket*, AsyncWebSocketClient* client, AwsEventType type, void*, uint8_t*, size_t) { + if (type == WS_EVT_CONNECT) { + sendStateToClient(client); + } +} + +void setupServer() { + ws.onEvent(handleWsEvent); + server.addHandler(&ws); + + if (webUiAvailable) { + server.serveStatic("/assets/", LittleFS, "/assets/") + .setCacheControl("public, max-age=604800"); + LOG_INFO(LogTag::WEB, "Serving UI assets from LittleFS"); + } else { + LOG_WARN(LogTag::WEB, "LittleFS not mounted; UI assets unavailable"); + } + + // Serve main page + server.on("/", HTTP_GET, handleRoot); + + // Health check endpoint - lightweight for monitoring + server.on("/health", HTTP_GET, [](AsyncWebServerRequest* request) { + JsonDocument doc; + + // Core health indicators + doc["status"] = "healthy"; + doc["uptime"] = millis() / 1000; + doc["version"] = FIRMWARE_VERSION; + + // Memory health + JsonObject memory = doc["memory"].to(); + memory["heap_free"] = ESP.getFreeHeap(); + memory["heap_min"] = ESP.getMinFreeHeap(); + memory["heap_max_block"] = ESP.getMaxAllocHeap(); + memory["psram_free"] = ESP.getFreePsram(); + + // Calculate heap fragmentation percentage + uint32_t heapFree = ESP.getFreeHeap(); + uint32_t maxBlock = ESP.getMaxAllocHeap(); + if (heapFree > 0) { + memory["fragmentation"] = 100 - (maxBlock * 100 / heapFree); + } + + // Network health + JsonObject network = doc["network"].to(); + network["wifi_connected"] = wifiConnected; + network["wifi_rssi"] = wifiConnected ? WiFi.RSSI() : 0; + network["ip"] = wifiConnected ? WiFi.localIP().toString() : WiFi.softAPIP().toString(); + network["ap_clients"] = WiFi.softAPgetStationNum(); + + // Component health + JsonObject components = doc["components"].to(); + components["led_controller"] = lume::controller.getLedCount() > 0; + components["storage"] = true; // Would fail at boot if broken + components["sacn_enabled"] = config.sacnEnabled; + components["sacn_receiving"] = lume::sacnProtocol.isActive(); + components["mqtt_enabled"] = config.mqttEnabled; + components["mqtt_connected"] = lume::mqtt.isConnected(); + + String response; + serializeJson(doc, response); + request->send(200, "application/json", response); + }); + + // API endpoints + server.on("/api/status", HTTP_GET, handleApiStatus); + server.on("/api/config", HTTP_GET, handleApiConfig); + + // POST handlers with body + server.on("/api/config", HTTP_POST, + [](AsyncWebServerRequest* request) {}, + NULL, + handleApiConfigPost + ); + + // Direct pixel control endpoint + server.on("/api/pixels", HTTP_POST, + [](AsyncWebServerRequest* request) {}, + NULL, + handleApiPixels + ); + + // Segment management endpoints (v2 architecture) + server.on("/api/segments", HTTP_GET, [](AsyncWebServerRequest* request) { + JsonDocument doc; + + // Global state + doc["power"] = lume::controller.getPower(); + doc["brightness"] = lume::controller.getBrightness(); + doc["ledCount"] = lume::controller.getLedCount(); + + // List all segments + JsonArray segArr = doc["segments"].to(); + uint8_t segCount = lume::controller.getSegmentCount(); + + for (uint8_t i = 0; i < segCount; i++) { + lume::Segment* seg = lume::controller.getSegment(i); + if (!seg) continue; + + JsonObject segObj = segArr.add(); + segObj["id"] = seg->getId(); + segObj["start"] = seg->getStart(); + segObj["length"] = seg->getLength(); + segObj["reverse"] = seg->isReversed(); + segObj["brightness"] = seg->getBrightness(); + segObj["speed"] = seg->getSpeed(); + segObj["intensity"] = seg->getParams().intensity; + + // Effect info + JsonObject effectObj = segObj["effect"].to(); + effectObj["id"] = seg->getEffectId(); + effectObj["name"] = seg->getEffectName(); + if (seg->getEffect()) { + effectObj["category"] = seg->getEffect()->categoryName(); + } + + // Colors + CRGB primary = seg->getPrimaryColor(); + CRGB secondary = seg->getSecondaryColor(); + + JsonArray colors = segObj["colors"].to(); + char hex1[8], hex2[8]; + snprintf(hex1, sizeof(hex1), "#%02x%02x%02x", primary.r, primary.g, primary.b); + snprintf(hex2, sizeof(hex2), "#%02x%02x%02x", secondary.r, secondary.g, secondary.b); + colors.add(hex1); + colors.add(hex2); + + // Capabilities + const lume::SegmentCapabilities& caps = seg->getCapabilities(); + JsonObject capsObj = segObj["capabilities"].to(); + capsObj["hasSpeed"] = caps.hasSpeed; + capsObj["hasIntensity"] = caps.hasIntensity; + capsObj["hasPalette"] = caps.hasPalette; + capsObj["hasSecondaryColor"] = caps.hasSecondaryColor; + } + + // Available effects + JsonArray effectsArr = doc["effects"].to(); + auto& registry = lume::effects(); + for (uint8_t i = 0; i < registry.getCount(); i++) { + const lume::EffectInfo* info = registry.getByIndex(i); + if (!info) continue; + + JsonObject effObj = effectsArr.add(); + effObj["id"] = info->id; + effObj["name"] = info->displayName; + effObj["category"] = info->categoryName(); // Use helper method + effObj["usesSpeed"] = info->usesSpeed; + effObj["usesIntensity"] = info->usesIntensity; + effObj["usesPalette"] = info->usesPalette; + } + + String response; + serializeJson(doc, response); + request->send(200, "application/json", response); + }); + + // Nightlight endpoints + server.on("/api/nightlight", HTTP_GET, handleApiNightlightGet); + server.on("/api/nightlight", HTTP_POST, + [](AsyncWebServerRequest* request) {}, + NULL, + handleApiNightlightPost + ); + server.on("/api/nightlight/stop", HTTP_POST, [](AsyncWebServerRequest* request) { + if (!checkAuth(request)) { + sendUnauthorized(request); + return; + } + lume::controller.stopNightlight(); + request->send(200, "application/json", "{\"success\":true}"); + }); + + // AI Prompt endpoint + server.on("/api/prompt", HTTP_POST, + [](AsyncWebServerRequest* request) {}, + NULL, + handleApiPromptPost + ); + + // =========================================================================== + // V2 API - Multi-segment LED control + // =========================================================================== + + // Controller-level endpoints (power, brightness) + server.on("/api/v2/controller", HTTP_GET, handleApiV2ControllerGet); + server.on("/api/v2/controller", HTTP_PUT, + [](AsyncWebServerRequest* request) {}, + NULL, + handleApiV2ControllerUpdate + ); + + // Segment management endpoints - URL path inspection for {id} parameter + // GET - Can be either /api/v2/segments (list) or /api/v2/segments/{id} (get one) + server.on("/api/v2/segments", HTTP_GET, [](AsyncWebServerRequest* request) { + String path = request->url(); + if (path.startsWith("/api/v2/segments/") && path.length() > 17) { + handleApiV2SegmentGet(request); + } else { + handleApiV2SegmentsList(request); + } + }); + + // POST - Create new segment (only /api/v2/segments, not with ID) + server.on("/api/v2/segments", HTTP_POST, + [](AsyncWebServerRequest* request) {}, + NULL, + handleApiV2SegmentCreate + ); + + // PUT - Update existing segment /api/v2/segments/{id} + server.on("/api/v2/segments", HTTP_PUT, + [](AsyncWebServerRequest* request) { + String path = request->url(); + if (!path.startsWith("/api/v2/segments/") || path.length() <= 17) { + request->send(400, "application/json", "{\"error\":\"Segment ID required\"}"); + } + }, + NULL, + [](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + String path = request->url(); + if (path.startsWith("/api/v2/segments/") && path.length() > 17) { + handleApiV2SegmentUpdate(request, data, len, index, total); + } + } + ); + + // DELETE - Remove segment /api/v2/segments/{id} + server.on("/api/v2/segments", HTTP_DELETE, [](AsyncWebServerRequest* request) { + String path = request->url(); + if (path.startsWith("/api/v2/segments/") && path.length() > 17) { + handleApiV2SegmentDelete(request); + } else { + request->send(400, "application/json", "{\"error\":\"Segment ID required\"}"); + } + }); + + // Effects and palettes metadata + server.on("/api/v2/effects", HTTP_GET, handleApiV2EffectsList); + server.on("/api/v2/palettes", HTTP_GET, handleApiV2PalettesList); + server.on("/api/v2/info", HTTP_GET, handleApiV2Info); + + // =========================================================================== + + // Handle CORS preflight + server.on("/api/*", HTTP_OPTIONS, [](AsyncWebServerRequest* request) { + AsyncWebServerResponse* response = request->beginResponse(200); + response->addHeader("Access-Control-Allow-Origin", "*"); + response->addHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + response->addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key"); + request->send(response); + }); + + // 404 handler with SPA fallback + server.onNotFound([](AsyncWebServerRequest* request) { + String path = request->url(); + if (path.startsWith("/api/")) { + request->send(404, "application/json", "{\"error\":\"Not found\"}"); + return; + } + + if (!webUiAvailable) { + request->send(404, "text/plain", "Not found"); + return; + } + + if (path.length() == 0) { + path = "/"; + } + if (!path.startsWith("/")) { + path = "/" + path; + } + if (path.endsWith("/")) { + path += "index.html"; + } + + if (LittleFS.exists(path)) { + request->send(LittleFS, path, contentTypeFromPath(path)); + return; + } + + // SPA fallback: serve index for client-side routes without extensions + if (path.indexOf('.') < 0 && LittleFS.exists("/index.html")) { + request->send(LittleFS, "/index.html", "text/html; charset=utf-8"); + return; + } + + request->send(404, "text/plain", "Not found"); + }); + + // Start server + DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*"); + server.begin(); + LOG_INFO(LogTag::WEB, "Web server started on port 80"); +} + +void loopServer() { + ws.cleanupClients(); + + if (ws.count() == 0) { + return; + } + + unsigned long now = millis(); + if (now - lastWsBroadcast >= WS_BROADCAST_INTERVAL_MS) { + broadcastUiState(); + lastWsBroadcast = now; + } +} diff --git a/src/network/server.h b/src/network/server.h new file mode 100644 index 0000000..c2055f3 --- /dev/null +++ b/src/network/server.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +// Setup web server routes and handlers +void setupServer(); + +// Periodic maintenance tasks (WebSocket cleanup, broadcast) +void loopServer(); diff --git a/src/network/wifi.cpp b/src/network/wifi.cpp new file mode 100644 index 0000000..e16930c --- /dev/null +++ b/src/network/wifi.cpp @@ -0,0 +1,88 @@ +#include "wifi.h" +#include "ota.h" +#include "../constants.h" +#include "../logging.h" +#include "../storage.h" +#include "../protocols/sacn.h" +#include "../protocols/mqtt.h" +#include + +// External globals +extern Config config; +extern bool wifiConnected; +extern unsigned long lastWifiAttempt; + +// Access Point settings +#define AP_SSID "LUME-Setup" +#define AP_PASSWORD "ledcontrol" + +void setupWiFi() { + // Always start AP mode for initial access + WiFi.mode(WIFI_AP_STA); + + // Start Access Point + WiFi.softAP(AP_SSID, AP_PASSWORD); + LOG_INFO(LogTag::WIFI, "AP started: %s", AP_SSID); + LOG_DEBUG(LogTag::WIFI, "AP IP: %s", WiFi.softAPIP().toString().c_str()); + + // Try to connect to configured WiFi + if (config.wifiSSID.length() > 0) { + LOG_INFO(LogTag::WIFI, "Connecting to WiFi: %s", config.wifiSSID.c_str()); + WiFi.begin(config.wifiSSID.c_str(), config.wifiPassword.c_str()); + + // Wait for connection (with timeout) + int attempts = 0; + while (WiFi.status() != WL_CONNECTED && attempts < 20) { + delay(500); + Serial.print("."); // Keep dots for visual feedback + attempts++; + } + Serial.println(); + + if (WiFi.status() == WL_CONNECTED) { + wifiConnected = true; + LOG_INFO(LogTag::WIFI, "Connected! IP: %s", WiFi.localIP().toString().c_str()); + } else { + LOG_WARN(LogTag::WIFI, "Connection failed, AP mode active"); + } + } else { + LOG_INFO(LogTag::WIFI, "No WiFi configured, AP mode only"); + } + + lastWifiAttempt = millis(); +} + +// Helper function for WiFi reconnection and status monitoring +void handleWifiMaintenance() { + // WiFi reconnection logic + if (!wifiConnected && config.wifiSSID.length() > 0) { + if (millis() - lastWifiAttempt > WIFI_RETRY_INTERVAL_MS) { + lastWifiAttempt = millis(); + LOG_INFO(LogTag::WIFI, "Attempting WiFi reconnection..."); + WiFi.begin(config.wifiSSID.c_str(), config.wifiPassword.c_str()); + } + } + + // Check WiFi status change + static bool lastWifiState = false; + bool currentWifiState = (WiFi.status() == WL_CONNECTED); + if (currentWifiState != lastWifiState) { + lastWifiState = currentWifiState; + wifiConnected = currentWifiState; + if (currentWifiState) { + LOG_INFO(LogTag::WIFI, "Connected! IP: %s", WiFi.localIP().toString().c_str()); + // Setup OTA when WiFi connects + setupOTA(); + // Start sACN protocol if enabled + if (config.sacnEnabled) { + lume::sacnProtocol.configure(config.sacnUniverse, config.sacnUniverseCount, + config.sacnUnicast, config.sacnStartChannel); + lume::sacnProtocol.begin(); + } + // MQTT will auto-reconnect in its update() cycle + } else { + LOG_WARN(LogTag::WIFI, "WiFi disconnected"); + lume::sacnProtocol.stop(); + } + } +} diff --git a/src/network/wifi.h b/src/network/wifi.h new file mode 100644 index 0000000..6554909 --- /dev/null +++ b/src/network/wifi.h @@ -0,0 +1,7 @@ +#pragma once + +// Setup WiFi in AP+STA mode +void setupWiFi(); + +// WiFi reconnection and status monitoring (call from main loop) +void handleWifiMaintenance(); diff --git a/src/secrets.h.example b/src/secrets.h.example index 0c12265..8a82777 100644 --- a/src/secrets.h.example +++ b/src/secrets.h.example @@ -17,9 +17,9 @@ #define DEV_WIFI_SSID "" // Your WiFi network name #define DEV_WIFI_PASSWORD "" // Your WiFi password -// API credentials (OpenRouter/Anthropic) -#define DEV_API_KEY "" // Your API key (starts with sk-or- for OpenRouter) -#define DEV_OPENROUTER_MODEL "claude-3-5-haiku-20241022" // AI model to use +// API credentials (Anthropic Claude) +#define DEV_AI_API_KEY "" // Your Anthropic API key (get from https://console.anthropic.com/) +#define DEV_AI_MODEL "claude-3-5-sonnet-20241022" // AI model to use // LED configuration (optional overrides) // Note: LED data pin is configured in constants.h (LED_DATA_PIN) diff --git a/src/storage.cpp b/src/storage.cpp index 1c856a5..4061627 100644 --- a/src/storage.cpp +++ b/src/storage.cpp @@ -20,8 +20,8 @@ bool Storage::loadConfig(Config& config) { config.wifiSSID = prefs.getString("ssid", ""); config.wifiPassword = prefs.getString("pass", ""); - config.apiKey = prefs.getString("apikey", ""); - config.openRouterModel = prefs.getString("model", "claude-sonnet-4-5-20250929"); + config.aiApiKey = prefs.getString("ai_apikey", ""); + config.aiModel = prefs.getString("ai_model", "claude-3-5-sonnet-20241022"); config.authToken = prefs.getString("authtoken", ""); config.ledCount = prefs.getUShort("ledcount", 160); config.defaultBrightness = prefs.getUChar("brightness", 128); @@ -50,8 +50,8 @@ bool Storage::saveConfig(const Config& config) { prefs.putString("ssid", config.wifiSSID); prefs.putString("pass", config.wifiPassword); - prefs.putString("apikey", config.apiKey); - prefs.putString("model", config.openRouterModel); + prefs.putString("ai_apikey", config.aiApiKey); + prefs.putString("ai_model", config.aiModel); prefs.putString("authtoken", config.authToken); prefs.putUShort("ledcount", config.ledCount); prefs.putUChar("brightness", config.defaultBrightness); @@ -157,9 +157,9 @@ bool Storage::clearPromptSpec() { void Storage::configToJson(const Config& config, JsonDocument& doc, bool maskApiKey) { doc["wifiSSID"] = config.wifiSSID; doc["wifiPassword"] = ""; // Never expose password - doc["apiKey"] = maskApiKey ? (config.apiKey.length() > 0 ? "****" + config.apiKey.substring(config.apiKey.length() - 4) : "") : config.apiKey; - doc["apiKeySet"] = config.apiKey.length() > 0; - doc["openRouterModel"] = config.openRouterModel; + doc["aiApiKey"] = maskApiKey ? (config.aiApiKey.length() > 0 ? "****" + config.aiApiKey.substring(config.aiApiKey.length() - 4) : "") : config.aiApiKey; + doc["aiApiKeySet"] = config.aiApiKey.length() > 0; + doc["aiModel"] = config.aiModel; doc["authToken"] = config.authToken.length() > 0 ? "****" : ""; doc["authEnabled"] = config.authToken.length() > 0; doc["ledCount"] = config.ledCount; @@ -190,15 +190,15 @@ bool Storage::configFromJson(Config& config, const JsonDocument& doc) { config.wifiPassword = pass; } } - if (doc["apiKey"].is()) { - String key = doc["apiKey"].as(); + if (doc["aiApiKey"].is()) { + String key = doc["aiApiKey"].as(); // Don't overwrite with masked value if (key.length() > 0 && !key.startsWith("****")) { - config.apiKey = key; + config.aiApiKey = key; } } - if (doc["openRouterModel"].is()) { - config.openRouterModel = doc["openRouterModel"].as(); + if (doc["aiModel"].is()) { + config.aiModel = doc["aiModel"].as(); } if (doc["authToken"].is()) { String token = doc["authToken"].as(); diff --git a/src/storage.h b/src/storage.h index 83510e3..861cb7f 100644 --- a/src/storage.h +++ b/src/storage.h @@ -9,8 +9,8 @@ struct Config { String wifiSSID; String wifiPassword; - String apiKey; - String openRouterModel; + String aiApiKey; // Anthropic API key + String aiModel; // AI model selection String authToken; // Optional API auth token (empty = no auth) uint16_t ledCount; uint8_t defaultBrightness; @@ -32,8 +32,8 @@ struct Config { Config() : wifiSSID(""), wifiPassword(""), - apiKey(""), - openRouterModel("claude-sonnet-4-5-20250929"), + aiApiKey(""), + aiModel("claude-3-5-sonnet-20241022"), authToken(""), ledCount(160), defaultBrightness(128), diff --git a/test/README b/test/README index 9b1e87b..b081b20 100644 --- a/test/README +++ b/test/README @@ -1,11 +1,81 @@ +LUME Test Scripts +================= -This directory is intended for PlatformIO Test Runner and project tests. +This directory contains test scripts for the LUME LED controller. -Unit Testing is a software testing method by which individual units of -source code, sets of one or more MCU program modules together with associated -control data, usage procedures, and operating procedures, are tested to -determine whether they are fit for use. Unit testing finds problems early -in the development cycle. +## Connection Diagnostic -More information about PlatformIO Unit Testing: +### check_connection.sh +Automatically scans for LUME on your network and provides troubleshooting help. + +Usage: + ./check_connection.sh + +Features: +- Tests mDNS hostname (lume.local) +- Tests AP mode (192.168.4.1) +- Scans common local IP ranges +- Shows device status if found +- Provides troubleshooting steps + +## Effect Testing Scripts + +### test_effects.sh (Advanced) +Automatically detects all effects from the API and cycles through them. +Requires `jq` and `curl` to be installed. + +Usage: + ./test_effects.sh [host] [delay] + ./test_effects.sh # Use lume.local, 1s delay + ./test_effects.sh 192.168.1.100 # Use IP, 1s delay + ./test_effects.sh lume.local 0.5 # Fast mode: 0.5s delay + ./test_effects.sh lume.local 0.2 # Rapid fire: 0.2s delay + +Features: +- Auto-detects available effects from API +- Creates segment if none exist +- Shows effect names and success/failure status +- Displays final state in JSON + +### test_effects_simple.sh (Simple) +Cycles through all 23 hardcoded effects. Only requires `curl`. + +Usage: + ./test_effects_simple.sh [host] [delay] [segment_id] + ./test_effects_simple.sh # Use lume.local, 1s delay, segment 0 + ./test_effects_simple.sh 192.168.1.100 # Use IP, 1s delay, segment 0 + ./test_effects_simple.sh lume.local 0.5 0 # Fast mode: 0.5s delay + ./test_effects_simple.sh lume.local 0.2 0 # Rapid fire: 0.2s delay + +Features: +- No dependencies except curl +- Works with existing segments +- Clean progress display + +## Manual API Testing Examples + +Power Control: + curl -X PUT http://lume.local/api/v2/controller -H "Content-Type: application/json" -d '{"power":true}' + curl -X PUT http://lume.local/api/v2/controller -H "Content-Type: application/json" -d '{"brightness":200}' + +Segment Management: + curl http://lume.local/api/v2/segments + curl -X POST http://lume.local/api/v2/segments -H "Content-Type: application/json" -d '{"start":0,"length":160,"effect":"rainbow"}' + curl -X PUT http://lume.local/api/v2/segments/0 -H "Content-Type: application/json" -d '{"effect":"fire","speed":150}' + curl -X DELETE http://lume.local/api/v2/segments/0 + +Effect Discovery: + curl http://lume.local/api/v2/effects | python3 -m json.tool + +## Quick Test + +Cycle through all 23 effects: +```bash +cd test +./test_effects_simple.sh # Default: 1s delay (23 seconds total) +./test_effects_simple.sh lume.local 0.5 # Fast mode (11.5 seconds) +./test_effects_simple.sh lume.local 0.2 # Rapid fire! (4.6 seconds) 🚀 +``` + +For PlatformIO Unit Testing: - https://docs.platformio.org/en/latest/advanced/unit-testing/index.html diff --git a/test/check_connection.sh b/test/check_connection.sh new file mode 100755 index 0000000..83b7b51 --- /dev/null +++ b/test/check_connection.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# +# check_connection.sh - Diagnostic tool for LUME connectivity +# Helps troubleshoot connection issues +# + +echo "🔍 LUME Connection Diagnostic Tool" +echo "==================================" +echo "" + +# Function to test a connection +test_connection() { + local host=$1 + local name=$2 + + printf "Testing %-30s " "$name ($host)..." + + if curl -s --max-time 10 "http://${host}/health" > /dev/null 2>&1; then + echo "✅ Success" + return 0 + else + echo "❌ Failed" + return 1 + fi +} + +# Test common hostnames and IPs +FOUND=0 + +echo "📡 Testing common LUME addresses:" +echo "" + +# Test mDNS hostname +if test_connection "lume.local" "mDNS hostname"; then + FOUND=1 + FOUND_HOST="lume.local" +fi + +# Test AP mode IP +if test_connection "192.168.4.1" "AP Mode"; then + FOUND=1 + FOUND_HOST="192.168.4.1" +fi + +# Test common local IPs +echo "" +echo "🌐 Scanning common local network ranges..." +echo " (This may take a moment)" +echo "" + +for i in {100..110}; do + host="192.168.1.${i}" + if timeout 10 curl -s --max-time 10 "http://${host}/health" > /dev/null 2>&1; then + printf "Testing %-30s ✅ Success\n" "192.168.1.${i}" + FOUND=1 + FOUND_HOST="192.168.1.${i}" + fi +done + +echo "" +echo "==================================" +echo "" + +if [ $FOUND -eq 1 ]; then + echo "✅ LUME found at: ${FOUND_HOST}" + echo "" + echo "📊 Device Status:" + curl -s "http://${FOUND_HOST}/api/status" | python3 -m json.tool 2>/dev/null || \ + curl -s "http://${FOUND_HOST}/api/status" + echo "" + echo "🎯 To run tests:" + echo " ./test_effects_simple.sh ${FOUND_HOST}" + echo " ./test_effects.sh ${FOUND_HOST}" + echo "" + echo "🌐 Web UI:" + echo " http://${FOUND_HOST}" +else + echo "❌ LUME not found" + echo "" + echo "Troubleshooting steps:" + echo "" + echo "1. Check power:" + echo " - Is the ESP32 powered on?" + echo " - Do you see LED activity?" + echo "" + echo "2. Check WiFi connection:" + echo " - Connect to 'LUME-Setup' WiFi network" + echo " - Password: ledcontrol" + echo " - Then run: ./check_connection.sh" + echo "" + echo "3. Check your network:" + echo " - Open web UI on device to configure WiFi" + echo " - Make sure LUME and your computer are on same network" + echo "" + echo "4. Try manual IP:" + echo " - Check your router for LUME's IP address" + echo " - Run: ./test_effects_simple.sh 192.168.x.x" + echo "" + echo "5. Check via serial monitor:" + echo " - Connect USB cable" + echo " - Run: pio device monitor" + echo " - Look for IP address in boot logs" +fi diff --git a/test/debug_connection.sh b/test/debug_connection.sh new file mode 100755 index 0000000..9953c45 --- /dev/null +++ b/test/debug_connection.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# +# debug_connection.sh - Debug connection issues +# + +HOST="${1:-lume.local}" + +echo "🔍 LUME Connection Debug" +echo "Target: ${HOST}" +echo "==========================" +echo "" + +echo "1️⃣ Testing DNS resolution:" +if host "${HOST}" > /dev/null 2>&1; then + echo "✅ DNS resolves:" + host "${HOST}" +else + echo "❌ DNS resolution failed" + echo " Trying ping..." + ping -c 1 "${HOST}" 2>&1 | head -3 +fi +echo "" + +echo "2️⃣ Testing root endpoint (/):" +echo " curl -v http://${HOST}/ 2>&1 | head -20" +curl -v "http://${HOST}/" 2>&1 | head -20 +echo "" + +echo "3️⃣ Testing /health endpoint:" +echo " curl -v http://${HOST}/health 2>&1 | head -20" +curl -v "http://${HOST}/health" 2>&1 | head -20 +echo "" + +echo "4️⃣ Testing /api/status endpoint:" +echo " curl -v http://${HOST}/api/status" +curl -v "http://${HOST}/api/status" 2>&1 | head -30 +echo "" + +echo "5️⃣ Testing /api/v2/segments endpoint:" +echo " curl http://${HOST}/api/v2/segments" +curl -s "http://${HOST}/api/v2/segments" | head -10 +echo "" + +echo "==========================" +echo "Debug complete" diff --git a/test/examples.sh b/test/examples.sh new file mode 100755 index 0000000..36db0fa --- /dev/null +++ b/test/examples.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# +# examples.sh - Example API calls for LUME v2 +# +# This script demonstrates common API operations +# Uncomment the examples you want to run +# + +HOST="${1:-lume.local}" + +echo "🎨 LUME API Examples" +echo "Target: http://${HOST}" +echo "" + +# 1. Get current status +echo "📊 Example 1: Get current status" +echo "Command: curl http://${HOST}/api/v2/segments" +# curl -s "http://${HOST}/api/v2/segments" | python3 -m json.tool +echo "" + +# 2. Turn on power and set brightness +echo "⚡ Example 2: Power on and set brightness to 200" +echo "Command: curl -X PUT http://${HOST}/api/v2/controller -d '{\"power\":true,\"brightness\":200}'" +# curl -s -X PUT "http://${HOST}/api/v2/controller" \ +# -H "Content-Type: application/json" \ +# -d '{"power":true,"brightness":200}' +echo "" + +# 3. Create a full strip rainbow effect +echo "🌈 Example 3: Create full strip rainbow effect" +echo "Command: curl -X POST http://${HOST}/api/v2/segments -d '{\"start\":0,\"length\":160,\"effect\":\"rainbow\"}'" +# curl -s -X POST "http://${HOST}/api/v2/segments" \ +# -H "Content-Type: application/json" \ +# -d '{"start":0,"length":160,"effect":"rainbow","speed":128,"intensity":128}' +echo "" + +# 4. Change effect to fire with high speed +echo "🔥 Example 4: Change segment 0 to fire effect with high speed" +echo "Command: curl -X PUT http://${HOST}/api/v2/segments/0 -d '{\"effect\":\"fire\",\"speed\":200}'" +# curl -s -X PUT "http://${HOST}/api/v2/segments/0" \ +# -H "Content-Type: application/json" \ +# -d '{"effect":"fire","speed":200,"intensity":180}' +echo "" + +# 5. Set custom colors for gradient effect +echo "🎨 Example 5: Gradient with custom colors (blue to purple)" +echo "Command: curl -X PUT http://${HOST}/api/v2/segments/0 -d '{\"effect\":\"gradient\",\"primaryColor\":[0,0,255],\"secondaryColor\":[128,0,255]}'" +# curl -s -X PUT "http://${HOST}/api/v2/segments/0" \ +# -H "Content-Type: application/json" \ +# -d '{"effect":"gradient","primaryColor":[0,0,255],"secondaryColor":[128,0,255],"speed":100}' +echo "" + +# 6. Create multi-segment setup (split strip in half) +echo "✂️ Example 6: Split strip - rainbow on first half, fire on second half" +echo "Command 1: curl -X POST http://${HOST}/api/v2/segments -d '{\"start\":0,\"length\":80,\"effect\":\"rainbow\"}'" +echo "Command 2: curl -X POST http://${HOST}/api/v2/segments -d '{\"start\":80,\"length\":80,\"effect\":\"fire\"}'" +# curl -s -X POST "http://${HOST}/api/v2/segments" \ +# -H "Content-Type: application/json" \ +# -d '{"start":0,"length":80,"effect":"rainbow","speed":128}' +# sleep 1 +# curl -s -X POST "http://${HOST}/api/v2/segments" \ +# -H "Content-Type: application/json" \ +# -d '{"start":80,"length":80,"effect":"fire","speed":150}' +echo "" + +# 7. Delete all segments (start fresh) +echo "🗑️ Example 7: Delete segment 0" +echo "Command: curl -X DELETE http://${HOST}/api/v2/segments/0" +# curl -s -X DELETE "http://${HOST}/api/v2/segments/0" +echo "" + +# 8. List all available effects +echo "📋 Example 8: List all available effects" +echo "Command: curl http://${HOST}/api/v2/effects" +# curl -s "http://${HOST}/api/v2/effects" | python3 -m json.tool +echo "" + +# 9. Solid color white at full brightness +echo "💡 Example 9: Solid white at full brightness" +echo "Command: curl -X PUT http://${HOST}/api/v2/segments/0 -d '{\"effect\":\"solid\",\"primaryColor\":[255,255,255]}'" +echo " curl -X PUT http://${HOST}/api/v2/controller -d '{\"brightness\":255}'" +# curl -s -X PUT "http://${HOST}/api/v2/segments/0" \ +# -H "Content-Type: application/json" \ +# -d '{"effect":"solid","primaryColor":[255,255,255]}' +# curl -s -X PUT "http://${HOST}/api/v2/controller" \ +# -H "Content-Type: application/json" \ +# -d '{"brightness":255}' +echo "" + +# 10. Slow breathing effect in blue +echo "🌬️ Example 10: Slow breathing effect in blue" +echo "Command: curl -X PUT http://${HOST}/api/v2/segments/0 -d '{\"effect\":\"breathe\",\"primaryColor\":[0,0,255],\"speed\":50}'" +# curl -s -X PUT "http://${HOST}/api/v2/segments/0" \ +# -H "Content-Type: application/json" \ +# -d '{"effect":"breathe","primaryColor":[0,0,255],"speed":50,"intensity":200}' +echo "" + +echo "💡 Tip: Uncomment the curl commands you want to run!" +echo " Edit this file and remove the # at the start of curl lines" diff --git a/test/test_effects.sh b/test/test_effects.sh new file mode 100755 index 0000000..1d94d4c --- /dev/null +++ b/test/test_effects.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# +# test_effects.sh - Cycle through all LED effects for testing +# Usage: ./test_effects.sh [host] [delay] +# +# Examples: +# ./test_effects.sh # Use lume.local, 3s delay +# ./test_effects.sh 192.168.1.100 # Use IP, 3s delay +# ./test_effects.sh lume.local 5 # Use lume.local, 5s delay +# + +HOST="${1:-lume.local}" +DELAY="${2:-1}" + +echo "🎨 LUME Effect Tester" +echo "Target: http://${HOST}" +echo "Delay: ${DELAY}s between effects" +echo "💡 Tip: Use 0.5 for fast mode, 0.2 for rapid fire" +echo "" + +# Check if host is reachable +echo "🔍 Checking connection..." +if curl -s --max-time 10 "http://${HOST}/health" > /dev/null 2>&1; then + echo "✅ Connected to LUME at ${HOST}" +elif curl -s --max-time 10 "http://192.168.4.1/health" > /dev/null 2>&1; then + echo "✅ Connected to LUME at 192.168.4.1 (AP mode)" + HOST="192.168.4.1" +else + echo "❌ Error: Cannot reach LUME" + echo "" + echo "Troubleshooting:" + echo " 1. Check if LUME is powered on" + echo " 2. If using WiFi: Make sure it's connected to your network" + echo " 3. Try using IP address instead: ./test_effects.sh 192.168.x.x" + echo " 4. If in AP mode: Connect to 'LUME-Setup' WiFi and use:" + echo " ./test_effects.sh 192.168.4.1" + echo "" + echo "Quick test: curl http://${HOST}/health" + exit 1 +fi +echo "" + +# Get list of effects +echo "📋 Fetching effects list..." +EFFECTS=$(curl -s "http://${HOST}/api/v2/effects" | jq -r '.effects[] | .id') + +if [ -z "$EFFECTS" ]; then + echo "❌ Error: Failed to fetch effects list" + exit 1 +fi + +EFFECT_COUNT=$(echo "$EFFECTS" | wc -l | tr -d ' ') +echo "Found ${EFFECT_COUNT} effects" +echo "" + +# Get current segments +SEGMENTS=$(curl -s "http://${HOST}/api/v2/segments") +SEGMENT_COUNT=$(echo "$SEGMENTS" | jq -r '.segments | length') + +# If no segments exist, create a full strip segment +if [ "$SEGMENT_COUNT" -eq 0 ]; then + echo "📝 No segments found, creating full strip segment..." + LED_COUNT=$(echo "$SEGMENTS" | jq -r '.ledCount // 160') + + CREATE_RESULT=$(curl -s -X POST "http://${HOST}/api/v2/segments" \ + -H "Content-Type: application/json" \ + -d "{\"start\":0,\"length\":${LED_COUNT},\"effect\":\"rainbow\",\"speed\":128,\"intensity\":128}") + + if [ $? -eq 0 ]; then + echo "✅ Segment created" + else + echo "❌ Error: Failed to create segment" + exit 1 + fi + + # Re-fetch segments + SEGMENTS=$(curl -s "http://${HOST}/api/v2/segments") + sleep 1 +fi + +# Get first segment ID +SEGMENT_ID=$(echo "$SEGMENTS" | jq -r '.segments[0].id') +echo "🎯 Using segment ID: ${SEGMENT_ID}" +echo "" + +# Turn on power if needed +echo "⚡ Ensuring power is ON..." +curl -s -X PUT "http://${HOST}/api/v2/controller" \ + -H "Content-Type: application/json" \ + -d '{"power":true}' > /dev/null + +sleep 1 +echo "" + +# Cycle through all effects +echo "🔄 Starting effect cycle..." +echo "Press Ctrl+C to stop" +echo "" + +COUNTER=1 +for EFFECT in $EFFECTS; do + # Get effect name for display + EFFECT_NAME=$(curl -s "http://${HOST}/api/v2/effects" | jq -r ".effects[] | select(.id == \"${EFFECT}\") | .name") + + printf "[%2d/%2d] Testing: %-20s " "$COUNTER" "$EFFECT_COUNT" "$EFFECT_NAME" + + # Update segment with the effect + RESULT=$(curl -s -X PUT "http://${HOST}/api/v2/segments/${SEGMENT_ID}" \ + -H "Content-Type: application/json" \ + -d "{\"effect\":\"${EFFECT}\"}" \ + -w "%{http_code}" \ + -o /dev/null) + + if [ "$RESULT" -eq 200 ]; then + echo "✅" + else + echo "❌ (HTTP ${RESULT})" + fi + + sleep "$DELAY" + ((COUNTER++)) +done + +echo "" +echo "🎉 Effect cycle complete!" +echo "" +echo "Current state:" +curl -s "http://${HOST}/api/v2/segments" | jq '.segments[0] | {id, effect, speed, intensity}' diff --git a/test/test_effects_simple.sh b/test/test_effects_simple.sh new file mode 100755 index 0000000..573513b --- /dev/null +++ b/test/test_effects_simple.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# +# test_effects_simple.sh - Cycle through all LED effects (no jq required) +# Usage: ./test_effects_simple.sh [host] [delay] [segment_id] +# +# Examples: +# ./test_effects_simple.sh # Use lume.local, 3s delay, segment 0 +# ./test_effects_simple.sh 192.168.1.100 # Use IP, 3s delay, segment 0 +# ./test_effects_simple.sh lume.local 5 0 # Use lume.local, 5s delay, segment 0 +# + +HOST="${1:-lume.local}" +DELAY="${2:-1}" +SEGMENT_ID="${3:-0}" + +echo "🎨 LUME Effect Tester (Simple)" +echo "Target: http://${HOST}" +echo "Delay: ${DELAY}s between effects" +echo "Segment: ${SEGMENT_ID}" +echo "💡 Tip: Use 0.5 for fast mode, 0.2 for rapid fire" +echo "" + +# Check if host is reachable +echo "🔍 Checking connection..." +if curl -s --max-time 10 "http://${HOST}/health" > /dev/null 2>&1; then + echo "✅ Connected to LUME at ${HOST}" +elif curl -s --max-time 10 "http://192.168.4.1/health" > /dev/null 2>&1; then + echo "✅ Connected to LUME at 192.168.4.1 (AP mode)" + HOST="192.168.4.1" +else + echo "❌ Error: Cannot reach LUME" + echo "" + echo "Troubleshooting:" + echo " 1. Check if LUME is powered on" + echo " 2. If using WiFi: Make sure it's connected to your network" + echo " 3. Try using IP address instead: ./test_effects_simple.sh 192.168.x.x" + echo " 4. If in AP mode: Connect to 'LUME-Setup' WiFi and use:" + echo " ./test_effects_simple.sh 192.168.4.1" + echo "" + echo "Quick test: curl http://${HOST}/health" + exit 1 +fi +echo "" + +# Turn on power +echo "⚡ Ensuring power is ON..." +curl -s -X PUT "http://${HOST}/api/v2/controller" \ + -H "Content-Type: application/json" \ + -d '{"power":true}' > /dev/null + +sleep 1 +echo "" + +# All 23 effects in order +EFFECTS=( + "solid" + "rainbow" + "confetti" + "fire" + "fireup" + "gradient" + "pulse" + "breathe" + "colorwaves" + "wave" + "theater" + "sparkle" + "noise" + "meteor" + "comet" + "rain" + "twinkle" + "strobe" + "sinelon" + "scanner" + "candle" + "pride" + "pacifica" +) + +TOTAL=${#EFFECTS[@]} + +echo "🔄 Starting effect cycle through ${TOTAL} effects..." +echo "Press Ctrl+C to stop" +echo "" + +COUNTER=1 +for EFFECT in "${EFFECTS[@]}"; do + printf "[%2d/%2d] Testing: %-20s " "$COUNTER" "$TOTAL" "$EFFECT" + + # Update segment with the effect (fire and forget for speed) + HTTP_CODE=$(curl -s --max-time 2 -X PUT "http://${HOST}/api/v2/segments/${SEGMENT_ID}" \ + -H "Content-Type: application/json" \ + -d "{\"effect\":\"${EFFECT}\"}" \ + -w "%{http_code}" \ + -o /dev/null) + + if [ "$HTTP_CODE" -eq 200 ]; then + echo "✅" + else + echo "❌ (HTTP ${HTTP_CODE})" + fi + + # Support fractional seconds (e.g., 0.5, 0.2) + sleep "$DELAY" + ((COUNTER++)) +done + +echo "" +echo "🎉 Effect cycle complete!" +echo "" +echo "To view current state, run:" +echo " curl -s http://${HOST}/api/v2/segments | python3 -m json.tool"