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:
659
docs/API.md
659
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)
|
This document describes the ESPNow message format for controlling LED driver devices.
|
||||||
**Protocol:** HTTP/1.1
|
|
||||||
**Content-Type:** `application/json`
|
|
||||||
|
|
||||||
## 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
|
```json
|
||||||
{
|
{
|
||||||
"preset1": {
|
"v": "1",
|
||||||
"name": "preset1",
|
"presets": { ... },
|
||||||
"pattern": "on",
|
"select": { ... }
|
||||||
"colors": [[255, 0, 0]],
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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,
|
"delay": 100,
|
||||||
|
"brightness": 127,
|
||||||
|
"auto": true,
|
||||||
"n1": 0,
|
"n1": 0,
|
||||||
"n2": 0,
|
"n2": 0,
|
||||||
"n3": 0,
|
"n3": 0,
|
||||||
"n4": 0,
|
"n4": 0,
|
||||||
"n5": 0,
|
"n5": 0,
|
||||||
"n6": 0,
|
"n6": 0
|
||||||
"n7": 0,
|
}
|
||||||
"n8": 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
|
```json
|
||||||
{
|
{
|
||||||
"name": "preset1",
|
"select": {
|
||||||
"pattern": "on",
|
"device_name": ["preset_name"],
|
||||||
"colors": [[255, 0, 0]],
|
"device_name2": ["preset_name2", step_value]
|
||||||
"delay": 100,
|
}
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:** `404 Not Found`
|
### Select Fields
|
||||||
|
|
||||||
|
- **`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)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
// Beat 1
|
||||||
|
{"select": {"device1": ["rainbow_preset"]}}
|
||||||
|
|
||||||
|
// Beat 2 (same preset = beat)
|
||||||
|
{"select": {"device1": ["rainbow_preset"]}}
|
||||||
|
|
||||||
|
// Beat 3
|
||||||
|
{"select": {"device1": ["rainbow_preset"]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Synchronization
|
||||||
|
|
||||||
|
### Using "off" Pattern
|
||||||
|
|
||||||
|
Selecting the "off" pattern resets the step counter to 0, providing a synchronization point:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"error": "Preset not found"
|
"select": {
|
||||||
|
"device1": ["off"],
|
||||||
|
"device2": ["off"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /presets
|
After all devices are "off", switching to a pattern ensures they all start from step 0:
|
||||||
|
|
||||||
Create a new preset.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "preset1",
|
"select": {
|
||||||
"pattern": "on",
|
"device1": ["rainbow_preset"],
|
||||||
"colors": [[255, 0, 0]],
|
"device2": ["rainbow_preset"]
|
||||||
"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
|
### Using Step Parameter
|
||||||
|
|
||||||
|
For precise synchronization, use the step parameter:
|
||||||
|
|
||||||
**Response:** `400 Bad Request`
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"error": "Name is required"
|
"select": {
|
||||||
|
"device1": ["rainbow_preset", 10],
|
||||||
|
"device2": ["rainbow_preset", 10],
|
||||||
|
"device3": ["rainbow_preset", 10]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:** `409 Conflict`
|
All devices will start at step 10 and advance together on subsequent beats.
|
||||||
```json
|
|
||||||
{
|
## Complete Example
|
||||||
"error": "Preset already exists"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### PUT /presets/{name}
|
|
||||||
|
|
||||||
Update an existing preset.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"v": "1",
|
||||||
|
"presets": {
|
||||||
|
"red_blink": {
|
||||||
|
"pattern": "blink",
|
||||||
|
"colors": ["#FF0000"],
|
||||||
"delay": 200,
|
"delay": 200,
|
||||||
"colors": [[0, 255, 0]]
|
"brightness": 255,
|
||||||
}
|
"auto": true
|
||||||
```
|
},
|
||||||
|
"rainbow_manual": {
|
||||||
**Response:** `200 OK` - Returns the updated preset
|
"pattern": "rainbow",
|
||||||
|
"delay": 100,
|
||||||
**Response:** `404 Not Found`
|
"n1": 2,
|
||||||
```json
|
"auto": false
|
||||||
{
|
},
|
||||||
"error": "Preset not found"
|
"pulse_slow": {
|
||||||
}
|
"pattern": "pulse",
|
||||||
```
|
"colors": ["#00FF00"],
|
||||||
|
"delay": 500,
|
||||||
### DELETE /presets/{name}
|
"n1": 1000,
|
||||||
|
"n2": 500,
|
||||||
Delete a preset.
|
"n3": 1000,
|
||||||
|
"auto": false
|
||||||
**Response:** `200 OK`
|
}
|
||||||
```json
|
},
|
||||||
{
|
"select": {
|
||||||
"message": "Preset deleted successfully"
|
"device1": ["red_blink"],
|
||||||
}
|
"device2": ["rainbow_manual", 0],
|
||||||
```
|
"device3": ["pulse_slow"]
|
||||||
|
|
||||||
**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": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET /profiles/{name}
|
## Message Processing
|
||||||
|
|
||||||
Get a specific profile by name.
|
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:** `200 OK`
|
## Best Practices
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "profile1",
|
|
||||||
"description": "Profile description",
|
|
||||||
"scenes": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `404 Not Found`
|
1. **Always include version**: Set `"v": "1"` in all messages
|
||||||
```json
|
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
|
||||||
"error": "Profile not found"
|
4. **Step for precision**: Use step parameter when exact synchronization is required
|
||||||
}
|
5. **Color format**: Always use hex strings (`"#RRGGBB"`), conversion is automatic
|
||||||
```
|
|
||||||
|
|
||||||
### POST /profiles
|
## Error Handling
|
||||||
|
|
||||||
Create a new profile.
|
- 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)
|
||||||
|
|
||||||
**Request Body:**
|
## Notes
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "profile1",
|
|
||||||
"description": "Profile description",
|
|
||||||
"scenes": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `201 Created` - Returns the created profile
|
- Colors are automatically converted from hex strings to RGB tuples
|
||||||
|
- Color order reordering happens automatically based on device settings
|
||||||
**Response:** `400 Bad Request`
|
- Step counter wraps around (0-255 for rainbow, unbounded for others)
|
||||||
```json
|
- Manual mode patterns stop after one step/cycle, waiting for next beat
|
||||||
{
|
- Auto mode patterns run continuously until changed
|
||||||
"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"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET /scenes/{profile_name}/{scene_name}
|
|
||||||
|
|
||||||
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"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `404 Not Found`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Scene not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST /scenes
|
|
||||||
|
|
||||||
Create a new scene.
|
|
||||||
|
|
||||||
**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"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `201 Created` - Returns the created scene
|
|
||||||
|
|
||||||
**Response:** `400 Bad Request`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Name is required"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
or
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Profile name is required"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `409 Conflict`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Scene already exists"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### PUT /scenes/{profile_name}/{scene_name}
|
|
||||||
|
|
||||||
Update an existing scene.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"transition_time": 500,
|
|
||||||
"description": "Updated description"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `200 OK` - Returns the updated scene
|
|
||||||
|
|
||||||
**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"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
4
install.sh
Executable file
4
install.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install script - runs pipenv install
|
||||||
|
|
||||||
|
pipenv install "$@"
|
||||||
23
msg.json
Normal file
23
msg.json
Normal 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
132
src/controllers/settings.py
Normal 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
0
src/profile.py
Normal file
1
src/settings.json
Normal file
1
src/settings.json
Normal 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
430
src/templates/settings.html
Normal 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
80
src/util/README.md
Normal 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
185
src/util/espnow_message.py
Normal 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
|
||||||
Reference in New Issue
Block a user