15 Commits

Author SHA1 Message Date
9cf1855b51 UI: Add rate limiting to brightness control
- Add 100ms minimum interval between brightness updates to backend
- Keep UI responsive with immediate local brightness updates
- Prevent backend overload from rapid MIDI CC33 changes
- Add time import for rate limiting functionality
- Add debug output to show when updates are sent vs rate limited
- Improve smoothness and reduce network traffic
- Protect lighting controller from too many rapid parameter changes
2025-10-04 10:02:47 +13:00
763a2053ad UI: Remove knobs section and make window responsive
- Remove knobs section (CC38-45) to simplify interface
- Make window fit screen with cross-platform maximization
- Use weight-based grid resizing instead of fixed minimum sizes
- Add responsive layout with expandable frames
- Fix Linux compatibility for window maximization
- Disable window geometry loading to maintain maximized state
- Elements now resize proportionally to fit any screen size
- Cleaner, more focused interface without redundant controls
2025-10-04 09:53:48 +13:00
324fa463be UI: Make all elements 50% bigger for better touch interface
- Increase window size from 1800x1200 to 2700x1800
- Scale all font sizes by 50% (14pt → 21pt, 12pt → 18pt, etc.)
- Make all buttons and controls 50% larger
- Increase grid minimum sizes from 140x70 to 210x105
- Scale button dimensions from 14x4 to 21x6 characters
- Increase padding and spacing by 50% (6px → 9px)
- Make border widths thicker (2px → 3px)
- Improve touch-friendliness and readability
- Maintain proportional scaling across all UI elements
2025-10-04 09:29:59 +13:00
aaf515d8f4 UI: Fix MIDI dropdown contrast and device detection
- Improve MIDI dropdown contrast with better colors (#FFFFFF text on #2C2C2C background)
- Add bold font and larger size for better visibility
- Fix MIDI device detection to show all available devices
- Add port validation to only show accessible MIDI devices
- Use direct widget reference instead of searching for dropdown
- Add delayed initialization to ensure UI is ready before populating dropdown
- Improve debugging output for MIDI port detection
- Add placeholder text 'No MIDI device selected' when no devices available
- Restore window geometry persistence for proper window positioning
2025-10-04 09:20:14 +13:00
7beca0cf53 UI: improve MIDI Combobox contrast in dark theme (field/list colors, selection highlight) 2025-10-04 02:16:35 +13:00
ace47b7835 UI: default CONTROL_SERVER_URI -> ws://10.42.0.1:8765 2025-10-04 02:13:21 +13:00
9045b10631 UI: MIDI 44–47 -> Color 2, 48–51 -> Color 1; label 'alternating' as 'alternating pulse'; MIDI note 39 sends 'ap'; fix async UI scheduler usage (no create_task) 2025-10-04 02:09:55 +13:00
f2e775f6f5 UI: replace 'on' with pattern 'alternating_phase' (MIDI note 36, grid label/icons); remove WebSocket usage; per-pattern parameters with state hydration; REST-only palette/state/parameters 2025-10-04 01:01:32 +13:00
a654527dc3 UI: Color palette REST integration, MIDI 44–51 color slot selection, Color 1/2 previews with next indicator and click-to-select target; use REST for pattern changes and parameter updates (brightness, delay, n1–n3); send colors only on confirm; load palette on startup; fix NoneType await issue in async handlers 2025-10-03 23:40:20 +13:00
Pi User
0906cb22e6 Add .env file support for UI client configuration
- Use python-dotenv to load environment variables
- Add CONTROL_SERVER_URI environment variable for WebSocket connection
- Create .env.example with configuration examples
- Update Pipfile to include python-dotenv dependency
- Allows easy configuration for running UI on desktop pointing to Pi
2025-10-03 20:08:36 +13:00
Pi User
e4a83e8f0d Add segmented_movement pattern with n4 parameter support
- Add n4 parameter to control server, LED bar receiver, and test script
- Create segmented_movement pattern with alternating forward/backward movement
- Pattern supports n1 (segment length), n2 (spacing), n3 (forward speed), n4 (backward speed)
- Fix test script to send all messages instead of just the first one
- Add segmented_movement to patterns_needing_params for proper parameter transmission
- Pattern intelligently handles all cases: alternating, forward-only, backward-only, or static
- Implements repeating segments with configurable spacing across LED strip
2025-10-03 19:54:43 +13:00
Pi User
f4e9f8fff7 fix: prioritize delay_change in control server test 2025-10-02 20:49:20 +13:00
Pi User
d57fce77fb update: misc changes 2025-10-01 23:31:00 +13:00
Pi User
fbeb365932 pipenv: add sound-run; sound.py: --input-device flag 2025-10-01 23:31:00 +13:00
Pi User
ed35d6b838 pipenv: add send-net; networking: use SPI; add networking test 2025-10-01 23:31:00 +13:00
13 changed files with 2867 additions and 306 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Lighting Controller UI Client Configuration
# WebSocket URI for the control server
# For local development (running UI on same machine as control server):
CONTROL_SERVER_URI=ws://localhost:8765
# For remote connection (running UI on desktop, control server on Pi):
# Replace with your Raspberry Pi's IP address
# CONTROL_SERVER_URI=ws://10.1.1.117:8765

13
Pipfile
View File

@@ -5,14 +5,13 @@ name = "pypi"
[packages] [packages]
websockets = "*" websockets = "*"
spidev = "*"
watchfiles = "*" watchfiles = "*"
async-tkinter-loop = "*" async-tkinter-loop = "*"
mido = "*" mido = "*"
python-rtmidi = "*" python-rtmidi = "*"
pyaudio = "*"
aubio = "*"
websocket-client = "*" websocket-client = "*"
python-dotenv = "*"
[dev-packages] [dev-packages]
@@ -23,9 +22,13 @@ python_version = "3.11"
run = "python src/main.py" run = "python src/main.py"
ui = "python src/ui_client.py" ui = "python src/ui_client.py"
control = "python src/control_server.py" control = "python src/control_server.py"
control-spi = "python src/control_server.py --transport spi"
control-ws = "python src/control_server.py --transport websocket"
sound = "python src/sound.py" sound = "python src/sound.py"
dev-ui = 'watchfiles "python src/ui_client.py" src' dev-ui = 'watchfiles "python src/ui_client.py" src'
dev-control = 'watchfiles "python src/control_server.py" src' dev-control = 'watchfiles --args "--transport spi" "python src/control_server.py" src'
dev-control-spi = 'watchfiles --args "--transport spi" "python src/control_server.py" src'
dev-control-ws = 'watchfiles --args "--transport websocket" "python src/control_server.py" src'
install = "pipenv install" install = "pipenv install"
install-system = "bash -c 'sudo apt-get update && sudo apt-get install -y python3-spidev python3-pip python3-dev portaudio19-dev libasound2-dev'" install-system = "bash -c 'sudo apt-get update && sudo apt-get install -y python3-spidev python3-pip python3-dev portaudio19-dev libasound2-dev'"
monitor-esp32 = "bash -c 'source $HOME/esp/esp-idf/export.sh && cd esp32 && idf.py -p ${ESPPORT:-/dev/ttyACM0} monitor'" monitor-esp32 = "bash -c 'source $HOME/esp/esp-idf/export.sh && cd esp32 && idf.py -p ${ESPPORT:-/dev/ttyACM0} monitor'"
@@ -33,3 +36,5 @@ build-esp32 = "bash -c 'source $HOME/esp/esp-idf/export.sh && cd esp32 && idf.py
flash-esp32 = "bash -c 'source $HOME/esp/esp-idf/export.sh && cd esp32 && idf.py -p $ESPPORT -b ${ESPSPEED:-460800} flash'" flash-esp32 = "bash -c 'source $HOME/esp/esp-idf/export.sh && cd esp32 && idf.py -p $ESPPORT -b ${ESPSPEED:-460800} flash'"
watch-esp32 = "watchfiles 'bash -c \"source $HOME/esp/esp-idf/export.sh && cd esp32 && idf.py -p ${ESPPORT:-/dev/ttyACM0} -b ${ESPSPEED:-460800} flash monitor\"' esp32/main" watch-esp32 = "watchfiles 'bash -c \"source $HOME/esp/esp-idf/export.sh && cd esp32 && idf.py -p ${ESPPORT:-/dev/ttyACM0} -b ${ESPSPEED:-460800} flash monitor\"' esp32/main"
send-json = "python test/send_json.py" send-json = "python test/send_json.py"
send-net = "python test/test_networking.py"
sound-run = "python src/sound.py"

751
api.md Normal file
View File

@@ -0,0 +1,751 @@
# Lighting Controller REST API - Frontend Documentation
## Overview
Complete REST API for controlling the LED lighting system. **No WebSocket required** - all operations use simple HTTP requests.
**Base URL:** `http://10.42.0.1:8765`
**Local Testing:** `http://localhost:8765`
---
## Table of Contents
1. [Quick Start](#quick-start)
2. [Pattern Control](#pattern-control)
3. [Color Palette](#color-palette)
4. [Parameters](#parameters)
5. [System State](#system-state)
6. [Tempo Control](#tempo-control)
7. [Complete Examples](#complete-examples)
---
## Quick Start
### Load Initial State
```javascript
// Get everything in one call
const response = await fetch('http://10.42.0.1:8765/api/state');
const state = await response.json();
console.log(state.pattern); // Current pattern
console.log(state.parameters); // All parameters
console.log(state.color_palette); // 8 colors + 2 selected
console.log(state.beat_index); // Current beat number
```
### Change Pattern
```javascript
await fetch('http://10.42.0.1:8765/api/pattern', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pattern: 'alternating'})
});
```
### Change Color
```javascript
await fetch('http://10.42.0.1:8765/api/color-palette', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({selected_indices: [2, 5]}) // Blue and Cyan
});
```
---
## Pattern Control
### GET /api/pattern
Get the currently active pattern.
**Request:**
```http
GET /api/pattern HTTP/1.1
```
**Response:**
```json
{
"pattern": "alternating"
}
```
---
### POST /api/pattern
Change the active pattern.
**Request:**
```http
POST /api/pattern HTTP/1.1
Content-Type: application/json
```
**Available Patterns:**
- `"on"` / `"o"` - Solid color
- `"off"` / `"f"` - All LEDs off
- `"flicker"` / `"f"` - Flickering effect
- `"fill_range"` / `"fr"` - Fill effect
- `"n_chase"` / `"nc"` - Chase pattern
- `"alternating"` / `"a"` - Alternating on/off
- `"pulse"` / `"p"` - Pulsing effect
- `"rainbow"` / `"r"` - Rainbow cycle
- `"specto"` / `"s"` - Spectograph effect
- `"radiate"` / `"rd"` - Radiate from center
- `"segmented_movement"` / `"sm"` - Moving segments
**Response:**
```json
{
"status": "ok",
"pattern": "alternating"
}
```
**JavaScript Example:**
```javascript
async function setPattern(patternName) {
const response = await fetch('http://10.42.0.1:8765/api/pattern', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pattern: patternName})
});
return await response.json();
}
// Usage
await setPattern('alternating');
await setPattern('rainbow');
```
---
## Color Palette
### GET /api/color-palette
Get the 8-color palette and selected colors.
**Response:**
```json
{
"palette": [
{"r": 255, "g": 0, "b": 0}, // Slot 0: Red
{"r": 0, "g": 255, "b": 0}, // Slot 1: Green
{"r": 0, "g": 0, "b": 255}, // Slot 2: Blue
{"r": 255, "g": 255, "b": 0}, // Slot 3: Yellow
{"r": 255, "g": 0, "b": 255}, // Slot 4: Magenta
{"r": 0, "g": 255, "b": 255}, // Slot 5: Cyan
{"r": 255, "g": 128, "b": 0}, // Slot 6: Orange
{"r": 255, "g": 255, "b": 255} // Slot 7: White
],
"selected_indices": [0, 1] // [0] = pattern color, [1] = reserved
}
```
**Important:** The **first selected color** (index 0) is used for all patterns!
---
### POST /api/color-palette
Update palette colors and/or selected colors.
**Request (Change Selected Colors):**
```json
{
"selected_indices": [2, 5] // Use slot 2 (Blue) for patterns
}
```
**Request (Update a Color):**
```json
{
"palette": [
{"r": 255, "g": 0, "b": 0},
{"r": 0, "g": 255, "b": 0},
{"r": 128, "g": 0, "b": 128}, // Changed to purple
// ... all 8 colors (must send complete array)
]
}
```
**Response:**
```json
{
"status": "ok",
"palette": {
"palette": [...],
"selected_indices": [2, 5]
}
}
```
**JavaScript Example:**
```javascript
// Change pattern color to slot 5 (Cyan)
async function selectColor(slotIndex) {
const response = await fetch('http://10.42.0.1:8765/api/color-palette', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({selected_indices: [slotIndex, 1]})
});
return await response.json();
}
// Edit a color in the palette
async function updatePaletteColor(slotIndex, r, g, b) {
// First get current palette
const current = await fetch('http://10.42.0.1:8765/api/color-palette')
.then(res => res.json());
// Update the specific slot
const newPalette = [...current.palette];
newPalette[slotIndex] = {r, g, b};
// Send updated palette
await fetch('http://10.42.0.1:8765/api/color-palette', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({palette: newPalette})
});
}
// Usage
await selectColor(2); // Use blue for patterns
await updatePaletteColor(3, 128, 0, 128); // Change slot 3 to purple
```
---
## Parameters
### GET /api/parameters
Get all current parameter values.
**Response:**
```json
{
"brightness": 100, // 0-100
"delay": 50, // milliseconds
"n1": 10, // Pattern parameter 1
"n2": 5, // Pattern parameter 2
"n3": 2, // Pattern parameter 3 (forward movement)
"n4": 1 // Pattern parameter 4 (backward movement)
}
```
---
### POST /api/parameters
Update one or more parameters. Only send the parameters you want to change.
**Request:**
```json
{
"brightness": 75,
"n1": 15
}
```
**Response:**
```json
{
"status": "ok",
"parameters": {
"brightness": 75,
"delay": 50,
"n1": 15,
"n2": 5,
"n3": 2,
"n4": 1
}
}
```
**Parameter Descriptions:**
| Parameter | Range | Description |
|-----------|-------|-------------|
| `brightness` | 0-100 | LED brightness percentage |
| `delay` | 1-1000 | Pattern speed (milliseconds) |
| `n1` | 0-255 | Pattern-specific (e.g., segment length) |
| `n2` | 0-255 | Pattern-specific (e.g., spacing) |
| `n3` | 0-255 | Pattern-specific (e.g., forward steps) |
| `n4` | 0-255 | Pattern-specific (e.g., backward steps) |
**JavaScript Example:**
```javascript
async function setBrightness(value) {
const response = await fetch('http://10.42.0.1:8765/api/parameters', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({brightness: value})
});
return await response.json();
}
async function setSpeed(delayMs) {
await fetch('http://10.42.0.1:8765/api/parameters', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({delay: delayMs})
});
}
// Usage
await setBrightness(75); // Set to 75%
await setSpeed(100); // Slow down pattern
```
---
## System State
### GET /api/state
Get complete system state in a single call. Perfect for initial UI load.
**Response:**
```json
{
"pattern": "alternating",
"parameters": {
"brightness": 100,
"delay": 50,
"n1": 10,
"n2": 5,
"n3": 2,
"n4": 1
},
"color_palette": {
"palette": [...8 colors...],
"selected_indices": [0, 1]
},
"beat_index": 42
}
```
**JavaScript Example:**
```javascript
// Load all state when UI starts
async function loadInitialState() {
const response = await fetch('http://10.42.0.1:8765/api/state');
const state = await response.json();
// Update UI with current state
updatePatternButtons(state.pattern);
updateColorPalette(state.color_palette);
updateSliders(state.parameters);
return state;
}
```
---
## Tempo Control
### POST /api/tempo/reset
Reset the tempo/beat detection in the sound system.
**Request:**
```http
POST /api/tempo/reset HTTP/1.1
```
**Response:**
```json
{
"status": "ok",
"message": "Tempo reset sent"
}
```
**JavaScript Example:**
```javascript
async function resetTempo() {
const response = await fetch('http://10.42.0.1:8765/api/tempo/reset', {
method: 'POST'
});
return await response.json();
}
// Usage: Call this when tempo detection seems off
await resetTempo();
```
---
## Complete Examples
### Example 1: Full UI Controller Class
```javascript
class LightingController {
constructor(baseUrl = 'http://10.42.0.1:8765') {
this.baseUrl = baseUrl;
}
// Load complete state
async loadState() {
const response = await fetch(`${this.baseUrl}/api/state`);
return await response.json();
}
// Pattern control
async setPattern(pattern) {
const response = await fetch(`${this.baseUrl}/api/pattern`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pattern})
});
return await response.json();
}
// Color selection
async selectColor(slotIndex) {
const response = await fetch(`${this.baseUrl}/api/color-palette`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({selected_indices: [slotIndex, 1]})
});
return await response.json();
}
// Brightness control
async setBrightness(value) {
const response = await fetch(`${this.baseUrl}/api/parameters`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({brightness: value})
});
return await response.json();
}
// Pattern parameters
async setParameters(params) {
const response = await fetch(`${this.baseUrl}/api/parameters`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(params)
});
return await response.json();
}
}
// Usage
const lights = new LightingController();
// On page load
const state = await lights.loadState();
// User interactions
await lights.setPattern('rainbow');
await lights.selectColor(2); // Blue
await lights.setBrightness(75);
```
---
### Example 2: React Component
```jsx
import { useState, useEffect } from 'react';
function LightingControl() {
const [state, setState] = useState(null);
const BASE_URL = 'http://10.42.0.1:8765';
// Load initial state
useEffect(() => {
fetch(`${BASE_URL}/api/state`)
.then(res => res.json())
.then(setState);
}, []);
// Change pattern
const handlePatternChange = async (pattern) => {
await fetch(`${BASE_URL}/api/pattern`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pattern})
});
// Reload state
const newState = await fetch(`${BASE_URL}/api/state`).then(r => r.json());
setState(newState);
};
// Change color
const handleColorSelect = async (slotIndex) => {
await fetch(`${BASE_URL}/api/color-palette`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({selected_indices: [slotIndex, 1]})
});
const newState = await fetch(`${BASE_URL}/api/state`).then(r => r.json());
setState(newState);
};
// Change brightness
const handleBrightnessChange = async (value) => {
await fetch(`${BASE_URL}/api/parameters`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({brightness: value})
});
};
if (!state) return <div>Loading...</div>;
return (
<div>
<h2>Pattern: {state.pattern}</h2>
<div>
<button onClick={() => handlePatternChange('alternating')}>Alternating</button>
<button onClick={() => handlePatternChange('rainbow')}>Rainbow</button>
<button onClick={() => handlePatternChange('pulse')}>Pulse</button>
</div>
<div>
<h3>Colors</h3>
{state.color_palette.palette.map((color, i) => (
<div
key={i}
onClick={() => handleColorSelect(i)}
style={{
background: `rgb(${color.r}, ${color.g}, ${color.b})`,
border: state.color_palette.selected_indices[0] === i ? '3px solid gold' : '1px solid black',
width: 50,
height: 50,
display: 'inline-block',
cursor: 'pointer'
}}
/>
))}
</div>
<div>
<label>Brightness: {state.parameters.brightness}</label>
<input
type="range"
min="0"
max="100"
value={state.parameters.brightness}
onChange={(e) => handleBrightnessChange(e.target.value)}
/>
</div>
</div>
);
}
```
---
### Example 3: Simple HTML + Vanilla JS
```html
<!DOCTYPE html>
<html>
<head>
<title>Lighting Controller</title>
</head>
<body>
<h1>LED Lighting Control</h1>
<div id="patterns"></div>
<div id="colors"></div>
<label>Brightness: <span id="brightness-value">100</span></label>
<input type="range" id="brightness" min="0" max="100" value="100">
<script>
const BASE_URL = 'http://10.42.0.1:8765';
// Load initial state
async function init() {
const response = await fetch(`${BASE_URL}/api/state`);
const state = await response.json();
// Create pattern buttons
const patterns = ['on', 'alternating', 'rainbow', 'pulse', 'segmented_movement'];
const patternsDiv = document.getElementById('patterns');
patterns.forEach(pattern => {
const btn = document.createElement('button');
btn.textContent = pattern;
btn.onclick = () => setPattern(pattern);
patternsDiv.appendChild(btn);
});
// Create color palette
const colorsDiv = document.getElementById('colors');
state.color_palette.palette.forEach((color, i) => {
const div = document.createElement('div');
div.style.cssText = `
background: rgb(${color.r}, ${color.g}, ${color.b});
width: 50px;
height: 50px;
display: inline-block;
cursor: pointer;
border: ${state.color_palette.selected_indices[0] === i ? '3px solid gold' : '1px solid black'};
`;
div.onclick = () => selectColor(i);
colorsDiv.appendChild(div);
});
// Brightness slider
document.getElementById('brightness').oninput = (e) => {
document.getElementById('brightness-value').textContent = e.target.value;
setBrightness(e.target.value);
};
}
async function setPattern(pattern) {
await fetch(`${BASE_URL}/api/pattern`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pattern})
});
}
async function selectColor(index) {
await fetch(`${BASE_URL}/api/color-palette`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({selected_indices: [index, 1]})
});
location.reload(); // Refresh to show updated selection
}
async function setBrightness(value) {
await fetch(`${BASE_URL}/api/parameters`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({brightness: parseInt(value)})
});
}
init();
</script>
</body>
</html>
```
---
## API Endpoint Summary
| Method | Endpoint | Purpose |
|--------|----------|---------|
| `GET` | `/api/state` | Get complete system state |
| `GET` | `/api/pattern` | Get current pattern |
| `POST` | `/api/pattern` | Change pattern |
| `GET` | `/api/color-palette` | Get color palette |
| `POST` | `/api/color-palette` | Update palette/selection |
| `GET` | `/api/parameters` | Get all parameters |
| `POST` | `/api/parameters` | Update parameters |
| `POST` | `/api/tempo/reset` | Reset tempo detection |
---
## Error Handling
All endpoints return standard HTTP status codes:
- `200 OK` - Success
- `400 Bad Request` - Invalid request data
- `500 Internal Server Error` - Server error
**Error Response Format:**
```json
{
"status": "error",
"message": "Pattern name required"
}
```
**JavaScript Error Handling Example:**
```javascript
async function safeApiCall(url, options) {
try {
const response = await fetch(url, options);
const data = await response.json();
if (data.status === 'error') {
console.error('API Error:', data.message);
return null;
}
return data;
} catch (error) {
console.error('Network Error:', error);
return null;
}
}
// Usage
const result = await safeApiCall('http://10.42.0.1:8765/api/pattern', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pattern: 'rainbow'})
});
```
---
## Testing
### Test All Endpoints
```bash
# Get state
curl http://10.42.0.1:8765/api/state | jq
# Change pattern
curl -X POST http://10.42.0.1:8765/api/pattern \
-H "Content-Type: application/json" \
-d '{"pattern": "rainbow"}'
# Change color
curl -X POST http://10.42.0.1:8765/api/color-palette \
-H "Content-Type: application/json" \
-d '{"selected_indices": [2, 5]}'
# Update brightness
curl -X POST http://10.42.0.1:8765/api/parameters \
-H "Content-Type: application/json" \
-d '{"brightness": 75}'
# Reset tempo
curl -X POST http://10.42.0.1:8765/api/tempo/reset
```
---
## Notes
- **No WebSocket needed** - Everything uses simple HTTP REST API
- **CORS**: Not currently enabled. Host UI on same domain or add CORS middleware
- **Persistence**: Color palette persists to `lighting_config.json`
- **Real-time**: Changes take effect immediately (within one beat cycle)
- **Pattern Color**: First selected color (index 0) is used for all patterns
---
## Support Files
- Full API details: `COLOR_PALETTE_API.md`
- Migration notes: `AIOHTTP_MIGRATION.md`
- Test results: `PATTERN_COLOR_TEST_RESULTS.md`
- Migration notes: `AIOHTTP_MIGRATION.md`
- Test results: `PATTERN_COLOR_TEST_RESULTS.md`

405
colorpallet.md Normal file
View File

@@ -0,0 +1,405 @@
# Color Palette REST API - UI Integration Guide
## Overview
The lighting control server provides a REST API for managing an 8-color palette with 2 selected colors. This is designed for UI integration to allow users to:
- View the current 8 colors in the palette
- Edit any of the 8 colors
- Select which 2 colors are active (for patterns that use selected colors)
Configuration is automatically persisted to `lighting_config.json`.
## Base URLs
The API is available on **two ports** for flexibility:
**Primary (same as WebSocket):**
```
http://<server-ip>:8765/api/color-palette
```
**Backward Compatibility:**
```
http://<server-ip>:8766/api/color-palette
```
**Default IPs:**
- Remote: `http://10.42.0.1:8765` (Pi server)
- Local: `http://localhost:8765` (testing)
---
## Quick Start for UI Developers
### Recommended Usage Pattern
1. **On UI Load:** `GET /api/color-palette` to populate the color picker
2. **When User Edits a Color:** `POST /api/color-palette` with updated palette
3. **When User Selects Colors:** `POST /api/color-palette` with new selected_indices
---
## API Reference
### GET /api/color-palette
**Purpose:** Get the current palette state (for populating UI on load)
#### Request
```http
GET /api/color-palette HTTP/1.1
```
#### Response (200 OK)
```json
{
"palette": [
{"r": 255, "g": 0, "b": 0}, // Slot 0: Red
{"r": 0, "g": 255, "b": 0}, // Slot 1: Green
{"r": 0, "g": 0, "b": 255}, // Slot 2: Blue
{"r": 255, "g": 255, "b": 0}, // Slot 3: Yellow
{"r": 255, "g": 0, "b": 255}, // Slot 4: Magenta
{"r": 0, "g": 255, "b": 255}, // Slot 5: Cyan
{"r": 255, "g": 128, "b": 0}, // Slot 6: Orange
{"r": 255, "g": 255, "b": 255} // Slot 7: White
],
"selected_indices": [0, 1] // Currently selected: Red and Green
}
```
#### Response Fields
- **`palette`**: Array of exactly 8 color objects
- Each color has `r`, `g`, `b` (integers 0-255)
- Index corresponds to palette slot (0-7)
- **`selected_indices`**: Array of exactly 2 integers (0-7)
- Indicates which 2 palette slots are currently active
- Patterns may use these selected colors
---
### POST /api/color-palette (or PUT)
**Purpose:** Update palette colors and/or selected colors
#### Request Body (JSON)
Both fields are **optional** - send only what you want to update:
```json
{
"palette": [...], // Optional: Update all 8 colors
"selected_indices": [0, 3] // Optional: Change selected colors
}
```
#### Success Response (200 OK)
```json
{
"status": "ok",
"palette": {
"palette": [...],
"selected_indices": [0, 3]
}
}
```
#### Error Response (400/500)
```json
{
"status": "error",
"message": "Palette must be an array of 8 colors"
}
```
---
## Common Use Cases
### Use Case 1: Load Palette on UI Startup
```javascript
async function loadPalette() {
const response = await fetch('http://10.42.0.1:8765/api/color-palette');
const data = await response.json();
// data.palette = array of 8 colors
// data.selected_indices = [index1, index2]
return data;
}
```
### Use Case 2: User Edits a Single Color
When user changes color in slot 3 to purple:
```javascript
async function updateColor(slotIndex, r, g, b) {
// Get current palette
const current = await fetch('http://10.42.0.1:8765/api/color-palette')
.then(res => res.json());
// Update the specific slot
const newPalette = [...current.palette];
newPalette[slotIndex] = {r, g, b};
// Send updated palette
await fetch('http://10.42.0.1:8765/api/color-palette', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({palette: newPalette})
});
}
// Example: Change slot 3 to purple (128, 0, 128)
updateColor(3, 128, 0, 128);
```
### Use Case 3: User Selects Different Active Colors
When user selects slots 2 and 5:
```javascript
async function selectColors(index1, index2) {
await fetch('http://10.42.0.1:8765/api/color-palette', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
selected_indices: [index1, index2]
})
});
}
// Example: Select blue (slot 2) and cyan (slot 5)
selectColors(2, 5);
```
### Use Case 4: Reset to Default Palette
```javascript
async function resetPalette() {
const defaultPalette = [
{r: 255, g: 0, b: 0}, // Red
{r: 0, g: 255, b: 0}, // Green
{r: 0, g: 0, b: 255}, // Blue
{r: 255, g: 255, b: 0}, // Yellow
{r: 255, g: 0, b: 255}, // Magenta
{r: 0, g: 255, b: 255}, // Cyan
{r: 255, g: 128, b: 0}, // Orange
{r: 255, g: 255, b: 255} // White
];
await fetch('http://10.42.0.1:8765/api/color-palette', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
palette: defaultPalette,
selected_indices: [0, 1]
})
});
}
```
---
## Validation & Error Handling
### Validation Rules
**Palette Array:**
- Must contain **exactly 8** color objects
- Each color must have `r`, `g`, `b` fields
- RGB values must be **integers between 0-255**
**Selected Indices:**
- Must be an array of **exactly 2** integers
- Each index must be **between 0-7** (inclusive)
- Can be the same index twice (e.g., `[3, 3]`)
### Error Responses
```javascript
// Example: Invalid palette length
{
"status": "error",
"message": "Palette must be an array of 8 colors"
}
// Example: Invalid RGB value
{
"status": "error",
"message": "RGB values must be 0-255"
}
// Example: Invalid index
{
"status": "error",
"message": "Selected indices must be 0-7"
}
```
---
## Data Persistence
- **Automatic Save:** All changes are immediately saved to `lighting_config.json`
- **Automatic Load:** Server loads saved config on startup
- **Default Values:** If no config file exists, server initializes with default palette
**Default Palette:**
```javascript
[
{r: 255, g: 0, b: 0}, // Slot 0: Red
{r: 0, g: 255, b: 0}, // Slot 1: Green
{r: 0, g: 0, b: 255}, // Slot 2: Blue
{r: 255, g: 255, b: 0}, // Slot 3: Yellow
{r: 255, g: 0, b: 255}, // Slot 4: Magenta
{r: 0, g: 255, b: 255}, // Slot 5: Cyan
{r: 255, g: 128, b: 0}, // Slot 6: Orange
{r: 255, g: 255, b: 255} // Slot 7: White
]
// Default selected: [0, 1] (Red and Green)
```
---
## Testing & Debugging
### Test API Connection
```bash
# Quick test - get current palette
curl http://10.42.0.1:8765/api/color-palette
# Pretty print with jq
curl http://10.42.0.1:8765/api/color-palette | jq
# Test update
curl -X POST http://10.42.0.1:8765/api/color-palette \
-H "Content-Type: application/json" \
-d '{"selected_indices": [3, 6]}'
```
### Browser DevTools
```javascript
// Test in browser console
fetch('http://10.42.0.1:8765/api/color-palette')
.then(r => r.json())
.then(console.log);
```
---
## Advanced Topics
### CORS (Cross-Origin Requests)
**Note:** The API does not currently include CORS headers.
If accessing from a different origin (e.g., UI on different domain):
1. Add CORS middleware to the server (contact backend team)
2. Use a proxy server
3. Host UI on same origin as API
### Helper Function: RGB to Hex
```javascript
function rgbToHex({r, g, b}) {
return '#' + [r, g, b]
.map(x => x.toString(16).padStart(2, '0'))
.join('');
}
// Usage
const color = {r: 255, g: 128, b: 0};
const hex = rgbToHex(color); // "#ff8000"
```
### Helper Function: Hex to RGB
```javascript
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
// Usage
const rgb = hexToRgb('#ff8000'); // {r: 255, g: 128, b: 0}
```
---
## Environment Configuration
### Server Environment Variables (`.env`)
```bash
# Server host (bind to all interfaces)
CONTROL_SERVER_HOST=0.0.0.0
# Primary port (WebSocket + HTTP API)
CONTROL_SERVER_PORT=8765
# Additional HTTP API port (optional, for backward compatibility)
HTTP_API_PORT=8766
```
### WebSocket Endpoint
**Important:** WebSocket is on a separate endpoint from the API:
- **WebSocket:** `ws://10.42.0.1:8765/ws`
- **HTTP API:** `http://10.42.0.1:8765/api/color-palette`
---
## Summary for UI Developers
### Key Points
**8 color slots**, each with RGB values (0-255)
**2 selected colors** (indices 0-7)
**Auto-persistence** to `lighting_config.json`
**Optional updates** - send only what changed
**Two ports available** - 8765 (primary) and 8766 (backup)
### Integration Checklist
- [ ] Load palette on UI startup with `GET`
- [ ] Display 8 color slots with current colors
- [ ] Highlight the 2 selected colors
- [ ] Allow editing individual colors
- [ ] Allow selecting which 2 colors are active
- [ ] Send updates with `POST` when user makes changes
- [ ] Handle errors gracefully
- [ ] Test with both local and remote server
### Quick Reference
```javascript
// GET - Load palette
const data = await fetch('http://10.42.0.1:8765/api/color-palette')
.then(r => r.json());
// POST - Update color in slot 3
await fetch('http://10.42.0.1:8765/api/color-palette', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
palette: modifiedPaletteArray
})
});
// POST - Change selected colors to slots 2 and 5
await fetch('http://10.42.0.1:8765/api/color-palette', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
selected_indices: [2, 5]
})
});
```

107
debug_espnow.py Normal file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
Real-time ESP NOW traffic monitor for debugging pattern pausing issues.
Monitors both the ESP32-C3 USB CDC output and LED bar debug info.
"""
import serial
import time
import threading
import subprocess
import sys
import os
class ESPNowDebugger:
def __init__(self):
self.esp32_port = "/dev/ttyACM0"
self.running = False
def monitor_esp32_serial(self):
"""Monitor ESP32-C3 USB CDC output for ESP NOW debug info"""
try:
ser = serial.Serial(self.esp32_port, 115200, timeout=1)
print("🔌 Monitoring ESP32-C3 USB CDC output...")
while self.running:
try:
line = ser.readline().decode('utf-8').strip()
if line:
timestamp = time.strftime("%H:%M:%S")
print(f"[{timestamp}] ESP32-C3: {line}")
except serial.SerialTimeoutException:
continue
except Exception as e:
print(f"❌ ESP32-C3 monitor error: {e}")
break
ser.close()
except Exception as e:
print(f"❌ Failed to connect to ESP32-C3: {e}")
def check_lighting_controller_logs(self):
"""Check lighting controller logs for message sending"""
try:
# Monitor control server output for ESP NOW messages
print("🔌 Monitoring lighting controller ESP NOW messages...")
# Check if control server is running
proc = subprocess.run(['pgrep', '-f', 'lighting-controller'],
capture_output=True, text=True)
if not proc.stdout.strip():
print("❌ Control server not running!")
return
print("✅ Control server running, monitor logs manually")
print("💡 Tips:")
print(" - Watch control server terminal output")
print(" - Look for SPI/ESP NOW communication messages")
print(" - Check for timing gaps between messages")
except Exception as e:
print(f"❌ Error checking control server: {e}")
def run(self):
"""Start all monitoring threads"""
print("🔍 ESP NOW Communication Debugger")
print("=" * 50)
print()
# Check if ESP32-C3 is connected
if not os.path.exists(self.esp32_port):
print(f"❌ ESP32-C3 not found on {self.esp32_port}")
print("💡 Make sure ESP32-C3 is connected via USB")
return
print(f"✅ ESP32-C3 found on {self.esp32_port}")
print()
self.running = True
# Start ESP32-C3 monitoring thread
esp32_thread = threading.Thread(target=self.monitor_esp32_serial)
esp32_thread.daemon = True
esp32_thread.start()
# Monitor lighting controller
self.check_lighting_controller_logs()
print()
print("🔍 Monitoring active. Press Ctrl+C to stop...")
print("📝 Watch for:")
print(" - ESP NOW message transmission timing")
print(" - Any error messages or delays")
print(" - Status updates every 5 seconds")
print(" - Pattern interrupt patterns")
try:
while self.running:
time.sleep(1)
except KeyboardInterrupt:
print("\n🛑 Stopping debugger...")
self.running = False
if __name__ == "__main__":
debugger = ESPNowDebugger()
debugger.run()

137
debug_led_bar.py Normal file
View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
Enhanced LED bar debugging script.
Adds timestamp and message sequence debugging to LED bar main.py
"""
debug_main_content = '''
import patterns
from settings import Settings
from web import web
from patterns import Patterns
import gc
import utime
import machine
import time
import wifi
import json
from p2p import p2p
import espnow
import network
def main():
settings = Settings()
print(settings)
if settings.get("color_order", "RGB") == "RBG":
color_order = (1, 5, 3)
else:
color_order = (1, 3, 5)
patterns = Patterns(settings["led_pin"], settings["num_leds"], selected="off")
sta_if = network.WLAN(network.STA_IF)
sta_if.active(True)
e = espnow.ESPNow()
e.config(rxbuf=1024)
e.active(True)
wdt = machine.WDT(timeout=10000)
wdt.feed()
# Debug counters
msg_count = 0
last_msg_time = 0
gap_count = 0
print(f"[DEBUG] Bar '{settings.get('name', 'unknown')}' starting ESP NOW debug mode")
print(f"[DEBUG] Expected message types: 'b' (beat), 'u' (update)")
while True:
# advance pattern based on its own returned schedule
# due = patterns.tick(due)
wdt.feed()
# Drain all pending packets and only process the latest
last_msg = None
msg_received = False
while True:
host, msg = e.recv(0)
if not msg:
break
last_msg = msg
msg_received = True
if last_msg:
msg_count += 1
current_time = time.ticks_ms()
# Calculate gap between messages
if last_msg_time > 0:
gap = time.ticks_diff(current_time, last_msg_time)
if gap > 1000: # > 1 second gap
gap_count += 1
print(f"[DEBUG] Message gap detected: {gap}ms (gap #{gap_count})")
last_msg_time = current_time
try:
data = json.loads(last_msg)
msg_type = data.get("d", {}).get("t", "unknown")
timestamp = time.strftime("%H:%M:%S")
print(f"[{timestamp}] MSG#{msg_count}: type='{msg_type}' gap={time.ticks_diff(current_time, last_msg_time) if last_msg_time > 0 else 0}ms")
# Full data print for debugging
print(f"[DEBUG] Full message: {data}")
defaults = data.get("d", {})
bar = data.get(settings.get("name"), {})
# Check message type
message_type = defaults.get("t", "b") # Default to beat if not specified
# Always update parameters from message
patterns.brightness = bar.get("br", defaults.get("br", patterns.brightness))
patterns.delay = bar.get("dl", defaults.get("dl", patterns.delay))
patterns.colors = bar.get("cl", defaults.get("cl", patterns.colors))
patterns.n1 = bar.get("n1", defaults.get("n1", patterns.n1))
patterns.n2 = bar.get("n2", defaults.get("n2", patterns.n2))
patterns.n3 = bar.get("n3", defaults.get("n3", patterns.n3))
patterns.step = bar.get("s", defaults.get("s", patterns.step))
# Only execute pattern if it's a beat message
if message_type == "b": # Beat message
selected_pattern = bar.get("pt", defaults.get("pt", "off"))
if selected_pattern in patterns.patterns:
print(f"[DEBUG] Executing pattern: {selected_pattern}")
patterns.patterns[selected_pattern]()
else:
print(f"[DEBUG] Pattern '{selected_pattern}' not found")
elif message_type == "u": # Update message
print(f"[DEBUG] Parameters updated: brightness={patterns.brightness}, delay={patterns.delay}")
else:
print(f"[DEBUG] Unknown message type: '{message_type}'")
except Exception as ex:
print(f"[DEBUG] Failed to load espnow data {last_msg}: {ex}")
continue
# Periodic status every 100 loops (about every 10 seconds)
if msg_count > 0 and msg_count % 100 == 0:
print(f"[STATUS] Processed {msg_count} messages, {gap_count} gaps detected")
main()
'''
if __name__ == "__main__":
print("Enhanced LED bar debugging output generated.")
print("This script would replace the main.py with enhanced debugging.")
print("The debug version adds:")
print("- Timestamped messages")
print("- Message sequence numbers")
print("- Gap detection between messages")
print("- Detailed pattern execution logging")
print("- Status summaries")

45
esp32_debug_patch.md Normal file
View File

@@ -0,0 +1,45 @@
# ESP32-C3 Debug Patch
## Issue Found
The ESP32-C3 firmware sends status messages every 5 seconds when SPI transactions fail (line 208 in main.c), which could interfere with ESP NOW communication and cause pattern pauses.
## Debugging Steps
### 1. Monitor ESP32-C3 Status Messages
The ESP32-C3 sends status messages every 5 seconds, which may interrupt ESP NOW communication.
### 2. Check SPI Communication
Pattern stops could be caused by:
- SPI transaction failures causing 5-second delays
- ESP NOW interference with SPI operations
- Memory/buffer issues during concurrent operations
### 3. Monitor ESP NOW Traffic
Use the debug scripts to monitor:
- ESP NOW message transmission timing
- Message gaps between transmissions
- ESP32-C3 vs LED bar timing differences
### 4. Potential Fixes
#### Immediate fixes:
1. **Reduce status message frequency** from 5 seconds to 30 seconds
2. **Add ESP NOW debug logging** to see message transmission times
3. **Remove blocking delays** on SPI failures
#### Firmware modifications needed:
1. Change `pdMS_TO_TICKS(5000)` to `pdMS_TO_TICKS(30000)` for status messages
2. Add debug printf statements for ESP NOW transmissions
3. Make SPI error handling non-blocking
### 5. Testing Strategy
1. Apply firmware patches
2. Monitor ESP NOW traffic with debug scripts
3. Observe pattern continuity
4. Check timing of ESP NOW vs SPI operations
## Current Status
- ESP32-C3 runs status sender task every 10 seconds
- ESP32-C3 sends status via ESP NOW every 5 seconds on SPI errors
- Original heartbeat in control server was disabled (this was correct)
- Issue likely in ESP32-C3 firmware timing

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Control Server for Lighting Controller Control Server for Lighting Controller
Handles lighting control logic and communicates with LED bars via WebSocket. Handles lighting control logic and communicates with LED bars via SPI or WebSocket.
Receives commands from UI client via WebSocket. Receives commands from UI client via WebSocket.
""" """
@@ -12,14 +12,15 @@ import logging
import socket import socket
import threading import threading
import time import time
import argparse
from bar_config import LED_BAR_NAMES, DEFAULT_BAR_SETTINGS from bar_config import LED_BAR_NAMES, DEFAULT_BAR_SETTINGS
from color_utils import adjust_brightness from color_utils import adjust_brightness
from networking import SPIClient, WebSocketClient
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Configuration # Configuration
LED_SERVER_URI = "ws://192.168.4.1:80/ws"
CONTROL_SERVER_PORT = 8765 CONTROL_SERVER_PORT = 8765
SOUND_CONTROL_HOST = "127.0.0.1" SOUND_CONTROL_HOST = "127.0.0.1"
SOUND_CONTROL_PORT = 65433 SOUND_CONTROL_PORT = 65433
@@ -36,61 +37,37 @@ PATTERN_NAMES = {
"radiate": "rd", "radiate": "rd",
"sequential_pulse": "sp", "sequential_pulse": "sp",
"alternating_phase": "ap", "alternating_phase": "ap",
"segmented_movement": "sm",
} }
class LEDController: class LEDController:
"""Handles communication with LED bars via WebSocket.""" """Handles communication with LED bars via SPI or WebSocket."""
def __init__(self, led_server_uri): def __init__(self, transport="spi", **kwargs):
self.led_server_uri = led_server_uri if transport == "spi":
self.websocket = None self.client = SPIClient(
self.is_connected = False bus=kwargs.get('spi_bus', 0),
self.reconnect_task = None device=kwargs.get('spi_device', 0),
speed_hz=kwargs.get('spi_speed_hz', 1_000_000)
)
elif transport == "websocket":
self.client = WebSocketClient(uri=kwargs.get('uri', 'ws://192.168.4.1/ws'))
else:
raise ValueError(f"Invalid transport: {transport}. Must be 'spi' or 'websocket'")
@property
def is_connected(self) -> bool:
return getattr(self.client, "is_connected", False)
async def connect(self): async def connect(self):
"""Connect to LED server.""" await self.client.connect()
if self.is_connected and self.websocket:
return
try:
logging.info(f"Connecting to LED server at {self.led_server_uri}...")
self.websocket = await websockets.connect(self.led_server_uri)
self.is_connected = True
logging.info("Connected to LED server")
except Exception as e:
logging.error(f"Failed to connect to LED server: {e}")
self.is_connected = False
self.websocket = None
async def send_data(self, data): async def send_data(self, data):
"""Send data to LED server.""" await self.client.send_data(data)
if not self.is_connected or not self.websocket:
logging.warning("Not connected to LED server. Attempting to reconnect...")
await self.connect()
if not self.is_connected:
logging.error("Failed to reconnect to LED server. Cannot send data.")
return
try:
await self.websocket.send(json.dumps(data))
logging.debug(f"Sent to LED server: {data}")
except Exception as e:
logging.error(f"Failed to send data to LED server: {e}")
self.is_connected = False
self.websocket = None
# Attempt to reconnect
await self.connect()
async def close(self): async def close(self):
"""Close LED server connection.""" await self.client.close()
if self.websocket and self.is_connected:
await self.websocket.close()
self.is_connected = False
self.websocket = None
logging.info("Disconnected from LED server")
class SoundController: class SoundController:
@@ -118,8 +95,8 @@ class SoundController:
class LightingController: class LightingController:
"""Main lighting control logic.""" """Main lighting control logic."""
def __init__(self): def __init__(self, transport="spi", **transport_kwargs):
self.led_controller = LEDController(LED_SERVER_URI) self.led_controller = LEDController(transport=transport, **transport_kwargs)
self.sound_controller = SoundController(SOUND_CONTROL_HOST, SOUND_CONTROL_PORT) self.sound_controller = SoundController(SOUND_CONTROL_HOST, SOUND_CONTROL_PORT)
# Lighting state # Lighting state
@@ -132,6 +109,7 @@ class LightingController:
self.n1 = 10 self.n1 = 10
self.n2 = 10 self.n2 = 10
self.n3 = 1 self.n3 = 1
self.n4 = 1
self.beat_index = 0 self.beat_index = 0
self.beat_sending_enabled = True self.beat_sending_enabled = True
@@ -159,6 +137,7 @@ class LightingController:
"n1": self.n1, "n1": self.n1,
"n2": self.n2, "n2": self.n2,
"n3": self.n3, "n3": self.n3,
"n4": self.n4,
"s": self.beat_index % 256, "s": self.beat_index % 256,
} }
} }
@@ -181,7 +160,7 @@ class LightingController:
async def _send_normal_pattern(self): async def _send_normal_pattern(self):
"""Send normal pattern to all bars.""" """Send normal pattern to all bars."""
patterns_needing_params = ["alternating", "flicker", "n_chase", "rainbow", "radiate"] patterns_needing_params = ["alternating", "flicker", "n_chase", "rainbow", "radiate", "segmented_movement"]
payload = { payload = {
"d": { "d": {
@@ -297,6 +276,8 @@ class LightingController:
self.n2 = data["n2"] self.n2 = data["n2"]
if "n3" in data: if "n3" in data:
self.n3 = data["n3"] self.n3 = data["n3"]
if "n4" in data:
self.n4 = data["n4"]
await self._request_param_update() await self._request_param_update()
elif message_type == "delay_change": elif message_type == "delay_change":
@@ -314,10 +295,11 @@ class LightingController:
class ControlServer: class ControlServer:
"""WebSocket server for UI client communication and TCP server for sound.""" """WebSocket server for UI client communication and TCP server for sound."""
def __init__(self): def __init__(self, transport="spi", enable_heartbeat=False, **transport_kwargs):
self.lighting_controller = LightingController() self.lighting_controller = LightingController(transport=transport, **transport_kwargs)
self.clients = set() self.clients = set()
self.tcp_server = None self.tcp_server = None
self.enable_heartbeat = enable_heartbeat
async def handle_ui_client(self, websocket): async def handle_ui_client(self, websocket):
"""Handle UI client WebSocket connection.""" """Handle UI client WebSocket connection."""
@@ -399,12 +381,30 @@ class ControlServer:
# Connect to LED server # Connect to LED server
await self.lighting_controller.led_controller.connect() await self.lighting_controller.led_controller.connect()
# Start servers and heartbeat task # Start servers (optionally include heartbeat)
await asyncio.gather( websocket_task = asyncio.create_task(self._websocket_server_task())
self.start_websocket_server(), tcp_task = asyncio.create_task(self._tcp_server_task())
self.start_tcp_server(),
self._heartbeat_loop() tasks = [websocket_task, tcp_task]
) if self.enable_heartbeat:
heartbeat_task = asyncio.create_task(self._heartbeat_loop())
tasks.append(heartbeat_task)
await asyncio.gather(*tasks)
async def _websocket_server_task(self):
"""Keep WebSocket server running."""
await self.start_websocket_server()
# Keep the server running indefinitely
while True:
await asyncio.sleep(1)
async def _tcp_server_task(self):
"""Keep TCP server running."""
await self.start_tcp_server()
# Keep the server running indefinitely
while True:
await asyncio.sleep(1)
async def _heartbeat_loop(self): async def _heartbeat_loop(self):
"""Send periodic heartbeats to keep LED connection alive.""" """Send periodic heartbeats to keep LED connection alive."""
@@ -427,7 +427,25 @@ class ControlServer:
async def main(): async def main():
"""Main entry point.""" """Main entry point."""
server = ControlServer() args = parse_arguments()
transport_kwargs = {}
if args.transport == "spi":
transport_kwargs = {
'spi_bus': args.spi_bus,
'spi_device': args.spi_device,
'spi_speed_hz': args.spi_speed
}
elif args.transport == "websocket":
transport_kwargs = {
'uri': args.uri
}
server = ControlServer(
transport=args.transport,
enable_heartbeat=args.enable_heartbeat,
**transport_kwargs
)
try: try:
await server.run() await server.run()
@@ -439,5 +457,61 @@ async def main():
await server.lighting_controller.led_controller.close() await server.lighting_controller.led_controller.close()
def parse_arguments():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description="Control Server for Lighting Controller")
# Transport selection
transport_group = parser.add_argument_group("Transport Options")
transport_group.add_argument(
"--transport",
choices=["spi", "websocket"],
default="spi",
help="Transport method for LED communication (default: spi)"
)
# Control options
control_group = parser.add_argument_group("Control Options")
control_group.add_argument(
"--enable-heartbeat",
action="store_true",
help="Enable heartbeat system (may cause pattern interruptions)"
)
# SPI options
spi_group = parser.add_argument_group("SPI Options")
spi_group.add_argument(
"--spi-bus",
type=int,
default=0,
help="SPI bus number (default: 0)"
)
spi_group.add_argument(
"--spi-device",
type=int,
default=0,
help="SPI device number (default: 0)"
)
spi_group.add_argument(
"--spi-speed",
type=int,
default=1_000_000,
help="SPI speed in Hz (default: 1000000)"
)
# WebSocket options
ws_group = parser.add_argument_group("WebSocket Options")
ws_group.add_argument(
"--uri",
type=str,
default="ws://192.168.4.1/ws",
help="WebSocket URI for LED communication (default: ws://192.168.4.1/ws)"
)
return parser.parse_args()
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())
#

View File

@@ -1,53 +1,132 @@
import json
import os
import time
import asyncio import asyncio
import websockets import websockets
import json
try:
import spidev
except Exception as e:
spidev = None
class SPIClient:
"""SPI transport client."""
def __init__(self, bus=None, device=None, speed_hz=None):
# SPI configuration (defaults can be overridden by args or env)
self.bus = 0 if bus is None else int(bus)
self.device = 0 if device is None else int(device)
self.speed_hz = (
int(os.getenv("SPI_SPEED_HZ", "1000000")) if speed_hz is None else int(speed_hz)
)
self.spi = None
self.is_connected = False
async def connect(self):
"""Initializes the SPI connection."""
if self.is_connected and self.spi:
return
if spidev is None:
print("spidev not available; cannot open SPI")
self.is_connected = False
self.spi = None
return
try:
spi = spidev.SpiDev()
spi.open(self.bus, self.device)
spi.max_speed_hz = self.speed_hz
spi.mode = 0
spi.bits_per_word = 8
self.spi = spi
self.is_connected = True
print(f"SPI connected: bus={self.bus} device={self.device} speed={self.speed_hz}Hz mode=0")
except Exception as e:
print(f"Error opening SPI: {e}")
self.is_connected = False
self.spi = None
async def send_data(self, data):
"""Sends a JSON object over SPI as UTF-8 bytes."""
if not self.is_connected or not self.spi:
await self.connect()
if not self.is_connected:
print("SPI not connected; cannot send")
return
try:
json_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
payload = list(json_str.encode("utf-8"))
if not payload:
return
# Keep payload comfortably below ESP-NOW max; trim if necessary
if len(payload) > 240:
payload = payload[:240]
self.spi.xfer2(payload)
except Exception as e:
print(f"SPI send failed: {e}")
# Attempt simple reopen on next call
self.is_connected = False
self.spi = None
async def close(self):
"""Closes the SPI connection."""
try:
if self.spi:
self.spi.close()
except Exception:
pass
self.is_connected = False
self.spi = None
class WebSocketClient: class WebSocketClient:
def __init__(self, uri): """WebSocket transport client."""
self.uri = uri def __init__(self, uri=None, *, bus=None, device=None, speed_hz=None):
self.uri = uri or "ws://192.168.4.1/ws"
self.websocket = None self.websocket = None
self.is_connected = False self.is_connected = False
async def connect(self): async def connect(self):
"""Establishes the WebSocket connection.""" """Initializes the WebSocket connection."""
if self.is_connected and self.websocket: if self.is_connected and self.websocket:
print("Already connected.")
return return
try: try:
print(f"Connecting to {self.uri}...")
self.websocket = await websockets.connect(self.uri) self.websocket = await websockets.connect(self.uri)
self.is_connected = True self.is_connected = True
print("WebSocket connected.") print(f"WebSocket connected: {self.uri}")
except (ConnectionError, websockets.exceptions.ConnectionClosedOK) as e: except Exception as e:
print(f"Error connecting: {e}") print(f"Error opening WebSocket: {e}")
self.is_connected = False self.is_connected = False
self.websocket = None self.websocket = None
async def send_data(self, data): async def send_data(self, data):
print(data) """Sends a JSON object over WebSocket."""
"""Sends data over the open WebSocket connection."""
if not self.is_connected or not self.websocket: if not self.is_connected or not self.websocket:
print("WebSocket not connected. Attempting to reconnect...")
await self.connect() await self.connect()
if not self.is_connected: if not self.is_connected:
print("Failed to reconnect. Cannot send data.") print("WebSocket not connected; cannot send")
return return
try: try:
await self.websocket.send(json.dumps(data)) json_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
print(f"Sent: {data}") await self.websocket.send(json_str)
except (ConnectionError, websockets.exceptions.ConnectionClosed) as e: print(f"WebSocket sent: {json_str}")
print(f"Error sending data: {e}") except Exception as e:
print(f"WebSocket send failed: {e}")
# Attempt simple reopen on next call
self.is_connected = False self.is_connected = False
self.websocket = None # Reset connection on error self.websocket = None
await self.connect() # Attempt to reconnect
async def close(self): async def close(self):
"""Closes the WebSocket connection.""" """Closes the WebSocket connection."""
if self.websocket and self.is_connected: try:
await self.websocket.close() if self.websocket:
self.is_connected = False await self.websocket.close()
self.websocket = None except Exception:
print("WebSocket closed.") pass
self.is_connected = False
self.websocket = None

View File

@@ -5,6 +5,7 @@ import aubio
import numpy as np import numpy as np
from time import sleep from time import sleep
import json import json
import argparse
import socket import socket
import time import time
import logging # Added logging import import logging # Added logging import
@@ -24,7 +25,7 @@ SOUND_CONTROL_HOST = "127.0.0.1"
SOUND_CONTROL_PORT = 65433 SOUND_CONTROL_PORT = 65433
class SoundBeatDetector: class SoundBeatDetector:
def __init__(self, tcp_host: str, tcp_port: int): def __init__(self, tcp_host: str, tcp_port: int, *, input_device: int | None = None):
self.tcp_host = tcp_host self.tcp_host = tcp_host
self.tcp_port = tcp_port self.tcp_port = tcp_port
self.tcp_socket = None self.tcp_socket = None
@@ -34,7 +35,7 @@ class SoundBeatDetector:
self.bufferSize = 512 self.bufferSize = 512
self.windowSizeMultiple = 2 self.windowSizeMultiple = 2
self.audioInputDeviceIndex = 7 self.audioInputDeviceIndex = 7 if input_device is None else int(input_device)
self.audioInputChannels = 1 self.audioInputChannels = 1
self.pa = pyaudio.PyAudio() self.pa = pyaudio.PyAudio()
@@ -196,11 +197,15 @@ class SoundBeatDetector:
# Removed async def run(self) # Removed async def run(self)
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Sound beat detector")
parser.add_argument("--input-device", type=int, help="Audio input device index to use")
args = parser.parse_args()
# TCP Server Configuration (should match midi.py) # TCP Server Configuration (should match midi.py)
MIDI_TCP_HOST = "127.0.0.1" MIDI_TCP_HOST = "127.0.0.1"
MIDI_TCP_PORT = 65432 MIDI_TCP_PORT = 65432
sound_detector = SoundBeatDetector(MIDI_TCP_HOST, MIDI_TCP_PORT) sound_detector = SoundBeatDetector(MIDI_TCP_HOST, MIDI_TCP_PORT, input_device=args.input_device)
logging.info("Starting SoundBeatDetector...") logging.info("Starting SoundBeatDetector...")
try: try:
sound_detector.start_stream() sound_detector.start_stream()

File diff suppressed because it is too large Load Diff

132
test/test_control_server.py Normal file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Test script for control_server.py UI WebSocket API.
Starts a client to localhost:8765 and sends a small sequence of UI commands:
- pattern_change
- color_change
- brightness_change
- parameter_change (n1/n2/n3/n4)
Usage examples:
python test/test_control_server.py --pattern on --r 255 --g 0 --b 0 --brightness 150 --n1 5 --n2 5 --n3 1 --n4 2
python test/test_control_server.py --pattern rainbow
"""
import argparse
import asyncio
import json
import websockets
import re
import os
def load_dotenv(filepath: str = ".env"):
try:
if not os.path.exists(filepath):
return
with open(filepath, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' not in line:
continue
key, value = line.split('=', 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
except Exception:
pass
def build_messages(args):
msgs = []
# Prioritize delay_change so a single send applies delay when both are provided
if args.delay is not None:
msgs.append({"type": "delay_change", "data": {"delay": args.delay}})
if args.pattern is not None:
msgs.append({"type": "pattern_change", "data": {"pattern": args.pattern}})
# Optional colors flag: parse first color and map to r,g,b if explicit r/g/b not given
if args.colors and (args.r is None and args.g is None and args.b is None):
hex_re = re.compile(r"^#?[0-9a-fA-F]{6}$")
first = args.colors.split(',')[0].strip()
if hex_re.match(first):
v = first[1:] if first.startswith('#') else first
args.r = int(v[0:2], 16)
args.g = int(v[2:4], 16)
args.b = int(v[4:6], 16)
if args.r is not None or args.g is not None or args.b is not None:
payload = {}
if args.r is not None:
payload["r"] = args.r
if args.g is not None:
payload["g"] = args.g
if args.b is not None:
payload["b"] = args.b
msgs.append({"type": "color_change", "data": payload})
if args.brightness is not None:
msgs.append({"type": "brightness_change", "data": {"brightness": args.brightness}})
if any(v is not None for v in (args.n1, args.n2, args.n3, args.n4)):
payload = {}
if args.n1 is not None:
payload["n1"] = args.n1
if args.n2 is not None:
payload["n2"] = args.n2
if args.n3 is not None:
payload["n3"] = args.n3
if args.n4 is not None:
payload["n4"] = args.n4
msgs.append({"type": "parameter_change", "data": payload})
return msgs
async def run_test(uri: str, messages: list[dict], sleep_s: float):
async with websockets.connect(uri) as ws:
# Send all messages with a delay between them
for m in messages:
await ws.send(json.dumps(m))
if len(messages) > 1:
await asyncio.sleep(sleep_s)
def parse_args():
p = argparse.ArgumentParser(description="Send UI commands to control_server WebSocket")
load_dotenv()
default_uri = os.getenv("CONTROL_SERVER_URI", "ws://10.1.1.117:8765")
p.add_argument("--uri", default=default_uri, help=f"WebSocket URI (default {default_uri})")
p.add_argument("--pattern", help="Pattern name for pattern_change")
p.add_argument("--r", type=int, help="Red 0-255 for color_change")
p.add_argument("--g", type=int, help="Green 0-255 for color_change")
p.add_argument("--b", type=int, help="Blue 0-255 for color_change")
p.add_argument("--brightness", type=int, help="Brightness value for brightness_change")
p.add_argument("--delay", type=int, help="Pattern delay (ms) via delay_change")
p.add_argument("--n1", type=int, help="n1 for parameter_change")
p.add_argument("--n2", type=int, help="n2 for parameter_change")
p.add_argument("--n3", type=int, help="n3 for parameter_change")
p.add_argument("--n4", type=int, help="n4 for parameter_change")
p.add_argument("--sleep", type=float, default=0.2, help="Seconds to wait between messages (default 0.2)")
p.add_argument("--colors", help="Comma-separated hex colors (uses first as r,g,b)")
return p.parse_args()
def main():
args = parse_args()
messages = build_messages(args)
if not messages:
# Default minimal test: just pattern_change to 'on'
messages = [{"type": "pattern_change", "data": {"pattern": "on"}}]
asyncio.run(run_test(args.uri, messages, args.sleep))
if __name__ == "__main__":
main()

118
test/test_networking.py Normal file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""
Networking SPI test: builds a legacy led-bar payload and sends it via src/networking.py SPI client.
Usage examples:
python test/test_networking.py --type b --pattern on --colors ff0000,00ff00,0000ff
python test/test_networking.py --type u --brightness 128 --delay 50
python test/test_networking.py --data '{"d":{"t":"b","pt":"off"}}'
"""
import argparse
import asyncio
import json
import os
import re
import sys
# Import SPI networking client
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.dirname(SCRIPT_DIR)
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
from src.networking import WebSocketClient # SPI client with same API
HEX6_RE = re.compile(r"^[0-9a-fA-F]{6}$")
def parse_hex6_to_rgb(value: str):
v = value.strip()
if v.startswith("0x") or v.startswith("0X"):
v = v[2:]
if v.startswith("#"):
v = v[1:]
if not HEX6_RE.match(v):
raise ValueError(f"Invalid hex color: {value}")
return [int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16)]
def build_payload(args: argparse.Namespace) -> dict:
if args.data:
return json.loads(args.data)
if args.file:
with open(args.file, "r", encoding="utf-8") as f:
return json.load(f)
d = {"t": args.type}
if args.pattern:
d["pt"] = args.pattern
if args.brightness is not None:
d["br"] = int(args.brightness)
if args.delay is not None:
d["dl"] = int(args.delay)
if args.n1 is not None:
d["n1"] = int(args.n1)
if args.n2 is not None:
d["n2"] = int(args.n2)
if args.n3 is not None:
d["n3"] = int(args.n3)
if args.step is not None:
d["s"] = int(args.step)
if args.colors:
items = [c.strip() for c in args.colors.split(',') if c.strip()]
d["cl"] = [parse_hex6_to_rgb(c) for c in items]
payload = {"d": d}
if args.name:
# For convenience, mirror defaults as per-device override
payload[args.name] = {k: v for k, v in d.items() if k != "t"}
return payload
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Send SPI networking test payload (legacy led-bar format)")
src = p.add_mutually_exclusive_group()
src.add_argument("--data", help="Raw JSON payload to send")
src.add_argument("--file", help="Path to JSON file to send")
p.add_argument("--type", choices=["b", "u"], default="b", help="Message type (beat/update)")
p.add_argument("--pattern", help="Pattern name (pt)")
p.add_argument("--brightness", type=int, help="Brightness (br)")
p.add_argument("--delay", type=int, help="Delay (dl)")
p.add_argument("--n1", type=int, help="n1")
p.add_argument("--n2", type=int, help="n2")
p.add_argument("--n3", type=int, help="n3")
p.add_argument("--step", type=int, help="step (s)")
p.add_argument("--colors", help="Comma-separated hex colors for cl (e.g. ff0000,00ff00,0000ff)")
p.add_argument("--name", help="Per-device override key (device name)")
# SPI config overrides
p.add_argument("--bus", type=int, default=0, help="SPI bus (default 0)")
p.add_argument("--device", type=int, default=0, help="SPI device/CE (default 0)")
p.add_argument("--speed", type=int, default=1_000_000, help="SPI speed Hz (default 1MHz)")
return p.parse_args()
async def main_async() -> int:
args = parse_args()
payload = build_payload(args)
client = WebSocketClient(uri=None, bus=args.bus, device=args.device, speed_hz=args.speed)
await client.connect()
await client.send_data(payload)
await client.close()
return 0
def main() -> int:
return asyncio.run(main_async())
if __name__ == "__main__":
raise SystemExit(main())