diff --git a/docs/API.md b/docs/API.md index 0ce0fb4..2914ca6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,504 +1,263 @@ -# LED Controller API Specification +# LED Driver ESPNow API Documentation -**Base URL:** `http://device-ip/` or `http://192.168.4.1/` (when in AP mode) -**Protocol:** HTTP/1.1 -**Content-Type:** `application/json` +This document describes the ESPNow message format for controlling LED driver devices. -## Presets API +## Message Format -### GET /presets +All messages are JSON objects sent via ESPNow with the following structure: -List all presets. - -**Response:** `200 OK` ```json { - "preset1": { - "name": "preset1", - "pattern": "on", - "colors": [[255, 0, 0]], - "delay": 100, - "n1": 0, - "n2": 0, - "n3": 0, - "n4": 0, - "n5": 0, - "n6": 0, - "n7": 0, - "n8": 0 + "v": "1", + "presets": { ... }, + "select": { ... } +} +``` + +### Version Field + +- **`v`** (required): Message version, must be `"1"`. Messages with other versions are ignored. + +## Presets + +Presets define LED patterns with their configuration. Each preset has a name and contains pattern-specific settings. + +### Preset Structure + +```json +{ + "presets": { + "preset_name": { + "pattern": "pattern_type", + "colors": ["#RRGGBB", ...], + "delay": 100, + "brightness": 127, + "auto": true, + "n1": 0, + "n2": 0, + "n3": 0, + "n4": 0, + "n5": 0, + "n6": 0 + } } } ``` -### GET /presets/{name} +### Preset Fields -Get a specific preset by name. +- **`pattern`** (required): Pattern type. Options: + - `"off"` - Turn off all LEDs + - `"on"` - Solid color + - `"blink"` - Blinking pattern + - `"rainbow"` - Rainbow color cycle + - `"pulse"` - Pulse/fade pattern + - `"transition"` - Color transition + - `"chase"` - Chasing pattern + - `"circle"` - Circle loading pattern + +- **`colors`** (optional): Array of hex color strings (e.g., `"#FF0000"` for red). Default: `["#FFFFFF"]` + - Colors are automatically converted from hex to RGB and reordered based on device color order setting + - Supports multiple colors for patterns that use them + +- **`delay`** (optional): Delay in milliseconds between pattern updates. Default: `100` + +- **`brightness`** (optional): Brightness level (0-255). Default: `127` + +- **`auto`** (optional): Auto mode flag. Default: `true` + - `true`: Pattern runs continuously + - `false`: Pattern advances one step per beat (manual mode) + +- **`n1` through `n6`** (optional): Pattern-specific numeric parameters. Default: `0` + - See pattern-specific documentation below + +### Pattern-Specific Parameters + +#### Rainbow +- **`n1`**: Step increment (how many color wheel positions to advance per update). Default: `1` + +#### Pulse +- **`n1`**: Attack time in milliseconds (fade in) +- **`n2`**: Hold time in milliseconds (full brightness) +- **`n3`**: Decay time in milliseconds (fade out) +- **`delay`**: Delay time in milliseconds (off between pulses) + +#### Transition +- **`delay`**: Transition duration in milliseconds + +#### Chase +- **`n1`**: Number of LEDs with first color +- **`n2`**: Number of LEDs with second color +- **`n3`**: Movement amount on even steps (can be negative) +- **`n4`**: Movement amount on odd steps (can be negative) + +#### Circle +- **`n1`**: Head movement rate (LEDs per second) +- **`n2`**: Maximum length +- **`n3`**: Tail movement rate (LEDs per second) +- **`n4`**: Minimum length + +## Select Messages + +Select messages control which preset is active on which device. The format uses a list to support step synchronization. + +### Select Format -**Response:** `200 OK` ```json { - "name": "preset1", - "pattern": "on", - "colors": [[255, 0, 0]], - "delay": 100, - "n1": 0, - "n2": 0, - "n3": 0, - "n4": 0, - "n5": 0, - "n6": 0, - "n7": 0, - "n8": 0 -} -``` - -**Response:** `404 Not Found` -```json -{ - "error": "Preset not found" -} -``` - -### POST /presets - -Create a new preset. - -**Request Body:** -```json -{ - "name": "preset1", - "pattern": "on", - "colors": [[255, 0, 0]], - "delay": 100, - "n1": 0, - "n2": 0, - "n3": 0, - "n4": 0, - "n5": 0, - "n6": 0, - "n7": 0, - "n8": 0 -} -``` - -**Response:** `201 Created` - Returns the created preset - -**Response:** `400 Bad Request` -```json -{ - "error": "Name is required" -} -``` - -**Response:** `409 Conflict` -```json -{ - "error": "Preset already exists" -} -``` - -### PUT /presets/{name} - -Update an existing preset. - -**Request Body:** -```json -{ - "delay": 200, - "colors": [[0, 255, 0]] -} -``` - -**Response:** `200 OK` - Returns the updated preset - -**Response:** `404 Not Found` -```json -{ - "error": "Preset not found" -} -``` - -### DELETE /presets/{name} - -Delete a preset. - -**Response:** `200 OK` -```json -{ - "message": "Preset deleted successfully" -} -``` - -**Response:** `404 Not Found` -```json -{ - "error": "Preset not found" -} -``` - -## Profiles API - -### GET /profiles - -List all profiles. - -**Response:** `200 OK` -```json -{ - "profile1": { - "name": "profile1", - "description": "Profile description", - "scenes": [] + "select": { + "device_name": ["preset_name"], + "device_name2": ["preset_name2", step_value] } } ``` -### GET /profiles/{name} +### Select Fields -Get a specific profile by name. +- **`select`**: Object mapping device names to selection lists + - **Key**: Device name (as configured in device settings) + - **Value**: List with one or two elements: + - `["preset_name"]` - Select preset (uses default step behavior) + - `["preset_name", step]` - Select preset with explicit step value (for synchronization) -**Response:** `200 OK` +### Step Synchronization + +The step value allows precise synchronization across multiple devices: + +- **Without step**: `["preset_name"]` + - If switching to different preset: step resets to 0 + - If selecting "off" pattern: step resets to 0 + - If selecting same preset (beat): step is preserved, pattern restarts + +- **With step**: `["preset_name", 10]` + - Explicitly sets step to the specified value + - Useful for synchronizing multiple devices to the same step + +### Beat Functionality + +Calling `select()` again with the same preset name acts as a "beat" - it restarts the pattern generator: + +- **Single-tick patterns** (rainbow, chase in manual mode): Advance one step per beat +- **Multi-tick patterns** (pulse in manual mode): Run through full cycle per beat + +Example beat sequence: ```json -{ - "name": "profile1", - "description": "Profile description", - "scenes": [] -} +// Beat 1 +{"select": {"device1": ["rainbow_preset"]}} + +// Beat 2 (same preset = beat) +{"select": {"device1": ["rainbow_preset"]}} + +// Beat 3 +{"select": {"device1": ["rainbow_preset"]}} ``` -**Response:** `404 Not Found` +## Synchronization + +### Using "off" Pattern + +Selecting the "off" pattern resets the step counter to 0, providing a synchronization point: + ```json { - "error": "Profile not found" -} -``` - -### POST /profiles - -Create a new profile. - -**Request Body:** -```json -{ - "name": "profile1", - "description": "Profile description", - "scenes": [] -} -``` - -**Response:** `201 Created` - Returns the created profile - -**Response:** `400 Bad Request` -```json -{ - "error": "Name is required" -} -``` - -**Response:** `409 Conflict` -```json -{ - "error": "Profile already exists" -} -``` - -### PUT /profiles/{name} - -Update an existing profile. - -**Request Body:** -```json -{ - "description": "Updated description" -} -``` - -**Response:** `200 OK` - Returns the updated profile - -**Response:** `404 Not Found` -```json -{ - "error": "Profile not found" -} -``` - -### DELETE /profiles/{name} - -Delete a profile. - -**Response:** `200 OK` -```json -{ - "message": "Profile deleted successfully" -} -``` - -**Response:** `404 Not Found` -```json -{ - "error": "Profile not found" -} -``` - -## Scenes API - -### GET /scenes - -List all scenes. Optionally filter by profile using query parameter. - -**Query Parameters:** -- `profile` (optional): Filter scenes by profile name - -**Example:** `GET /scenes?profile=profile1` - -**Response:** `200 OK` -```json -{ - "profile1:scene1": { - "name": "scene1", - "profile_name": "profile1", - "description": "Scene description", - "transition_time": 0, - "devices": [ - {"device_name": "device1", "preset_name": "preset1"}, - {"device_name": "device2", "preset_name": "preset2"} - ] + "select": { + "device1": ["off"], + "device2": ["off"] } } ``` -### GET /scenes/{profile_name}/{scene_name} +After all devices are "off", switching to a pattern ensures they all start from step 0: -Get a specific scene. - -**Response:** `200 OK` ```json { - "name": "scene1", - "profile_name": "profile1", - "description": "Scene description", - "transition_time": 0, - "devices": [ - {"device_name": "device1", "preset_name": "preset1"}, - {"device_name": "device2", "preset_name": "preset2"} - ] + "select": { + "device1": ["rainbow_preset"], + "device2": ["rainbow_preset"] + } } ``` -**Response:** `404 Not Found` +### Using Step Parameter + +For precise synchronization, use the step parameter: + ```json { - "error": "Scene not found" + "select": { + "device1": ["rainbow_preset", 10], + "device2": ["rainbow_preset", 10], + "device3": ["rainbow_preset", 10] + } } ``` -### POST /scenes +All devices will start at step 10 and advance together on subsequent beats. -Create a new scene. +## Complete Example -**Request Body:** ```json { - "name": "scene1", - "profile_name": "profile1", - "description": "Scene description", - "transition_time": 0, - "devices": [ - {"device_name": "device1", "preset_name": "preset1"}, - {"device_name": "device2", "preset_name": "preset2"} - ] + "v": "1", + "presets": { + "red_blink": { + "pattern": "blink", + "colors": ["#FF0000"], + "delay": 200, + "brightness": 255, + "auto": true + }, + "rainbow_manual": { + "pattern": "rainbow", + "delay": 100, + "n1": 2, + "auto": false + }, + "pulse_slow": { + "pattern": "pulse", + "colors": ["#00FF00"], + "delay": 500, + "n1": 1000, + "n2": 500, + "n3": 1000, + "auto": false + } + }, + "select": { + "device1": ["red_blink"], + "device2": ["rainbow_manual", 0], + "device3": ["pulse_slow"] + } } ``` -**Response:** `201 Created` - Returns the created scene +## Message Processing -**Response:** `400 Bad Request` -```json -{ - "error": "Name is required" -} -``` -or -```json -{ - "error": "Profile name is required" -} -``` +1. **Version Check**: Messages with `v != "1"` are rejected +2. **Preset Processing**: Presets are created or updated (upsert behavior) +3. **Color Conversion**: Hex colors are converted to RGB tuples and reordered based on device color order +4. **Selection**: Devices select their assigned preset, optionally with step value -**Response:** `409 Conflict` -```json -{ - "error": "Scene already exists" -} -``` +## Best Practices -### PUT /scenes/{profile_name}/{scene_name} +1. **Always include version**: Set `"v": "1"` in all messages +2. **Use "off" for sync**: Select "off" pattern to synchronize devices before starting patterns +3. **Beats for manual mode**: Send select messages repeatedly with same preset name to advance manual patterns +4. **Step for precision**: Use step parameter when exact synchronization is required +5. **Color format**: Always use hex strings (`"#RRGGBB"`), conversion is automatic -Update an existing scene. +## Error Handling -**Request Body:** -```json -{ - "transition_time": 500, - "description": "Updated description" -} -``` +- Invalid version: Message is ignored +- Missing preset: Selection fails, device keeps current preset +- Invalid pattern: Selection fails, device keeps current preset +- Missing colors: Pattern uses default white color +- Invalid step: Step value is used as-is (may cause unexpected behavior) -**Response:** `200 OK` - Returns the updated scene +## Notes -**Response:** `404 Not Found` -```json -{ - "error": "Scene not found" -} -``` - -### DELETE /scenes/{profile_name}/{scene_name} - -Delete a scene. - -**Response:** `200 OK` -```json -{ - "message": "Scene deleted successfully" -} -``` - -**Response:** `404 Not Found` -```json -{ - "error": "Scene not found" -} -``` - -### POST /scenes/{profile_name}/{scene_name}/devices - -Add a device assignment to a scene. - -**Request Body:** -```json -{ - "device_name": "device1", - "preset_name": "preset1" -} -``` - -**Response:** `200 OK` - Returns the updated scene - -**Response:** `400 Bad Request` -```json -{ - "error": "Device name and preset name are required" -} -``` - -**Response:** `404 Not Found` -```json -{ - "error": "Scene not found" -} -``` - -### DELETE /scenes/{profile_name}/{scene_name}/devices/{device_name} - -Remove a device assignment from a scene. - -**Response:** `200 OK` - Returns the updated scene - -**Response:** `404 Not Found` -```json -{ - "error": "Scene not found" -} -``` - -## Patterns API - -### GET /patterns - -Get the list of available pattern names. - -**Response:** `200 OK` -```json -["on", "bl", "cl", "rb", "sb", "o"] -``` - -### POST /patterns - -Add a new pattern name to the list. - -**Request Body:** -```json -{ - "name": "new_pattern" -} -``` - -**Response:** `201 Created` - Returns the updated list of patterns -```json -["on", "bl", "cl", "rb", "sb", "o", "new_pattern"] -``` - -**Response:** `400 Bad Request` -```json -{ - "error": "Name is required" -} -``` - -**Response:** `409 Conflict` -```json -{ - "error": "Pattern already exists" -} -``` - -### DELETE /patterns/{name} - -Remove a pattern name from the list. - -**Response:** `200 OK` -```json -{ - "message": "Pattern deleted successfully" -} -``` - -**Response:** `404 Not Found` -```json -{ - "error": "Pattern not found" -} -``` - -## Error Responses - -All endpoints may return the following error responses: - -**400 Bad Request** - Invalid request data -```json -{ - "error": "Error message" -} -``` - -**404 Not Found** - Resource not found -```json -{ - "error": "Resource not found" -} -``` - -**409 Conflict** - Resource already exists -```json -{ - "error": "Resource already exists" -} -``` - -**500 Internal Server Error** - Server error -```json -{ - "error": "Error message" -} -``` +- Colors are automatically converted from hex strings to RGB tuples +- Color order reordering happens automatically based on device settings +- Step counter wraps around (0-255 for rainbow, unbounded for others) +- Manual mode patterns stop after one step/cycle, waiting for next beat +- Auto mode patterns run continuously until changed diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ce3a525 --- /dev/null +++ b/install.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Install script - runs pipenv install + +pipenv install "$@" diff --git a/msg.json b/msg.json new file mode 100644 index 0000000..b31b800 --- /dev/null +++ b/msg.json @@ -0,0 +1,23 @@ +{ + "g":{ + "df": { + "pt": "on", + "cl": ["#ff0000"], + "br": 200, + "n1": 10, + "n2": 10, + "n3": 10, + "n4": 10, + "n5": 10, + "n6": 10, + "dl": 100 + }, + "dj": { + "pt": "blink", + "cl": ["#00ff00"], + "dl": 500 + } + }, + "sv": true, + "st": 0 +} \ No newline at end of file diff --git a/src/controllers/settings.py b/src/controllers/settings.py new file mode 100644 index 0000000..6e7a85b --- /dev/null +++ b/src/controllers/settings.py @@ -0,0 +1,132 @@ +from microdot import Microdot, send_file +from settings import Settings +import util.wifi as wifi +import json + +controller = Microdot() +settings = Settings() + +@controller.get('') +async def get_settings(request): + """Get all settings.""" + return json.dumps(dict(settings)), 200, {'Content-Type': 'application/json'} + +@controller.get('/wifi/station') +async def get_station_status(request): + """Get WiFi station connection status.""" + status = wifi.get_sta_status() + if status: + return json.dumps(status), 200, {'Content-Type': 'application/json'} + return json.dumps({"error": "Failed to get station status"}), 500 + +@controller.post('/wifi/station') +async def connect_station(request): + """Connect to WiFi station with credentials.""" + try: + data = request.json + ssid = data.get('ssid') + password = data.get('password', '') + ip = data.get('ip') + gateway = data.get('gateway') + + if not ssid: + return json.dumps({"error": "SSID is required"}), 400 + + # Save credentials to settings + settings['wifi_station_ssid'] = ssid + settings['wifi_station_password'] = password + if ip: + settings['wifi_station_ip'] = ip + if gateway: + settings['wifi_station_gateway'] = gateway + settings.save() + + # Attempt connection + result = wifi.connect(ssid, password, ip, gateway) + if result: + return json.dumps({ + "message": "Connected successfully", + "ip": result[0], + "netmask": result[1], + "gateway": result[2], + "dns": result[3] if len(result) > 3 else None + }), 200, {'Content-Type': 'application/json'} + else: + return json.dumps({"error": "Failed to connect"}), 400 + except Exception as e: + return json.dumps({"error": str(e)}), 500 + +@controller.get('/wifi/ap') +async def get_ap_config(request): + """Get Access Point configuration.""" + config = wifi.get_ap_config() + if config: + # Also get saved settings + config['saved_ssid'] = settings.get('wifi_ap_ssid') + config['saved_password'] = settings.get('wifi_ap_password') + config['saved_channel'] = settings.get('wifi_ap_channel') + return json.dumps(config), 200, {'Content-Type': 'application/json'} + return json.dumps({"error": "Failed to get AP config"}), 500 + +@controller.post('/wifi/ap') +async def configure_ap(request): + """Configure Access Point.""" + try: + data = request.json + ssid = data.get('ssid') + password = data.get('password', '') + channel = data.get('channel') + + if not ssid: + return json.dumps({"error": "SSID is required"}), 400 + + # Validate channel (1-11 for 2.4GHz) + if channel is not None: + channel = int(channel) + if channel < 1 or channel > 11: + return json.dumps({"error": "Channel must be between 1 and 11"}), 400 + + # Save to settings + settings['wifi_ap_ssid'] = ssid + settings['wifi_ap_password'] = password + if channel is not None: + settings['wifi_ap_channel'] = channel + settings.save() + + # Configure AP + wifi.ap(ssid, password, channel) + + return json.dumps({ + "message": "AP configured successfully", + "ssid": ssid, + "channel": channel + }), 200, {'Content-Type': 'application/json'} + except Exception as e: + return json.dumps({"error": str(e)}), 500 + +@controller.get('/wifi/station/credentials') +async def get_station_credentials(request): + """Get saved WiFi station credentials (without password).""" + return json.dumps({ + "ssid": settings.get('wifi_station_ssid', ''), + "ip": settings.get('wifi_station_ip', ''), + "gateway": settings.get('wifi_station_gateway', '') + }), 200, {'Content-Type': 'application/json'} + +@controller.put('/settings') +async def update_settings(request): + """Update general settings.""" + try: + data = request.json + for key, value in data.items(): + settings[key] = value + settings.save() + return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'} + except Exception as e: + return json.dumps({"error": str(e)}), 500 + +@controller.get('/page') +async def settings_page(request): + """Serve the settings page.""" + return send_file('templates/settings.html') + diff --git a/src/profile.py b/src/profile.py new file mode 100644 index 0000000..e69de29 diff --git a/src/settings.json b/src/settings.json new file mode 100644 index 0000000..1155c37 --- /dev/null +++ b/src/settings.json @@ -0,0 +1 @@ +{"name": "led-controller", "patterns": [{"name": "Off", "pt": "off", "cl": ["#ff0000"], "dl": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 10, "n6": 10}, {"name": "On", "pt": "on", "cl": ["#ff0000"], "dl": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 10, "n6": 10}, {"name": "Blink", "pt": "blink", "cl": ["#ff0000"], "dl": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 10, "n6": 10}, {"name": "Rainbow", "pt": "rainbow", "cl": ["#ff0000"], "dl": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 10, "n6": 10}, {"name": "test1", "pt": "off", "dl": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 10, "n6": 10, "cl": ["#ff0000"]}, {"name": "test2", "pt": "off", "dl": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 10, "n6": 10, "cl": ["#ff0000"]}, {"name": "test3", "pt": "off", "dl": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 10, "n6": 10, "cl": ["#ff0000"]}]} \ No newline at end of file diff --git a/src/templates/settings.html b/src/templates/settings.html new file mode 100644 index 0000000..ca9b584 --- /dev/null +++ b/src/templates/settings.html @@ -0,0 +1,430 @@ + + + + + + LED Controller - Settings + + + + +
+
+ ← Back to Dashboard + +
+

