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

This commit is contained in:
2025-10-03 23:38:52 +13:00
parent 0906cb22e6
commit a654527dc3
4 changed files with 1925 additions and 83 deletions

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]
})
});
```

View File

@@ -15,14 +15,118 @@ import logging
from async_tkinter_loop import async_handler, async_mainloop from async_tkinter_loop import async_handler, async_mainloop
import websockets import websockets
import websocket import websocket
from dotenv import load_dotenv import atexit
import signal
import urllib.request
import urllib.error
# Load environment variables from .env file # Single instance locker to prevent multiple UI processes
load_dotenv() class SingleInstanceLocker:
def __init__(self, name: str):
self.name = name
self.lock_path = f"/tmp/{name}.lock"
self._fd = None
self.acquire()
def acquire(self):
try:
self._fd = os.open(self.lock_path, os.O_CREAT | os.O_EXCL | os.O_RDWR)
os.write(self._fd, str(os.getpid()).encode())
except FileExistsError:
# If lock exists but process is dead, remove it
try:
with open(self.lock_path, 'r') as f:
pid_str = f.read().strip()
if pid_str and pid_str.isdigit():
pid = int(pid_str)
try:
os.kill(pid, 0)
# Process exists, deny new instance
raise SystemExit("Another UI instance is already running.")
except OSError:
# Stale lock; remove
os.remove(self.lock_path)
return self.acquire()
else:
os.remove(self.lock_path)
return self.acquire()
except Exception:
raise SystemExit("Another UI instance may be running (lock busy).")
def release(self):
try:
if self._fd is not None:
os.close(self._fd)
self._fd = None
if os.path.exists(self.lock_path):
os.remove(self.lock_path)
except Exception:
pass
# Configuration # Configuration
CONFIG_FILE = "config.json" CONFIG_FILE = "config.json"
CONTROL_SERVER_URI = os.getenv("CONTROL_SERVER_URI", "ws://localhost:8765")
# Minimal .env loader (no external dependency)
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("'")
# Do not overwrite if already set in environment
if key and key not in os.environ:
os.environ[key] = value
except Exception:
# Silently ignore .env parse errors to avoid breaking UI
pass
# Load environment variables from .env if present
load_dotenv()
# Control server URI can be overridden via environment
CONTROL_SERVER_URI = os.getenv("CONTROL_SERVER_URI", "ws://10.1.1.117:8765")
def _build_palette_api_base() -> str:
try:
# Expect ws://host:port or ws://host
uri = CONTROL_SERVER_URI
if uri.startswith("ws://"):
http = "http://" + uri[len("ws://"):]
elif uri.startswith("wss://"):
http = "https://" + uri[len("wss://"):]
else:
http = uri
# Append API path
if http.endswith('/'):
http = http[:-1]
return f"{http}/api/color-palette"
except Exception:
return "http://localhost:8765/api/color-palette"
PALETTE_API_BASE = _build_palette_api_base()
def _build_base_http() -> str:
try:
uri = CONTROL_SERVER_URI
if uri.startswith("ws://"):
http = "http://" + uri[len("ws://"):]
elif uri.startswith("wss://"):
http = "https://" + uri[len("wss://"):]
else:
http = uri
if http.endswith('/'):
http = http[:-1]
return http
except Exception:
return "http://localhost:8765"
HTTP_BASE = _build_base_http()
# Dark theme colors # Dark theme colors
bg_color = "#2e2e2e" bg_color = "#2e2e2e"
@@ -48,6 +152,7 @@ class WebSocketClient:
self.websocket = None self.websocket = None
self.is_connected = False self.is_connected = False
self.reconnect_task = None self.reconnect_task = None
self._stop_reconnect = False
async def connect(self): async def connect(self):
"""Establish WebSocket connection to control server.""" """Establish WebSocket connection to control server."""
@@ -64,6 +169,22 @@ class WebSocketClient:
self.is_connected = False self.is_connected = False
self.websocket = None self.websocket = None
async def auto_reconnect(self, base_interval: float = 2.0, max_interval: float = 10.0):
"""Background task to reconnect if the connection drops."""
backoff = base_interval
while not self._stop_reconnect:
if not self.is_connected:
await self.connect()
# Exponential backoff when not connected
if not self.is_connected:
await asyncio.sleep(backoff)
backoff = min(max_interval, backoff * 1.5)
else:
backoff = base_interval
else:
# Connected; check again later
await asyncio.sleep(base_interval)
async def send_message(self, message_type, data=None): async def send_message(self, message_type, data=None):
"""Send a message to the control server.""" """Send a message to the control server."""
if not self.is_connected or not self.websocket: if not self.is_connected or not self.websocket:
@@ -83,11 +204,15 @@ class WebSocketClient:
async def close(self): async def close(self):
"""Close WebSocket connection.""" """Close WebSocket connection."""
if self.websocket and self.is_connected: self._stop_reconnect = True
await self.websocket.close() if self.websocket:
self.is_connected = False try:
self.websocket = None await self.websocket.close()
logging.info("Disconnected from control server") except Exception:
pass
self.is_connected = False
self.websocket = None
logging.info("Disconnected from control server")
class MidiController: class MidiController:
@@ -113,6 +238,13 @@ class MidiController:
self.knob7 = 0 self.knob7 = 0
self.knob8 = 0 self.knob8 = 0
self.beat_sending_enabled = True self.beat_sending_enabled = True
# Color palette selection state (two selected indices, alternating target)
self.selected_indices = [0, 1]
self.next_selected_target = 0 # 0 selects color1 next, 1 selects color2 next
# Optional async callback set by UI to persist selected indices via REST
self.on_select_palette_indices = None
# Optional async callback to persist parameter changes via REST
self.on_parameters_change = None
def get_midi_ports(self): def get_midi_ports(self):
"""Get list of available MIDI input ports.""" """Get list of available MIDI input ports."""
@@ -185,19 +317,42 @@ class MidiController:
async def handle_midi_message(self, msg): async def handle_midi_message(self, msg):
"""Handle incoming MIDI message and send to control server.""" """Handle incoming MIDI message and send to control server."""
if msg.type == 'note_on': if msg.type == 'note_on':
# Pattern selection (notes 36-51) # Pattern selection for specific MIDI notes
logging.info(f"MIDI Note {msg.note}: {msg.velocity}") logging.info(f"MIDI Note {msg.note}: {msg.velocity}")
pattern_bindings = [ note_to_pattern = {
"pulse", "sequential_pulse", "alternating", "alternating_phase", 36: "on",
"n_chase", "rainbow", "flicker", "radiate" 37: "o",
] 38: "f",
idx = msg.note - 36 39: "a",
if 0 <= idx < len(pattern_bindings): 40: "p",
self.current_pattern = pattern_bindings[idx] 41: "r",
await self.websocket_client.send_message("pattern_change", { 42: "rd",
"pattern": self.current_pattern 43: "sm",
}) }
logging.info(f"Pattern changed to: {self.current_pattern}") pattern = note_to_pattern.get(msg.note)
if pattern:
self.current_pattern = pattern
# Send pattern change via REST per api.md
try:
data = json.dumps({"pattern": pattern}).encode('utf-8')
req = urllib.request.Request(f"{HTTP_BASE}/api/pattern", data=data, method='POST', headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=2.0) as resp:
_ = resp.read()
logging.info(f"Pattern changed to: {pattern}")
except Exception as e:
logging.error(f"Failed to POST pattern change: {e}")
return
# Color selection notes 44-51 map to color slots 0-7
if 44 <= msg.note <= 51:
slot_index = msg.note - 44
# Set the selected slot for the currently chosen target (0=Color1, 1=Color2)
self.selected_indices[self.next_selected_target] = slot_index
if callable(self.on_select_palette_indices):
try:
self.on_select_palette_indices(self.selected_indices)
except Exception as _e:
logging.debug(f"Failed to persist selected indices: {_e}")
logging.info(f"Set Color {self.next_selected_target+1} to slot {slot_index+1}")
elif msg.type == 'control_change': elif msg.type == 'control_change':
# Handle control change messages # Handle control change messages
@@ -205,46 +360,26 @@ class MidiController:
value = msg.value value = msg.value
logging.info(f"MIDI CC {control}: {value}") logging.info(f"MIDI CC {control}: {value}")
if control == 30: # Red if control == 33: # Brightness (0-100)
self.color_r = round((value / 127) * 255)
await self.websocket_client.send_message("color_change", {
"r": self.color_r, "g": self.color_g, "b": self.color_b
})
elif control == 31: # Green
self.color_g = round((value / 127) * 255)
await self.websocket_client.send_message("color_change", {
"r": self.color_r, "g": self.color_g, "b": self.color_b
})
elif control == 32: # Blue
self.color_b = round((value / 127) * 255)
await self.websocket_client.send_message("color_change", {
"r": self.color_r, "g": self.color_g, "b": self.color_b
})
elif control == 33: # Brightness
self.brightness = round((value / 127) * 100) self.brightness = round((value / 127) * 100)
await self.websocket_client.send_message("brightness_change", { if callable(self.on_parameters_change):
"brightness": self.brightness self.on_parameters_change({"brightness": self.brightness})
}) elif control == 34: # n1 (0-255)
elif control == 34: # n1
self.n1 = int(value) self.n1 = int(value)
await self.websocket_client.send_message("parameter_change", { if callable(self.on_parameters_change):
"n1": self.n1 self.on_parameters_change({"n1": self.n1})
}) elif control == 35: # n2 (0-255)
elif control == 35: # n2
self.n2 = int(value) self.n2 = int(value)
await self.websocket_client.send_message("parameter_change", { if callable(self.on_parameters_change):
"n2": self.n2 self.on_parameters_change({"n2": self.n2})
}) elif control == 36: # n3 (>=1)
elif control == 36: # n3 self.n3 = max(1, int(value))
self.n3 = max(1, value) if callable(self.on_parameters_change):
await self.websocket_client.send_message("parameter_change", { self.on_parameters_change({"n3": self.n3})
"n3": self.n3 elif control == 37: # Delay (ms)
}) self.delay = int(value) * 4
elif control == 37: # Delay if callable(self.on_parameters_change):
self.delay = value * 4 self.on_parameters_change({"delay": self.delay})
await self.websocket_client.send_message("delay_change", {
"delay": self.delay
})
elif control == 27: # Beat sending toggle elif control == 27: # Beat sending toggle
self.beat_sending_enabled = (value == 127) self.beat_sending_enabled = (value == 127)
await self.websocket_client.send_message("beat_toggle", { await self.websocket_client.send_message("beat_toggle", {
@@ -262,9 +397,13 @@ class UIClient:
"""Main UI client application.""" """Main UI client application."""
def __init__(self): def __init__(self):
# Ensure single instance via lock file
self._lock = SingleInstanceLocker('lighting_controller_ui')
self.root = tk.Tk() self.root = tk.Tk()
self.root.configure(bg=bg_color) self.root.configure(bg=bg_color)
self.root.title("Lighting Controller - UI Client") self.root.title("Lighting Controller - UI Client")
# Restore last window geometry if available
self.load_window_geometry()
# WebSocket client # WebSocket client
self.websocket_client = WebSocketClient(CONTROL_SERVER_URI) self.websocket_client = WebSocketClient(CONTROL_SERVER_URI)
@@ -282,10 +421,31 @@ class UIClient:
self.n1 = 10 self.n1 = 10
self.n2 = 10 self.n2 = 10
self.n3 = 1 self.n3 = 1
# Cache for per-pattern windows
self.pattern_windows = {}
# Color slots (8) and selected slot via MIDI
self.color_slots = []
self.selected_color_slot = None
self._init_color_slots()
self.setup_ui() self.setup_ui()
self.setup_async_tasks() self.setup_async_tasks()
# Hook MIDI controller selection persistence to REST method
try:
self.midi_controller.on_select_palette_indices = self.persist_selected_indices
self.midi_controller.on_parameters_change = self.persist_parameters
except Exception:
pass
# Graceful shutdown on signals
try:
signal.signal(signal.SIGTERM, lambda *_: self.on_closing())
signal.signal(signal.SIGINT, lambda *_: self.on_closing())
except Exception:
pass
atexit.register(self._cleanup_at_exit)
def setup_ui(self): def setup_ui(self):
"""Setup the user interface.""" """Setup the user interface."""
# Configure ttk style # Configure ttk style
@@ -295,9 +455,13 @@ class UIClient:
style.configure("TNotebook", background=bg_color, borderwidth=0) style.configure("TNotebook", background=bg_color, borderwidth=0)
style.configure("TNotebook.Tab", background=bg_color, foreground=fg_color, font=("Arial", 30), padding=[10, 5]) style.configure("TNotebook.Tab", background=bg_color, foreground=fg_color, font=("Arial", 30), padding=[10, 5])
# MIDI Controller Selection # Top bar: MIDI Controller (left) + Selected Colors (right)
midi_frame = ttk.LabelFrame(self.root, text="MIDI Controller") top_bar = ttk.Frame(self.root)
midi_frame.pack(padx=16, pady=8, fill="x") top_bar.pack(padx=16, pady=8, fill="x")
# MIDI Controller Selection (smaller, on the left)
midi_frame = ttk.LabelFrame(top_bar, text="MIDI Controller")
midi_frame.pack(side="left", padx=8)
# MIDI port dropdown # MIDI port dropdown
self.midi_port_var = tk.StringVar() self.midi_port_var = tk.StringVar()
@@ -306,7 +470,7 @@ class UIClient:
textvariable=self.midi_port_var, textvariable=self.midi_port_var,
values=[], values=[],
state="readonly", state="readonly",
font=("Arial", 12) font=("Arial", 11)
) )
midi_dropdown.pack(padx=8, pady=4, fill="x") midi_dropdown.pack(padx=8, pady=4, fill="x")
midi_dropdown.bind("<<ComboboxSelected>>", self.on_midi_port_change) midi_dropdown.bind("<<ComboboxSelected>>", self.on_midi_port_change)
@@ -329,6 +493,27 @@ class UIClient:
) )
self.midi_status_label.pack(padx=8, pady=2) self.midi_status_label.pack(padx=8, pady=2)
# Selected color preview boxes (on the right)
previews_frame = ttk.LabelFrame(top_bar, text="Selected Colors")
previews_frame.pack(side="right", padx=8)
self.color1_preview = tk.Label(previews_frame, text="Color 1", width=14, height=2, bg="#000000", fg="#FFFFFF", font=("Arial", 12), borderwidth=2, relief="ridge")
self.color2_preview = tk.Label(previews_frame, text="Color 2", width=14, height=2, bg="#000000", fg="#FFFFFF", font=("Arial", 12), borderwidth=2, relief="ridge")
self.color1_preview.grid(row=0, column=0, padx=8, pady=8)
self.color2_preview.grid(row=1, column=0, padx=8, pady=8)
# Click to choose which target is set by MIDI
def _set_target_color1(_e=None):
try:
self.midi_controller.next_selected_target = 0
except Exception:
pass
def _set_target_color2(_e=None):
try:
self.midi_controller.next_selected_target = 1
except Exception:
pass
self.color1_preview.bind("<Button-1>", _set_target_color1)
self.color2_preview.bind("<Button-1>", _set_target_color2)
# Controls overview # Controls overview
controls_frame = ttk.Frame(self.root) controls_frame = ttk.Frame(self.root)
controls_frame.pack(padx=16, pady=8, fill="both") controls_frame.pack(padx=16, pady=8, fill="both")
@@ -422,6 +607,11 @@ class UIClient:
) )
lbl.grid(row=1 + (3 - r), column=c, padx=6, pady=6, sticky="nsew") lbl.grid(row=1 + (3 - r), column=c, padx=6, pady=6, sticky="nsew")
self.button1_cells.append(lbl) self.button1_cells.append(lbl)
# Bind clicks to open/focus child window and select pattern
for idx, lbl in enumerate(self.button1_cells):
lbl.bind("<Button-1>", lambda e, i=idx: self.on_pattern_button_click(i))
# (Previews moved to top bar)
# Connection status # Connection status
self.connection_status = tk.Label( self.connection_status = tk.Label(
@@ -437,10 +627,126 @@ class UIClient:
self.root.after(200, self.update_status_labels) self.root.after(200, self.update_status_labels)
self.root.protocol("WM_DELETE_WINDOW", self.on_closing) self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
def open_pattern_window(self, pattern_name: str):
"""Create or focus a child window per pattern with sliders for parameters."""
if pattern_name in self.pattern_windows:
win = self.pattern_windows[pattern_name]
if win.winfo_exists():
try:
win.deiconify()
win.lift()
win.focus_force()
except Exception:
pass
return
win = tk.Toplevel(self.root)
win.title(f"Pattern: {pattern_name}")
win.configure(bg=bg_color)
# Make the child window larger for touch use
try:
win.geometry("900x520")
except Exception:
pass
self.pattern_windows[pattern_name] = win
def on_close():
try:
win.withdraw()
except Exception:
try:
win.destroy()
except Exception:
pass
win.protocol("WM_DELETE_WINDOW", on_close)
frm = ttk.Frame(win)
frm.pack(padx=20, pady=20, fill="both", expand=True)
# Helper to add a labeled slider
def add_slider(row: int, label_text: str, from_value, to_value, initial_value, on_change_cb):
lbl = tk.Label(frm, text=label_text, bg=bg_color, fg=fg_color, font=("Arial", 16))
lbl.grid(row=row, column=0, sticky="w", padx=12, pady=16)
var = tk.IntVar(value=int(initial_value))
s = tk.Scale(
frm,
from_=from_value,
to=to_value,
orient="horizontal",
showvalue=True,
resolution=1,
variable=var,
length=700,
sliderlength=32,
width=26,
bg=bg_color,
fg=fg_color,
highlightthickness=0,
troughcolor=trough_color_brightness,
command=lambda v: on_change_cb(int(float(v))),
)
# Enable click-to-jump behavior on the slider trough
def click_set_value(event, scale=s, vmin=from_value, vmax=to_value):
try:
# Compute fraction across the widget width
width = max(1, scale.winfo_width())
frac = min(1.0, max(0.0, event.x / width))
value = int(round(vmin + frac * (vmax - vmin)))
scale.set(value)
on_change_cb(int(value))
except Exception:
pass
s.bind("<Button-1>", click_set_value, add="+")
s.grid(row=row, column=1, sticky="ew", padx=12, pady=16)
frm.grid_columnconfigure(1, weight=1)
return s
# Sliders: n1, n2, n3, delay
add_slider(0, "n1", 0, 127, self.n1, self._on_change_n1)
add_slider(1, "n2", 0, 127, self.n2, self._on_change_n2)
add_slider(2, "n3", 1, 127, self.n3, self._on_change_n3)
add_slider(3, "delay (ms)", 0, 1000, self.delay, self._on_change_delay)
# Close button row
btn_row = 4
btns = ttk.Frame(win)
btns.pack(fill="x", padx=16, pady=10)
close_btn = tk.Button(btns, text="Close", command=on_close, font=("Arial", 14), padx=16, pady=8)
close_btn.pack(side="right")
try:
win.deiconify(); win.lift()
except Exception:
pass
@async_handler
async def _on_change_n1(self, value: int):
self.n1 = int(value)
self.persist_parameters({"n1": self.n1})
@async_handler
async def _on_change_n2(self, value: int):
self.n2 = int(value)
self.persist_parameters({"n2": self.n2})
@async_handler
async def _on_change_n3(self, value: int):
self.n3 = int(value) if value >= 1 else 1
self.persist_parameters({"n3": self.n3})
@async_handler
async def _on_change_delay(self, value: int):
self.delay = int(value)
self.persist_parameters({"delay": self.delay})
def setup_async_tasks(self): def setup_async_tasks(self):
"""Setup async tasks for WebSocket and MIDI.""" """Setup async tasks for WebSocket and MIDI."""
# Connect to control server # Connect to control server
self.root.after(100, async_handler(self.websocket_client.connect)) self.root.after(100, async_handler(self.websocket_client.connect))
# Start auto-reconnect background task
self.root.after(300, async_handler(self.start_ws_reconnect))
# Fetch color palette shortly after connect
self.root.after(600, async_handler(self.fetch_color_palette))
# Initialize MIDI # Initialize MIDI
self.root.after(200, async_handler(self.initialize_midi)) self.root.after(200, async_handler(self.initialize_midi))
@@ -474,6 +780,14 @@ class UIClient:
self.midi_controller.start_midi_listener() self.midi_controller.start_midi_listener()
) )
@async_handler
async def start_ws_reconnect(self):
if not self.websocket_client.reconnect_task:
self.websocket_client._stop_reconnect = False
self.websocket_client.reconnect_task = asyncio.create_task(
self.websocket_client.auto_reconnect()
)
def refresh_midi_ports(self): def refresh_midi_ports(self):
"""Refresh MIDI ports list.""" """Refresh MIDI ports list."""
old_ports = self.midi_controller.available_ports.copy() old_ports = self.midi_controller.available_ports.copy()
@@ -500,7 +814,7 @@ class UIClient:
self.midi_controller.midi_port_index = self.midi_controller.available_ports.index(selected_port) self.midi_controller.midi_port_index = self.midi_controller.available_ports.index(selected_port)
self.midi_controller.save_midi_preference() self.midi_controller.save_midi_preference()
# Restart MIDI connection # Restart MIDI connection
asyncio.create_task(self.restart_midi()) self.restart_midi()
@async_handler @async_handler
async def restart_midi(self): async def restart_midi(self):
@@ -548,58 +862,407 @@ class UIClient:
# Update buttons # Update buttons
icon_for = { icon_for = {
"pulse": "💥", "flicker": "", "alternating": "↔️", # long names
"n_chase": "🏃", "rainbow": "🌈", "radiate": "🌟", "on": "🟢", "off": "", "flicker": "",
"sequential_pulse": "🔄", "alternating_phase": "", "-": "", "alternating": "↔️", "pulse": "💥", "rainbow": "🌈",
"radiate": "🌟", "segmented_movement": "🔀",
# short codes used by led-bar
"o": "", "f": "", "a": "↔️", "p": "💥",
"r": "🌈", "rd": "🌟", "sm": "🔀",
"-": "",
} }
bank1_patterns = [ bank1_patterns = self.get_bank1_patterns()
"pulse", "sequential_pulse", "alternating", "alternating_phase",
"n_chase", "rainbow", "flicker", "radiate",
"-", "-", "-", "-", "-", "-", "-", "-",
]
# Display names for UI (with line breaks for better display) # Display names for UI (with line breaks for better display)
display_names = { display_names = {
"pulse": "pulse", # long
"sequential_pulse": "sequential\npulse", "on": "on",
"alternating": "alternating", "off": "off",
"alternating_phase": "alternating\nphase",
"n_chase": "n chase",
"rainbow": "rainbow",
"flicker": "flicker", "flicker": "flicker",
"alternating": "alternating",
"pulse": "pulse",
"rainbow": "rainbow",
"radiate": "radiate", "radiate": "radiate",
"segmented_movement": "segmented\nmovement",
# short
"o": "off",
"f": "flicker",
"a": "alternating",
"p": "pulse",
"r": "rainbow",
"rd": "radiate",
"sm": "segmented\nmovement",
} }
current_pattern = self.midi_controller.current_pattern # Normalize current pattern for highlight (map short codes to long names)
current_raw = self.midi_controller.current_pattern or self.current_pattern
short_to_long = {
"o": "off", "f": "flicker", "a": "alternating", "p": "pulse",
"r": "rainbow", "rd": "radiate", "sm": "segmented_movement",
}
current_pattern = short_to_long.get(current_raw, current_raw)
for idx, lbl in enumerate(self.button1_cells): for idx, lbl in enumerate(self.button1_cells):
pattern_name = bank1_patterns[idx] pattern_name = bank1_patterns[idx]
is_selected = (current_pattern == pattern_name and pattern_name != "-") is_selected = (current_pattern == pattern_name and pattern_name != "-")
display_name = display_names.get(pattern_name, pattern_name) display_name = display_names.get(pattern_name, pattern_name)
icon = icon_for.get(pattern_name, "") icon = icon_for.get(pattern_name, icon_for.get(current_raw, ""))
text = f"{icon} {display_name}" if pattern_name != "-" else "" text = f"{icon} {display_name}" if pattern_name != "-" else ""
if is_selected: if is_selected:
lbl.config(text=text, bg=highlight_pattern_color) lbl.config(text=text, bg=highlight_pattern_color)
else: else:
lbl.config(text=text, bg=bg_color) lbl.config(text=text, bg=bg_color)
# Render color cells in indices 8..15
if self.color_slots and len(self.button1_cells) >= 16:
for color_idx in range(8):
cell_index = 8 + color_idx
lbl = self.button1_cells[cell_index]
r, g, b = self.color_slots[color_idx]
hex_color = self._rgb_to_hex(r, g, b)
text_color = "#000000" if (r*0.299 + g*0.587 + b*0.114) > 186 else "#FFFFFF"
# Indicate if this slot is currently assigned to color1 or color2
assigned = "1" if (hasattr(self.midi_controller, 'selected_indices') and self.midi_controller.selected_indices and self.midi_controller.selected_indices[0] == color_idx) else ("2" if (hasattr(self.midi_controller, 'selected_indices') and len(self.midi_controller.selected_indices) > 1 and self.midi_controller.selected_indices[1] == color_idx) else "")
label_text = f"C{color_idx+1}{' ('+assigned+')' if assigned else ''}"
lbl.config(
text=label_text,
bg=hex_color,
fg=text_color,
)
# Update selected color preview boxes
try:
if hasattr(self, 'color1_preview') and hasattr(self, 'color2_preview'):
if hasattr(self.midi_controller, 'selected_indices') and self.color_slots:
idx1 = self.midi_controller.selected_indices[0] if len(self.midi_controller.selected_indices) > 0 else 0
idx2 = self.midi_controller.selected_indices[1] if len(self.midi_controller.selected_indices) > 1 else 1
r1, g1, b1 = self.color_slots[idx1]
r2, g2, b2 = self.color_slots[idx2]
# Update preview colors
self.color1_preview.configure(bg=self._rgb_to_hex(r1, g1, b1), fg=("#000000" if (r1*0.299+g1*0.587+b1*0.114) > 186 else "#FFFFFF"))
self.color2_preview.configure(bg=self._rgb_to_hex(r2, g2, b2), fg=("#000000" if (r2*0.299+g2*0.587+b2*0.114) > 186 else "#FFFFFF"))
# Indicate which color will be set next by MIDI (toggle target)
next_target = getattr(self.midi_controller, 'next_selected_target', 0)
if next_target == 0:
# Next sets Color 1
self.color1_preview.configure(text="Color 1 (next)", borderwidth=3, relief="solid")
self.color2_preview.configure(text="Color 2", borderwidth=2, relief="ridge")
else:
# Next sets Color 2
self.color1_preview.configure(text="Color 1", borderwidth=2, relief="ridge")
self.color2_preview.configure(text="Color 2 (next)", borderwidth=3, relief="solid")
except Exception:
pass
# Render color cells in indices 8..15
if self.color_slots and len(self.button1_cells) >= 16:
for color_idx in range(8):
cell_index = 8 + color_idx
lbl = self.button1_cells[cell_index]
r, g, b = self.color_slots[color_idx]
hex_color = self._rgb_to_hex(r, g, b)
text_color = "#000000" if (r*0.299 + g*0.587 + b*0.114) > 186 else "#FFFFFF"
is_selected_color = (self.selected_color_slot == color_idx)
lbl.config(
text=f"C{color_idx+1}",
bg=hex_color,
fg=text_color,
borderwidth=4 if is_selected_color else 2,
relief="solid" if is_selected_color else "ridge",
)
# Reschedule # Reschedule
self.root.after(200, self.update_status_labels) self.root.after(200, self.update_status_labels)
@async_handler
async def fetch_color_palette(self):
"""Request color palette from server and hydrate UI state."""
try:
req = urllib.request.Request(PALETTE_API_BASE, method='GET')
with urllib.request.urlopen(req, timeout=2.0) as resp:
data = json.loads(resp.read().decode('utf-8'))
palette = data.get("palette")
selected_indices = data.get("selected_indices")
if isinstance(palette, list) and len(palette) == 8:
new_slots = []
for c in palette:
r = int(c.get("r", 0)); g = int(c.get("g", 0)); b = int(c.get("b", 0))
r = max(0, min(255, r)); g = max(0, min(255, g)); b = max(0, min(255, b))
new_slots.append((r, g, b))
self.color_slots = new_slots
if isinstance(selected_indices, list) and len(selected_indices) == 2:
try:
self.midi_controller.selected_indices = [int(selected_indices[0]), int(selected_indices[1])]
except Exception:
pass
except Exception as e:
logging.debug(f"Failed to fetch color palette (REST): {e}")
@async_handler
async def persist_palette(self):
"""POST current palette to server via REST."""
try:
payload = {
"palette": [{"r": c[0], "g": c[1], "b": c[2]} for c in self.color_slots]
}
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(PALETTE_API_BASE, data=data, method='POST', headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=2.0) as resp:
_ = resp.read()
except Exception as e:
logging.debug(f"Failed to persist palette (REST): {e}")
@async_handler
async def persist_selected_indices(self, indices: list[int]):
"""POST selected indices via REST."""
try:
payload = {"selected_indices": [int(indices[0]), int(indices[1])]} if len(indices) == 2 else None
if not payload:
return
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(PALETTE_API_BASE, data=data, method='POST', headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=2.0) as resp:
_ = resp.read()
except Exception as e:
logging.debug(f"Failed to persist selected indices (REST): {e}")
@async_handler
async def persist_parameters(self, params: dict):
"""POST parameter changes via REST to /api/parameters."""
try:
data = json.dumps(params).encode('utf-8')
req = urllib.request.Request(f"{HTTP_BASE}/api/parameters", data=data, method='POST', headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=2.0) as resp:
_ = resp.read()
except Exception as e:
logging.debug(f"Failed to persist parameters (REST): {e}")
def on_closing(self): def on_closing(self):
"""Handle application closing.""" """Handle application closing."""
logging.info("Closing UI client...") logging.info("Closing UI client...")
# Persist window geometry
self.save_window_geometry()
if self.midi_controller.midi_task: if self.midi_controller.midi_task:
self.midi_controller.midi_task.cancel() self.midi_controller.midi_task.cancel()
self.midi_controller.close() self.midi_controller.close()
try:
if self.websocket_client and self.websocket_client.reconnect_task:
self.websocket_client._stop_reconnect = True
self.websocket_client.reconnect_task.cancel()
except Exception:
pass
asyncio.create_task(self.websocket_client.close()) asyncio.create_task(self.websocket_client.close())
self.root.destroy() self.root.destroy()
# Release lock
try:
self._lock.release()
except Exception:
pass
def run(self): def run(self):
"""Run the UI client.""" """Run the UI client."""
async_mainloop(self.root) async_mainloop(self.root)
def _cleanup_at_exit(self):
try:
self._lock.release()
except Exception:
pass
def get_bank1_patterns(self):
return [
"on", "off", "flicker", "alternating",
"pulse", "rainbow", "radiate", "segmented_movement",
"-", "-", "-", "-", "-", "-", "-", "-",
]
def on_pattern_button_click(self, index: int):
patterns = self.get_bank1_patterns()
if 0 <= index < len(patterns):
pattern = patterns[index]
if index < 8 and pattern != "-":
# Send selection and open the window
self.select_pattern(pattern)
self.open_pattern_window(pattern)
elif index >= 8:
# Open color editor window for the slot (no selection change)
slot_index = index - 8
self.open_color_window(slot_index)
@async_handler
async def select_pattern(self, pattern: str):
try:
self.current_pattern = pattern
# Use REST API per api.md
try:
data = json.dumps({"pattern": pattern}).encode('utf-8')
req = urllib.request.Request(f"{HTTP_BASE}/api/pattern", data=data, method='POST', headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=2.0) as resp:
_ = resp.read()
except Exception as e:
logging.debug(f"Pattern REST update failed: {e}")
except Exception as e:
logging.debug(f"Failed to select pattern {pattern}: {e}")
def _init_color_slots(self):
if not self.color_slots:
self.color_slots = [
(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0),
(255, 0, 255), (0, 255, 255), (255, 255, 255), (128, 128, 128),
]
def _rgb_to_hex(self, r, g, b):
r = max(0, min(255, int(r)))
g = max(0, min(255, int(g)))
b = max(0, min(255, int(b)))
return f"#{r:02x}{g:02x}{b:02x}"
def open_color_window(self, slot_index: int):
key = f"color_slot_{slot_index}"
if key in self.pattern_windows:
win = self.pattern_windows[key]
if win.winfo_exists():
try:
win.deiconify(); win.lift(); win.focus_force()
except Exception:
pass
return
win = tk.Toplevel(self.root)
win.title(f"Color Slot {slot_index+1}")
win.configure(bg=bg_color)
try:
win.geometry("700x360")
except Exception:
pass
self.pattern_windows[key] = win
def on_close():
try:
win.withdraw()
except Exception:
try:
win.destroy()
except Exception:
pass
win.protocol("WM_DELETE_WINDOW", on_close)
frm = ttk.Frame(win)
frm.pack(padx=20, pady=20, fill="both", expand=True)
r, g, b = self.color_slots[slot_index]
def add_rgb_slider(row, label, initial, on_change_cb):
lbl = tk.Label(frm, text=label, bg=bg_color, fg=fg_color, font=("Arial", 16))
lbl.grid(row=row, column=0, sticky="w", padx=12, pady=16)
var = tk.IntVar(value=int(initial))
s = tk.Scale(frm, from_=0, to=255, orient="horizontal", showvalue=True, resolution=1,
variable=var, length=560, sliderlength=28, width=20, bg=bg_color, fg=fg_color,
highlightthickness=0, troughcolor=trough_color_brightness,
command=lambda v: on_change_cb(int(float(v))))
s.grid(row=row, column=1, sticky="ew", padx=12, pady=16)
frm.grid_columnconfigure(1, weight=1)
return s
def on_r(val):
self._update_color_slot(slot_index, r=val)
def on_g(val):
self._update_color_slot(slot_index, g=val)
def on_b(val):
self._update_color_slot(slot_index, b=val)
add_rgb_slider(0, "Red", r, on_r)
add_rgb_slider(1, "Green", g, on_g)
add_rgb_slider(2, "Blue", b, on_b)
# Preview acts as a confirm/select button for this color slot
def on_preview_click():
self.selected_color_slot = slot_index
rr, gg, bb = self.color_slots[slot_index]
# Persist selection via REST (first index is used for pattern color per api.md)
asyncio.create_task(self.persist_selected_indices([slot_index, 1]))
# For immediate LED update, optional: send parameters API if required. Keeping color_change for now is removed per REST-only guidance.
on_close()
preview = tk.Button(
frm,
text="Use This Color",
font=("Arial", 14),
bg=self._rgb_to_hex(r, g, b),
fg="#000000",
width=16,
height=2,
command=on_preview_click,
)
preview.grid(row=3, column=0, columnspan=2, pady=12)
# Close button row
btns = ttk.Frame(win)
btns.pack(fill="x", padx=16, pady=10)
close_btn = tk.Button(btns, text="Close", command=on_close, font=("Arial", 14), padx=16, pady=8)
close_btn.pack(side="right")
def refresh_preview():
rr, gg, bb = self.color_slots[slot_index]
preview.configure(bg=self._rgb_to_hex(rr, gg, bb),
fg="#000000" if (rr*0.299+gg*0.587+bb*0.114) > 186 else "#FFFFFF")
self.root.after(200, refresh_preview)
refresh_preview()
def _update_color_slot(self, slot_index, r=None, g=None, b=None):
cr, cg, cb = self.color_slots[slot_index]
if r is not None: cr = int(r)
if g is not None: cg = int(g)
if b is not None: cb = int(b)
self.color_slots[slot_index] = (cr, cg, cb)
# Update palette colors on backend via REST (no immediate output)
asyncio.create_task(self.persist_palette())
# Do not send color_change here; only send when user confirms via preview button
# ----------------------
# Window geometry persist
# ----------------------
def load_window_geometry(self):
"""Load last saved window geometry from config and apply it."""
try:
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r') as f:
cfg = json.load(f)
geom = cfg.get('window_geometry')
if isinstance(geom, dict):
x = geom.get('x')
y = geom.get('y')
w = geom.get('w')
h = geom.get('h')
if all(isinstance(v, int) for v in (x, y, w, h)) and w > 0 and h > 0:
self.root.geometry(f"{w}x{h}+{x}+{y}")
except Exception as e:
logging.debug(f"Failed to load window geometry: {e}")
def save_window_geometry(self):
"""Save current window geometry to config."""
try:
# Get current position and size
self.root.update_idletasks()
x = self.root.winfo_x()
y = self.root.winfo_y()
w = self.root.winfo_width()
h = self.root.winfo_height()
cfg = {}
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, 'r') as f:
cfg = json.load(f) or {}
except Exception:
cfg = {}
cfg['window_geometry'] = {'x': int(x), 'y': int(y), 'w': int(w), 'h': int(h)}
with open(CONFIG_FILE, 'w') as f:
json.dump(cfg, f, indent=2)
except Exception as e:
logging.debug(f"Failed to save window geometry: {e}")
if __name__ == "__main__": if __name__ == "__main__":
app = UIClient() app = UIClient()

View File

@@ -18,6 +18,27 @@ import asyncio
import json import json
import websockets import websockets
import re 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): def build_messages(args):
@@ -77,7 +98,9 @@ async def run_test(uri: str, messages: list[dict], sleep_s: float):
def parse_args(): def parse_args():
p = argparse.ArgumentParser(description="Send UI commands to control_server WebSocket") p = argparse.ArgumentParser(description="Send UI commands to control_server WebSocket")
p.add_argument("--uri", default="ws://localhost:8765", help="WebSocket URI (default ws://localhost:8765)") 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("--pattern", help="Pattern name for pattern_change")
p.add_argument("--r", type=int, help="Red 0-255 for color_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("--g", type=int, help="Green 0-255 for color_change")