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:
751
api.md
Normal file
751
api.md
Normal 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
405
colorpallet.md
Normal 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]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
```
|
821
src/ui_client.py
821
src/ui_client.py
@@ -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()
|
||||||
|
@@ -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")
|
||||||
|
Reference in New Issue
Block a user