Device Settings

+

Configure WiFi and device settings

+
+ +
+ + +
+

WiFi Station (Client) Settings

+ +
+

Connection Status

+

Loading...

+
+ +
+
+ + + The name of the WiFi network to connect to +
+ +
+ + + Leave empty for open networks +
+ +
+
+ + + Static IP address (leave empty for DHCP) +
+ +
+ + + Gateway/router IP address +
+
+ +
+ +
+
+
+ + +
+

WiFi Access Point Settings

+ +
+

AP Status

+

Loading...

+
+ +
+
+ + + The name of the WiFi access point this device creates +
+ +
+ + + Leave empty for open network (min 8 characters if set) +
+ +
+ + + WiFi channel (1-11 for 2.4GHz). Leave empty for auto. +
+ +
+ +
+
+
+
+
+ + + + + diff --git a/src/util/README.md b/src/util/README.md new file mode 100644 index 0000000..bc5e6c6 --- /dev/null +++ b/src/util/README.md @@ -0,0 +1,80 @@ +# ESPNow Message Builder + +This utility module provides functions to build ESPNow messages according to the LED Driver API specification. + +## Usage + +### Basic Message Building + +```python +from util.espnow_message import build_message, build_preset_dict, build_select_dict + +# Build a message with presets and select +presets = { + "red_blink": build_preset_dict({ + "pattern": "blink", + "colors": ["#FF0000"], + "delay": 200, + "brightness": 255, + "auto": True + }) +} + +select = build_select_dict({ + "device1": "red_blink" +}) + +message = build_message(presets=presets, select=select) +# Result: {"v": "1", "presets": {...}, "select": {...}} +``` + +### Building Select Messages with Step Synchronization + +```python +from util.espnow_message import build_message, build_select_dict + +# Select with step for synchronization +select = build_select_dict( + {"device1": "rainbow_preset", "device2": "rainbow_preset"}, + step_mapping={"device1": 10, "device2": 10} +) + +message = build_message(select=select) +# Result: {"v": "1", "select": {"device1": ["rainbow_preset", 10], "device2": ["rainbow_preset", 10]}} +``` + +### Converting Presets + +```python +from util.espnow_message import build_preset_dict, build_presets_dict + +# Single preset +preset = build_preset_dict({ + "name": "my_preset", + "pattern": "rainbow", + "colors": ["#FF0000", "#00FF00"], # Can be hex strings or RGB tuples + "delay": 100, + "brightness": 127, + "auto": False, + "n1": 2 +}) + +# Multiple presets +presets_data = { + "preset1": {"pattern": "on", "colors": ["#FF0000"]}, + "preset2": {"pattern": "blink", "colors": ["#00FF00"]} +} +presets = build_presets_dict(presets_data) +``` + +## API Specification + +See `docs/API.md` for the complete ESPNow API specification. + +## Key Features + +- **Version Field**: All messages include `"v": "1"` for version tracking +- **Preset Format**: Presets use hex color strings (`#RRGGBB`), not RGB tuples +- **Select Format**: Select values are always lists: `["preset_name"]` or `["preset_name", step]` +- **Color Conversion**: Automatically converts RGB tuples to hex strings +- **Default Values**: Provides sensible defaults for missing fields diff --git a/src/util/espnow_message.py b/src/util/espnow_message.py new file mode 100644 index 0000000..fb8b858 --- /dev/null +++ b/src/util/espnow_message.py @@ -0,0 +1,185 @@ +""" +ESPNow message builder utility for LED driver communication. + +This module provides utilities to build ESPNow messages according to the API specification. +""" + +import json + + +def build_message(presets=None, select=None): + """ + Build an ESPNow message according to the API specification. + + Args: + presets: Dictionary mapping preset names to preset objects, or None + select: Dictionary mapping device names to select lists, or None + + Returns: + JSON string ready to send via ESPNow + + Example: + message = build_message( + presets={ + "red_blink": { + "pattern": "blink", + "colors": ["#FF0000"], + "delay": 200, + "brightness": 255, + "auto": True + } + }, + select={ + "device1": ["red_blink"] + } + ) + """ + message = { + "v": "1" + } + + if presets: + message["presets"] = presets + + if select: + message["select"] = select + + return json.dumps(message) + + +def build_select_message(device_name, preset_name, step=None): + """ + Build a select message for a single device. + + Args: + device_name: Name of the device + preset_name: Name of the preset to select + step: Optional step value for synchronization + + Returns: + Dictionary with select field ready to use in build_message + + Example: + select = build_select_message("device1", "rainbow_preset", step=10) + message = build_message(select=select) + """ + select_list = [preset_name] + if step is not None: + select_list.append(step) + + return {device_name: select_list} + + +def build_preset_dict(preset_data): + """ + Convert preset data to API-compliant format. + + Args: + preset_data: Dictionary with preset fields (may include name, pattern, colors, etc.) + + Returns: + Dictionary with preset in API-compliant format (without name field) + + Example: + preset = build_preset_dict({ + "name": "red_blink", + "pattern": "blink", + "colors": ["#FF0000"], + "delay": 200, + "brightness": 255, + "auto": True, + "n1": 0, + "n2": 0, + "n3": 0, + "n4": 0, + "n5": 0, + "n6": 0 + }) + """ + # Ensure colors are in hex format + colors = preset_data.get("colors", ["#FFFFFF"]) + if colors: + # Convert RGB tuples to hex strings if needed + if isinstance(colors[0], list) and len(colors[0]) == 3: + # RGB tuple format [r, g, b] + colors = [f"#{r:02x}{g:02x}{b:02x}" for r, g, b in colors] + elif not isinstance(colors[0], str): + # Handle other formats - convert to hex + colors = ["#FFFFFF"] + # Ensure all colors start with # + colors = [c if c.startswith("#") else f"#{c}" for c in colors] + else: + colors = ["#FFFFFF"] + + preset = { + "pattern": preset_data.get("pattern", "off"), + "colors": colors, + "delay": preset_data.get("delay", 100), + "brightness": preset_data.get("brightness", preset_data.get("br", 127)), + "auto": preset_data.get("auto", True), + "n1": preset_data.get("n1", 0), + "n2": preset_data.get("n2", 0), + "n3": preset_data.get("n3", 0), + "n4": preset_data.get("n4", 0), + "n5": preset_data.get("n5", 0), + "n6": preset_data.get("n6", 0) + } + + return preset + + +def build_presets_dict(presets_data): + """ + Convert multiple presets to API-compliant format. + + Args: + presets_data: Dictionary mapping preset names to preset data + + Returns: + Dictionary mapping preset names to API-compliant preset objects + + Example: + presets = build_presets_dict({ + "red_blink": { + "pattern": "blink", + "colors": ["#FF0000"], + "delay": 200 + }, + "blue_pulse": { + "pattern": "pulse", + "colors": ["#0000FF"], + "delay": 100 + } + }) + """ + result = {} + for preset_name, preset_data in presets_data.items(): + result[preset_name] = build_preset_dict(preset_data) + return result + + +def build_select_dict(device_preset_mapping, step_mapping=None): + """ + Build a select dictionary mapping device names to select lists. + + Args: + device_preset_mapping: Dictionary mapping device names to preset names + step_mapping: Optional dictionary mapping device names to step values + + Returns: + Dictionary with select field ready to use in build_message + + Example: + select = build_select_dict( + {"device1": "rainbow_preset", "device2": "pulse_preset"}, + step_mapping={"device1": 10} + ) + message = build_message(select=select) + """ + select = {} + for device_name, preset_name in device_preset_mapping.items(): + select_list = [preset_name] + if step_mapping and device_name in step_mapping: + select_list.append(step_mapping[device_name]) + select[device_name] = select_list + return select