Add additional configuration and utility files

- Add install script and message configuration
- Add settings controller and templates
- Add ESP-NOW message utility
- Update API documentation
This commit is contained in:
2026-01-27 13:05:09 +13:00
parent e74ef6d64f
commit 7e33f7db6a
9 changed files with 1064 additions and 450 deletions

View File

@@ -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

4
install.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Install script - runs pipenv install
pipenv install "$@"

23
msg.json Normal file
View File

@@ -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
}

132
src/controllers/settings.py Normal file
View File

@@ -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')

0
src/profile.py Normal file
View File

1
src/settings.json Normal file
View File

@@ -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"]}]}

430
src/templates/settings.html Normal file
View File

@@ -0,0 +1,430 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Controller - Settings</title>
<link rel="stylesheet" href="/static/style.css">
<style>
.settings-container {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
overflow-y: auto;
height: 100%;
}
.settings-header {
margin-bottom: 2rem;
}
.settings-header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.settings-header p {
color: #aaa;
}
.settings-section {
background-color: #1a1a1a;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid #4a4a4a;
}
.settings-section h2 {
font-size: 1.3rem;
margin-bottom: 1rem;
color: #fff;
border-bottom: 2px solid #4a4a4a;
padding-bottom: 0.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="password"],
.form-group input[type="number"],
.form-group select {
width: 100%;
padding: 0.75rem;
background-color: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
font-size: 1rem;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #5a5a5a;
}
.form-group small {
display: block;
margin-top: 0.25rem;
color: #888;
font-size: 0.875rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.status-info {
background-color: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;
}
.status-info h3 {
font-size: 1rem;
margin-bottom: 0.5rem;
color: #fff;
}
.status-info p {
color: #aaa;
margin: 0.25rem 0;
font-size: 0.9rem;
}
.status-connected {
color: #4caf50;
}
.status-disconnected {
color: #f44336;
}
.btn-group {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.btn-full {
flex: 1;
}
.back-link {
display: inline-block;
margin-bottom: 1rem;
color: #aaa;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.back-link:hover {
background-color: #2e2e2e;
color: white;
}
.message {
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
display: none;
}
.message.success {
background-color: #1b5e20;
color: #4caf50;
border: 1px solid #4caf50;
}
.message.error {
background-color: #5e1b1b;
color: #f44336;
border: 1px solid #f44336;
}
.message.show {
display: block;
}
</style>
</head>
<body>
<div class="app-container">
<div class="settings-container">
<a href="/" class="back-link">← Back to Dashboard</a>
<div class="settings-header">
<h1>Device Settings</h1>
<p>Configure WiFi and device settings</p>
</div>
<div id="message" class="message"></div>
<!-- WiFi Station Settings -->
<div class="settings-section">
<h2>WiFi Station (Client) Settings</h2>
<div id="station-status" class="status-info">
<h3>Connection Status</h3>
<p>Loading...</p>
</div>
<form id="station-form">
<div class="form-group">
<label for="station-ssid">SSID (Network Name)</label>
<input type="text" id="station-ssid" name="ssid" placeholder="Enter WiFi network name" required>
<small>The name of the WiFi network to connect to</small>
</div>
<div class="form-group">
<label for="station-password">Password</label>
<input type="password" id="station-password" name="password" placeholder="Enter WiFi password">
<small>Leave empty for open networks</small>
</div>
<div class="form-row">
<div class="form-group">
<label for="station-ip">IP Address (Optional)</label>
<input type="text" id="station-ip" name="ip" placeholder="192.168.1.100">
<small>Static IP address (leave empty for DHCP)</small>
</div>
<div class="form-group">
<label for="station-gateway">Gateway (Optional)</label>
<input type="text" id="station-gateway" name="gateway" placeholder="192.168.1.1">
<small>Gateway/router IP address</small>
</div>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Connect</button>
</div>
</form>
</div>
<!-- WiFi Access Point Settings -->
<div class="settings-section">
<h2>WiFi Access Point Settings</h2>
<div id="ap-status" class="status-info">
<h3>AP Status</h3>
<p>Loading...</p>
</div>
<form id="ap-form">
<div class="form-group">
<label for="ap-ssid">AP SSID (Network Name)</label>
<input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
<small>The name of the WiFi access point this device creates</small>
</div>
<div class="form-group">
<label for="ap-password">AP Password</label>
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)">
<small>Leave empty for open network (min 8 characters if set)</small>
</div>
<div class="form-group">
<label for="ap-channel">Channel (1-11)</label>
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Show message helper
function showMessage(text, type = 'success') {
const messageEl = document.getElementById('message');
messageEl.textContent = text;
messageEl.className = `message ${type} show`;
setTimeout(() => {
messageEl.classList.remove('show');
}, 5000);
}
// Load station status
async function loadStationStatus() {
try {
const response = await fetch('/settings/wifi/station');
const status = await response.json();
const statusEl = document.getElementById('station-status');
if (status.connected) {
statusEl.innerHTML = `
<h3>Connection Status: <span class="status-connected">Connected</span></h3>
<p><strong>SSID:</strong> ${status.ssid || 'N/A'}</p>
<p><strong>IP Address:</strong> ${status.ip || 'N/A'}</p>
<p><strong>Gateway:</strong> ${status.gateway || 'N/A'}</p>
<p><strong>Netmask:</strong> ${status.netmask || 'N/A'}</p>
<p><strong>DNS:</strong> ${status.dns || 'N/A'}</p>
`;
} else {
statusEl.innerHTML = `
<h3>Connection Status: <span class="status-disconnected">Disconnected</span></h3>
<p>Not connected to any WiFi network</p>
`;
}
} catch (error) {
console.error('Error loading station status:', error);
}
}
// Load saved station credentials
async function loadStationCredentials() {
try {
const response = await fetch('/settings/wifi/station/credentials');
const creds = await response.json();
if (creds.ssid) document.getElementById('station-ssid').value = creds.ssid;
if (creds.ip) document.getElementById('station-ip').value = creds.ip;
if (creds.gateway) document.getElementById('station-gateway').value = creds.gateway;
} catch (error) {
console.error('Error loading station credentials:', error);
}
}
// Load AP status and config
async function loadAPStatus() {
try {
const response = await fetch('/settings/wifi/ap');
const config = await response.json();
const statusEl = document.getElementById('ap-status');
if (config.active) {
statusEl.innerHTML = `
<h3>AP Status: <span class="status-connected">Active</span></h3>
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
`;
} else {
statusEl.innerHTML = `
<h3>AP Status: <span class="status-disconnected">Inactive</span></h3>
<p>Access Point is not currently active</p>
`;
}
// Load saved values
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
} catch (error) {
console.error('Error loading AP status:', error);
}
}
// Station form submission
document.getElementById('station-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
ssid: document.getElementById('station-ssid').value,
password: document.getElementById('station-password').value,
ip: document.getElementById('station-ip').value || null,
gateway: document.getElementById('station-gateway').value || null
};
try {
const response = await fetch('/settings/wifi/station', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
const result = await response.json();
if (response.ok) {
showMessage('WiFi station connected successfully!', 'success');
setTimeout(loadStationStatus, 1000);
} else {
showMessage(`Error: ${result.error || 'Failed to connect'}`, 'error');
}
} catch (error) {
showMessage(`Error: ${error.message}`, 'error');
}
});
// AP form submission
document.getElementById('ap-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
ssid: document.getElementById('ap-ssid').value,
password: document.getElementById('ap-password').value,
channel: document.getElementById('ap-channel').value || null
};
// Validate password length if provided
if (formData.password && formData.password.length > 0 && formData.password.length < 8) {
showMessage('AP password must be at least 8 characters', 'error');
return;
}
// Convert channel to number if provided
if (formData.channel) {
formData.channel = parseInt(formData.channel);
if (formData.channel < 1 || formData.channel > 11) {
showMessage('Channel must be between 1 and 11', 'error');
return;
}
}
try {
const response = await fetch('/settings/wifi/ap', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
const result = await response.json();
if (response.ok) {
showMessage('Access Point configured successfully!', 'success');
setTimeout(loadAPStatus, 1000);
} else {
showMessage(`Error: ${result.error || 'Failed to configure AP'}`, 'error');
}
} catch (error) {
showMessage(`Error: ${error.message}`, 'error');
}
});
// Load all data on page load
loadStationStatus();
loadStationCredentials();
loadAPStatus();
// Refresh status every 10 seconds
setInterval(() => {
loadStationStatus();
loadAPStatus();
}, 10000);
</script>
</body>
</html>

80
src/util/README.md Normal file
View File

@@ -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

185
src/util/espnow_message.py Normal file
View File

@@ -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