18 Commits
full ... web

Author SHA1 Message Date
90be198483 Add presets system and convert back to Flask
- Convert from Microdot back to Flask
- Add presets system with CRUD operations
- Store presets in presets.json file
- Replace patterns section with presets grid
- Add preset editor with full configuration
- Add collapse/expand functionality to left panel
- Always show on/off presets in presets list
- Highlight active preset matching current tab settings
- Add 'Create from Current' button in preset editor
2026-01-08 21:45:55 +13:00
ce3b9f4ea5 Add profile deletion feature
- Added DELETE endpoint /api/profiles/<profile_name> to delete profiles
- Prevent deletion of the only remaining profile
- Clear current profile state if the active profile is deleted
- Added Delete button next to each profile in the Profiles modal
- Added confirmation dialog before deleting profiles
- Automatically refresh profile list after deletion
2026-01-05 23:09:10 +13:00
40cfe19759 Add profile color palette feature with quick-select modal
- Added per-profile color palette storage in profile JSON files
- Created Color Palette modal for managing profile colors
- Added quick-select modal window when clicking color pickers
- Implemented palette color selection to apply to active tab colors
- Added 'Use Color Picker' button in quick palette modal
- Fixed pattern selection to properly update UI
- Improved color picker interaction to prevent conflicts between quick palette and native picker
2026-01-05 22:42:58 +13:00
c97ca308a7 Add profile persistence for color changes and data saving
- Added save_current_profile() function to persist lights data to profile files
- Updated all endpoints to save to profile files after changes
- Ensures color changes, pattern changes, and tab modifications are persisted
- Data now saves to both settings.json (patterns) and profile files (lights data)
2026-01-04 16:07:54 +13:00
5aa500a7fb Convert app to Flask web application with color pickers
- Created Flask backend with REST API endpoints
- Built HTML/CSS/JavaScript frontend
- Replaced RGB sliders with color pickers for each palette color
- Reorganized layout: color palette on left, patterns on right
- Added persistence for color changes
- Integrated WebSocket client for lighting controller communication
- Added tab management, profile support, and pattern selection
2026-01-04 15:59:19 +13:00
c8ae113355 Remove associated names label and always show n parameter inputs 2025-11-30 17:23:32 +13:00
2db2d9e120 Fix bottom menu buttons visibility by adjusting packing order 2025-11-30 17:07:21 +13:00
42575b9d2e Fix profile loading to not modify settings.json, preserve patterns 2025-11-30 17:03:43 +13:00
517750e5f6 Add patterns configuration to settings.json 2025-11-30 16:52:09 +13:00
5e4798a9dc Remove scrolling and fix empty space, restore patterns to settings.json 2025-11-30 16:49:54 +13:00
fb4944e475 Add screen resolution scaling and move tab buttons to bottom 2025-11-30 16:44:14 +13:00
c5a76c24a7 Move patterns to settings.json and remove patterns.json 2025-11-30 16:31:22 +13:00
ce8596ca58 Add tab management, profiles, and pattern-specific delay ranges 2025-11-30 16:23:08 +13:00
92526ab05c Move patterns to separate patterns.json file 2025-11-30 14:43:22 +13:00
8dabf852ba Show descriptive names for n parameters based on selected pattern 2025-11-30 13:27:35 +13:00
e803dd4243 Update main 2025-11-21 16:18:08 +13:00
baf3d0b0ff Update settings 2025-11-21 16:17:42 +13:00
7f43b93cb7 Update UI: shorter sliders, add n1-n6 inputs, logarithmic delay scale 2025-11-19 23:05:51 +13:00
25 changed files with 10618 additions and 2815 deletions

View File

@@ -12,6 +12,7 @@ python-rtmidi = "*"
pyaudio = "*"
aubio = "*"
websocket-client = "*"
microdot = "*"
[dev-packages]
@@ -19,8 +20,6 @@ websocket-client = "*"
python_version = "3.12"
[scripts]
ui = "python src/ui_client.py"
control = "python src/control_server.py"
sound = "python src/sound.py"
dev-ui = 'watchfiles "python src/ui_client.py" src'
dev-control = 'watchfiles "python src/control_server.py" src'
main = "python src/main.py"
dev = 'watchfiles "python src/main.py" src'
web = "python run_web.py"

View File

@@ -1,296 +0,0 @@
# Lighting Controller - Separated Architecture
This version of the lighting controller separates the UI and control logic, communicating via WebSocket. The MIDI controller is now integrated with the UI client.
## Architecture Overview
```
┌─────────────────┐ WebSocket ┌─────────────────┐ WebSocket ┌─────────────────┐
│ UI Client │◄─────────────────►│ Control Server │◄─────────────────►│ LED Server │
│ │ │ │ │ │
│ - MIDI Input │ │ - Lighting Logic│ │ - LED Bars │
│ - User Interface│ │ - Pattern Logic │ │ - ESP-NOW │
│ - Status Display│ │ - Beat Handling │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
│ │ TCP
│ ▼
│ ┌─────────────────┐
│ │ Sound Detector │
│ │ │
│ │ - Audio Input │
│ │ - Beat Detection│
│ │ - BPM Analysis │
│ └─────────────────┘
│ MIDI
┌─────────────────┐
│ MIDI Controller │
│ │
│ - Knobs/Dials │
│ - Buttons │
│ - Pattern Select│
└─────────────────┘
```
## Components
### 1. UI Client (`src/ui_client.py`)
- **Purpose**: User interface and MIDI controller integration
- **Features**:
- MIDI controller input handling
- Real-time status display
- Pattern selection visualization
- Connection status monitoring
- **Communication**: WebSocket client to control server
### 2. Control Server (`src/control_server.py`)
- **Purpose**: Core lighting control logic
- **Features**:
- Pattern execution
- Beat synchronization
- Parameter management
- LED bar communication
- **Communication**:
- WebSocket server for UI clients
- TCP server for sound detector
- WebSocket client to LED server
### 3. Sound Detector (`src/sound.py`)
- **Purpose**: Audio beat detection and BPM analysis
- **Features**:
- Real-time audio processing
- Beat detection
- BPM calculation
- Tempo reset functionality
- **Communication**: TCP client to control server
## WebSocket Protocol
### UI Client → Control Server Messages
```json
{
"type": "pattern_change",
"data": {
"pattern": "pulse"
}
}
```
```json
{
"type": "color_change",
"data": {
"r": 255,
"g": 0,
"b": 0
}
}
```
```json
{
"type": "brightness_change",
"data": {
"brightness": 80
}
}
```
```json
{
"type": "parameter_change",
"data": {
"n1": 15,
"n2": 20
}
}
```
```json
{
"type": "delay_change",
"data": {
"delay": 150
}
}
```
```json
{
"type": "beat_toggle",
"data": {
"enabled": true
}
}
```
```json
{
"type": "reset_tempo",
"data": {}
}
```
## Running the System
### Option 1: Use the startup script (Recommended)
```bash
python start_lighting_controller.py
```
### Option 2: Start components individually
1. **Start Control Server**:
```bash
pipenv run control
# or
python src/control_server.py
```
2. **Start Sound Detector** (in another terminal):
```bash
pipenv run sound
# or
python src/sound.py
```
3. **Start UI Client** (in another terminal):
```bash
pipenv run ui
# or
python src/ui_client.py
```
### Option 3: Development mode with auto-reload
```bash
# Terminal 1 - Control Server
pipenv run dev-control
# Terminal 2 - Sound Detector
pipenv run sound
# Terminal 3 - UI Client
pipenv run dev-ui
```
## Configuration
### MIDI Controller
- MIDI device preferences are saved in `config.json`
- The UI client automatically detects and connects to MIDI devices
- Use the dropdown to select different MIDI ports
### Network Settings
- **Control Server**: `localhost:8765` (WebSocket)
- **Sound Detector**: `127.0.0.1:65432` (TCP)
- **LED Server**: `192.168.4.1:80/ws` (WebSocket)
### Audio Settings
- Audio input device index: 7 (modify in `src/sound.py`)
- Buffer size: 512 samples
- Sample rate: Auto-detected from device
## MIDI Controller Mapping
### Buttons (Notes 36-51)
- **Row 1**: Pulse, Sequential Pulse
- **Row 2**: Alternating, Alternating Phase
- **Row 3**: N Chase, Rainbow
- **Row 4**: Flicker, Radiate
### Dials (CC30-37)
- **CC30**: Red (0-255)
- **CC31**: Green (0-255)
- **CC32**: Blue (0-255)
- **CC33**: Brightness (0-100)
- **CC34**: N1 parameter
- **CC35**: N2 parameter
- **CC36**: N3 parameter
- **CC37**: Delay (0-508ms)
### Additional Knobs (CC38-45)
- **CC38**: Pulse N1
- **CC39**: Pulse N2
- **CC40**: Alternating N1
- **CC41**: Alternating N2
- **CC42**: Radiate N1
- **CC43**: Radiate Delay
- **CC44**: Knob 7
- **CC45**: Knob 8
### Control Buttons
- **CC27**: Beat sending toggle (127=on, 0=off)
- **CC29**: Reset tempo detection
## Troubleshooting
### Connection Issues
1. **UI Client can't connect to Control Server**:
- Ensure control server is running first
- Check firewall settings
- Verify port 8765 is available
2. **Control Server can't connect to LED Server**:
- Check LED server IP address (192.168.4.1)
- Verify LED server is running
- Check network connectivity
3. **Sound Detector can't connect to Control Server**:
- Ensure control server is running
- Check TCP port 65432 is available
### MIDI Issues
1. **No MIDI devices detected**:
- Check MIDI controller connection
- Install MIDI drivers if needed
- Use "Refresh MIDI Ports" button
2. **MIDI input not working**:
- Verify correct MIDI port is selected
- Check MIDI controller is sending data
- Look for error messages in console
### Performance Issues
1. **High CPU usage**:
- Reduce audio buffer size in sound.py
- Increase parameter update interval
- Check for network latency
2. **Audio dropouts**:
- Increase audio buffer size
- Check audio device settings
- Reduce system load
## Development
### Adding New Patterns
1. Add pattern name to `PATTERN_NAMES` in `control_server.py`
2. Implement pattern logic in `LightingController` class
3. Add pattern to MIDI button mapping in `ui_client.py`
### Adding New MIDI Controls
1. Add control change handler in `MidiController.handle_midi_message()`
2. Add corresponding WebSocket message type
3. Implement handler in `LightingController.handle_ui_command()`
### Modifying UI
- Edit `src/ui_client.py` for UI changes
- Use `pipenv run dev-ui` for auto-reload during development
- UI uses tkinter with dark theme
## Migration from Monolithic Version
The separated architecture maintains compatibility with:
- Existing MIDI controller mappings
- LED bar communication protocol
- Sound detection functionality
- Configuration files
Key differences:
- MIDI controller is now part of UI client
- Control logic is isolated in control server
- Communication via WebSocket instead of direct function calls
- Better separation of concerns and modularity

41
presets.json Normal file
View File

@@ -0,0 +1,41 @@
{
"blinker": {
"pattern": "blink",
"colors": [
"#12b533"
],
"brightness": 127,
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"circler": {
"pattern": "circle",
"colors": [
"#9d3434",
"#cb5d5d"
],
"brightness": 127,
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulser": {
"pattern": "pulse",
"colors": [
"#9f1d1d",
"#176d2d",
"#50309c"
],
"brightness": 127,
"delay": 300,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}

157
profiles/default.json Normal file
View File

@@ -0,0 +1,157 @@
{
"tab_password": "",
"color_palette": [
"#c12525",
"#246dcc"
],
"lights": {
"test": {
"names": [
"test"
],
"settings": {
"pattern": "pulse",
"brightness": 127,
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"patterns": {
"on": {
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"rainbow": {
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"transition": {
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"blink": {
"colors": [
"#12b533"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"circle": {
"colors": [
"#9d3434",
"#cb5d5d"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulse": {
"colors": [
"#9f1d1d",
"#176d2d",
"#50309c"
],
"delay": 300,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"test2": {
"names": [
"test"
],
"settings": {
"pattern": "pulse",
"brightness": 127,
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"patterns": {
"blink": {
"colors": [
"#12b533"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"circle": {
"colors": [
"#9d3434",
"#cb5d5d"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulse": {
"colors": [
"#9f1d1d",
"#176d2d",
"#50309c"
],
"delay": 300,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
}
},
"tab_order": [
"test",
"test2"
]
}

26
profiles/ring.json Normal file
View File

@@ -0,0 +1,26 @@
{
"tab_password": "",
"lights": {
"dsfdfd": {
"names": [
"1"
],
"settings": {
"pattern": "on",
"brightness": 127,
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"patterns": {}
}
}
},
"tab_order": [
"dsfdfd"
]
}

6
profiles/test.json Normal file
View File

@@ -0,0 +1,6 @@
{
"lights": {},
"tab_password": "",
"tab_order": [],
"color_palette": []
}

868
profiles/tt.json Normal file
View File

@@ -0,0 +1,868 @@
{
"tab_password": "qwerty1234",
"lights": {
"sign": {
"names": [
"tt-sign",
"1"
],
"settings": {
"colors": [
"#968a00"
],
"brightness": 39,
"pattern": "circle",
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"patterns": {
"pulse": {
"colors": [
"#ff00ff"
],
"delay": 657,
"n1": 100,
"n2": 10,
"n3": 100,
"n4": 10,
"n5": 10,
"n6": 10
},
"n_chase": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"on": {
"colors": [
"#ff00ff"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"rainbow": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"off": {
"colors": [
"#0000ff",
"#ff0000"
],
"delay": 10000,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"blink": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"transition": {
"colors": [
"#ff00ff",
"#ffff00"
],
"delay": 10000,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"circle": {
"colors": [
"#0000ff",
"#ff0000"
],
"delay": 10000,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"chase": {
"colors": [
"#000091",
"#00d800"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"dj": {
"names": [
"dj"
],
"settings": {
"colors": [
"#0000ff",
"#ff0000"
],
"brightness": 39,
"pattern": "transition",
"delay": 10000,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"patterns": {
"rainbow": {
"colors": [
"#00006a"
],
"delay": 17,
"n1": 1,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"on": {
"colors": [
"#ff0062",
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"blink": {
"colors": [
"#0000d0"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"pulse": {
"delay": 1002,
"colors": [
"#006600",
"#0000ff"
],
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"transition": {
"colors": [
"#0000ff",
"#ff0000"
],
"delay": 10000,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"n_chase": {
"n1": 11,
"n2": 13,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"delay": 639,
"colors": [
"#0000ff"
]
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"circle": {
"colors": [
"#0001bd",
"#00ff00"
],
"delay": 1778,
"n1": 20,
"n2": 40,
"n3": 40,
"n4": 0
},
"chase": {
"colors": [
"#8d00ff",
"#ff0077"
],
"delay": 69,
"n1": 30,
"n2": 30,
"n3": 5,
"n4": 30
}
}
}
},
"middle": {
"names": [
"middle1",
"middle2",
"middle3",
"middle4"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 44,
"pattern": "on",
"delay": 520,
"patterns": {
"flicker": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"on": {
"colors": [
"#ff00ff"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"sides": {
"names": [
"left",
"right"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 13,
"pattern": "on",
"delay": 520,
"patterns": {
"on": {
"colors": [
"#ff00ff"
],
"delay": 988,
"n1": 100,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulse": {
"n1": 100,
"n2": 100,
"n3": 100,
"n4": 10,
"delay": 411,
"colors": [
"#ff00ff"
]
}
}
}
},
"outside": {
"names": [
"outside"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 76,
"pattern": "on",
"delay": 520,
"n1": -17,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"patterns": {
"on": {
"colors": [
"#ff00ff"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"transition": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"middle1": {
"names": [
"middle1"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 59,
"pattern": "on",
"delay": 520,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"patterns": {
"flicker": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"on": {
"colors": [
"#ff00ff"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulse": {
"delay": 1096,
"colors": [
"#0000ff"
],
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"rainbow": {
"n1": 1,
"n2": 10,
"n3": 10,
"n4": 10,
"delay": 2884,
"colors": [
"#000000"
]
},
"transition": {
"colors": [
"#0000ff",
"#ff0000"
],
"delay": 269,
"n1": 5,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"middle2": {
"names": [
"middle2"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 141,
"pattern": "on",
"delay": 520,
"patterns": {
"flicker": {
"colors": [
"#000078"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"on": {
"colors": [
"#ff00ff"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulse": {
"colors": [
"#0000a0",
"#720000"
],
"delay": 4102,
"n1": 100,
"n2": 10,
"n3": 100,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"middle3": {
"names": [
"middle3"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "on",
"delay": 520,
"patterns": {
"flicker": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"on": {
"colors": [
"#00c4a5"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"middle4": {
"names": [
"middle4"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "on",
"delay": 520,
"patterns": {
"flicker": {
"colors": [
"#ff00d6"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"on": {
"colors": [
"#ff00ff"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"front1": {
"names": [
"front1"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 255,
"pattern": "on",
"delay": 520,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"patterns": {
"on": {
"colors": [
"#ff00ff",
"#0000ff"
],
"delay": 2409,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulse": {
"colors": [
"#000090"
],
"delay": 1051,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"transition": {
"colors": [
"#ff0000",
"#0000ff"
],
"delay": 2564,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"front2": {
"names": [
"front2"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 255,
"pattern": "off",
"delay": 520,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"patterns": {
"on": {
"colors": [
"#ff00ff"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"rainbow": {
"colors": [
"#00006b"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"transition": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"front3": {
"names": [
"front3"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 29,
"pattern": "on",
"delay": 520,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"patterns": {
"on": {
"colors": [
"#d200d1"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"rainbow": {
"colors": [
"#00006b"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"transition": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
}
},
"tab_order": [
"sign",
"dj",
"middle",
"sides",
"outside",
"middle1",
"middle2",
"middle3",
"middle4",
"front1",
"front2",
"front3"
],
"color_palette": [
"#c33232",
"#3237c3"
]
}

17
run_web.py Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""
Startup script for the Flask web application.
"""
import sys
import os
# Add src directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
from flask_app import app
if __name__ == '__main__':
print("Starting Lighting Controller Web App with Flask...")
print("Open http://localhost:5000 in your browser")
app.run(host='0.0.0.0', port=5000, debug=True)

View File

@@ -1,162 +1,54 @@
{
"lights": {
"sign": {
"names": [
"tt-sign",
"1"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00"
],
"brightness": 9,
"pattern": "off",
"delay": 50
}
"tab_password": "",
"current_profile": "default",
"patterns": {
"on": {
"min_delay": 10,
"max_delay": 10000
},
"dj": {
"names": [
"dj"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
"off": {
"min_delay": 10,
"max_delay": 10000
},
"middle": {
"names": [
"middle1",
"middle2",
"middle3",
"middle4"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
"rainbow": {
"Step Rate": "n1",
"min_delay": 10,
"max_delay": 10000
},
"sides": {
"names": [
"left",
"right"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "on",
"delay": 520
}
"transition": {
"min_delay": 10,
"max_delay": 10000
},
"outside": {
"names": [
"outside"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "on",
"delay": 520
}
"chase": {
"Colour 1 Length": "n1",
"Colour 2 Length": "n2",
"Step 1": "n3",
"Step 2": "n4",
"min_delay": 10,
"max_delay": 10000
},
"middle1": {
"names": [
"middle1"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
"pulse": {
"Attack": "n1",
"Hold": "n2",
"Decay": "n3",
"min_delay": 10,
"max_delay": 10000
},
"middle2": {
"names": [
"middle2"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
"circle": {
"Head Rate": "n1",
"Max Length": "n2",
"Tail Rate": "n3",
"Min Length": "n4",
"min_delay": 10,
"max_delay": 10000
},
"middle3": {
"names": [
"middle3"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
},
"middle4": {
"names": [
"middle4"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
"blink": {
"min_delay": 10,
"max_delay": 10000
}
},
"patterns": [
"on",
"off",
"blink",
"rainbow_cycle",
"color_transition",
"theater_chase",
"flicker",
"pulse"
"color_palette": [
"#c12525",
"#246dcc"
]
}

View File

@@ -1,38 +0,0 @@
# LED Bar Configuration
# Modify these names as needed for your setup
# LED Bar Names/IDs - 4 left bars + 4 right bars
LED_BAR_NAMES = [
"100", # Left Bar 1
"101", # Left Bar 2
"102", # Left Bar 3
"103", # Left Bar 4
"104", # Right Bar 1
"105", # Right Bar 2
"106", # Right Bar 3
"107", # Right Bar 4
]
# Left and right bar groups for spatial control
LEFT_BARS = ["100", "101", "102", "103"]
RIGHT_BARS = ["104", "105", "106", "107"]
# Number of LED bars
NUM_BARS = len(LED_BAR_NAMES)
# Default settings for all bars
DEFAULT_BAR_SETTINGS = {
"pattern": "pulse",
"delay": 100,
"colors": [(0, 255, 0)], # Default green
"brightness": 10,
"num_leds": 200,
"n1": 10,
"n2": 10,
"n3": 1,
"n": 0,
}
# ESP-NOW broadcast settings
ESP_NOW_CHANNEL = 1
ESP_NOW_ENCRYPTION = False

View File

@@ -1,443 +0,0 @@
#!/usr/bin/env python3
"""
Control Server for Lighting Controller
Handles lighting control logic and communicates with LED bars via WebSocket.
Receives commands from UI client via WebSocket.
"""
import asyncio
import websockets
import json
import logging
import socket
import threading
import time
from bar_config import LED_BAR_NAMES, DEFAULT_BAR_SETTINGS
from color_utils import adjust_brightness
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Configuration
LED_SERVER_URI = "ws://192.168.4.1:80/ws"
CONTROL_SERVER_PORT = 8765
SOUND_CONTROL_HOST = "127.0.0.1"
SOUND_CONTROL_PORT = 65433
# Pattern name mapping for shorter JSON payloads
PATTERN_NAMES = {
"flicker": "f",
"fill_range": "fr",
"n_chase": "nc",
"alternating": "a",
"pulse": "p",
"rainbow": "r",
"specto": "s",
"radiate": "rd",
"sequential_pulse": "sp",
"alternating_phase": "ap",
}
class LEDController:
"""Handles communication with LED bars via WebSocket."""
def __init__(self, led_server_uri):
self.led_server_uri = led_server_uri
self.websocket = None
self.is_connected = False
self.reconnect_task = None
async def connect(self):
"""Connect to LED server."""
if self.is_connected and self.websocket:
return
try:
logging.info(f"Connecting to LED server at {self.led_server_uri}...")
self.websocket = await websockets.connect(self.led_server_uri)
self.is_connected = True
logging.info("Connected to LED server")
except Exception as e:
logging.error(f"Failed to connect to LED server: {e}")
self.is_connected = False
self.websocket = None
async def send_data(self, data):
"""Send data to LED server."""
if not self.is_connected or not self.websocket:
logging.warning("Not connected to LED server. Attempting to reconnect...")
await self.connect()
if not self.is_connected:
logging.error("Failed to reconnect to LED server. Cannot send data.")
return
try:
await self.websocket.send(json.dumps(data))
logging.debug(f"Sent to LED server: {data}")
except Exception as e:
logging.error(f"Failed to send data to LED server: {e}")
self.is_connected = False
self.websocket = None
# Attempt to reconnect
await self.connect()
async def close(self):
"""Close LED server connection."""
if self.websocket and self.is_connected:
await self.websocket.close()
self.is_connected = False
self.websocket = None
logging.info("Disconnected from LED server")
class SoundController:
"""Handles communication with sound beat detector."""
def __init__(self, sound_host, sound_port):
self.sound_host = sound_host
self.sound_port = sound_port
async def send_reset_tempo(self):
"""Send reset tempo command to sound controller."""
try:
reader, writer = await asyncio.open_connection(self.sound_host, self.sound_port)
cmd = "RESET_TEMPO\n".encode('utf-8')
writer.write(cmd)
await writer.drain()
resp = await reader.read(100)
logging.info(f"Sent RESET_TEMPO, response: {resp.decode().strip()}")
writer.close()
await writer.wait_closed()
except Exception as e:
logging.error(f"Failed to send RESET_TEMPO: {e}")
class LightingController:
"""Main lighting control logic."""
def __init__(self):
self.led_controller = LEDController(LED_SERVER_URI)
self.sound_controller = SoundController(SOUND_CONTROL_HOST, SOUND_CONTROL_PORT)
# Lighting state
self.current_pattern = ""
self.delay = 100
self.brightness = 100
self.color_r = 0
self.color_g = 255
self.color_b = 0
self.n1 = 10
self.n2 = 10
self.n3 = 1
self.beat_index = 0
self.beat_sending_enabled = True
# Rate limiting
self.last_param_update = 0.0
self.param_update_interval = 0.1
self.pending_param_update = False
def _current_color_rgb(self):
"""Get current RGB color tuple."""
r = max(0, min(255, int(self.color_r)))
g = max(0, min(255, int(self.color_g)))
b = max(0, min(255, int(self.color_b)))
return (r, g, b)
async def _send_full_parameters(self):
"""Send all parameters to LED bars."""
full_payload = {
"d": {
"t": "u", # Message type: update
"pt": PATTERN_NAMES.get(self.current_pattern, self.current_pattern),
"dl": self.delay,
"cl": [self._current_color_rgb()],
"br": self.brightness,
"n1": self.n1,
"n2": self.n2,
"n3": self.n3,
"s": self.beat_index % 256,
}
}
# Add empty entries for each bar
for bar_name in LED_BAR_NAMES:
full_payload[bar_name] = {}
await self.led_controller.send_data(full_payload)
async def _request_param_update(self):
"""Request parameter update with rate limiting."""
current_time = time.time()
if current_time - self.last_param_update >= self.param_update_interval:
self.last_param_update = current_time
await self._send_full_parameters()
else:
self.pending_param_update = True
async def _send_normal_pattern(self):
"""Send normal pattern to all bars."""
patterns_needing_params = ["alternating", "flicker", "n_chase", "rainbow", "radiate"]
payload = {
"d": {
"t": "b", # Message type: beat
"pt": PATTERN_NAMES.get(self.current_pattern, self.current_pattern),
}
}
if self.current_pattern in patterns_needing_params:
payload["d"].update({
"n1": self.n1,
"n2": self.n2,
"n3": self.n3,
"dl": self.delay,
"s": self.beat_index % 256,
})
for bar_name in LED_BAR_NAMES:
payload[bar_name] = {}
await self.led_controller.send_data(payload)
async def _handle_sequential_pulse(self):
"""Handle sequential pulse pattern."""
from bar_config import LEFT_BARS, RIGHT_BARS
bar_index = self.beat_index % 4
payload = {
"d": {
"t": "b",
"pt": "o", # off
}
}
left_bar = LEFT_BARS[bar_index]
right_bar = RIGHT_BARS[bar_index]
payload[left_bar] = {"pt": "p"} # pulse
payload[right_bar] = {"pt": "p"} # pulse
await self.led_controller.send_data(payload)
async def _handle_alternating_phase(self):
"""Handle alternating pattern with phase offset."""
payload = {
"d": {
"t": "b",
"pt": "a", # alternating
"n1": self.n1,
"n2": self.n2,
"s": self.beat_index % 2,
}
}
swap_bars = ["101", "103", "105", "107"]
for bar_name in LED_BAR_NAMES:
if bar_name in swap_bars:
payload[bar_name] = {"s": (self.beat_index + 1) % 2}
else:
payload[bar_name] = {}
await self.led_controller.send_data(payload)
async def handle_beat(self, bpm_value):
"""Handle beat from sound detector."""
if not self.beat_sending_enabled or not self.current_pattern:
return
self.beat_index = (self.beat_index + 1) % 1000000
# Send periodic parameter updates every 8 beats
if self.beat_index % 8 == 0:
await self._send_full_parameters()
# Check for pending parameter updates
if self.pending_param_update:
current_time = time.time()
if current_time - self.last_param_update >= self.param_update_interval:
self.last_param_update = current_time
self.pending_param_update = False
await self._send_full_parameters()
# Handle pattern-specific beat logic
if self.current_pattern == "sequential_pulse":
await self._handle_sequential_pulse()
elif self.current_pattern == "alternating_phase":
await self._handle_alternating_phase()
elif self.current_pattern:
await self._send_normal_pattern()
async def handle_ui_command(self, message_type, data):
"""Handle command from UI client."""
if message_type == "pattern_change":
self.current_pattern = data.get("pattern", "")
await self._send_full_parameters()
logging.info(f"Pattern changed to: {self.current_pattern}")
elif message_type == "color_change":
self.color_r = data.get("r", self.color_r)
self.color_g = data.get("g", self.color_g)
self.color_b = data.get("b", self.color_b)
await self._request_param_update()
elif message_type == "brightness_change":
self.brightness = data.get("brightness", self.brightness)
await self._request_param_update()
elif message_type == "parameter_change":
if "n1" in data:
self.n1 = data["n1"]
if "n2" in data:
self.n2 = data["n2"]
if "n3" in data:
self.n3 = data["n3"]
await self._request_param_update()
elif message_type == "delay_change":
self.delay = data.get("delay", self.delay)
await self._request_param_update()
elif message_type == "beat_toggle":
self.beat_sending_enabled = data.get("enabled", True)
logging.info(f"Beat sending {'enabled' if self.beat_sending_enabled else 'disabled'}")
elif message_type == "reset_tempo":
await self.sound_controller.send_reset_tempo()
class ControlServer:
"""WebSocket server for UI client communication and TCP server for sound."""
def __init__(self):
self.lighting_controller = LightingController()
self.clients = set()
self.tcp_server = None
async def handle_ui_client(self, websocket):
"""Handle UI client WebSocket connection."""
self.clients.add(websocket)
client_addr = websocket.remote_address
logging.info(f"UI client connected: {client_addr}")
try:
async for message in websocket:
try:
data = json.loads(message)
message_type = data.get("type")
message_data = data.get("data", {})
await self.lighting_controller.handle_ui_command(message_type, message_data)
except json.JSONDecodeError:
logging.error(f"Invalid JSON from client {client_addr}: {message}")
except Exception as e:
logging.error(f"Error handling message from client {client_addr}: {e}")
except websockets.exceptions.ConnectionClosed:
logging.info(f"UI client disconnected: {client_addr}")
except Exception as e:
logging.error(f"Error in UI client handler: {e}")
finally:
self.clients.discard(websocket)
async def handle_tcp_client(self, reader, writer):
"""Handle TCP client (sound detector) connection."""
addr = writer.get_extra_info('peername')
logging.info(f"Sound client connected: {addr}")
try:
while True:
data = await reader.read(4096)
if not data:
logging.info(f"Sound client disconnected: {addr}")
break
message = data.decode().strip()
if self.lighting_controller.beat_sending_enabled:
try:
bpm_value = float(message)
await self.lighting_controller.handle_beat(bpm_value)
except ValueError:
logging.warning(f"Non-BPM message from {addr}: {message}")
except Exception as e:
logging.error(f"Error processing beat from {addr}: {e}")
except asyncio.CancelledError:
logging.info(f"Sound client handler cancelled: {addr}")
except Exception as e:
logging.error(f"Error handling sound client {addr}: {e}")
finally:
logging.info(f"Closing connection for sound client: {addr}")
writer.close()
await writer.wait_closed()
async def start_tcp_server(self):
"""Start TCP server for sound detector."""
self.tcp_server = await asyncio.start_server(
self.handle_tcp_client, "127.0.0.1", 65432
)
addrs = ', '.join(str(sock.getsockname()) for sock in self.tcp_server.sockets)
logging.info(f"TCP server listening on {addrs}")
async def start_websocket_server(self):
"""Start WebSocket server for UI clients."""
server = await websockets.serve(
self.handle_ui_client, "localhost", CONTROL_SERVER_PORT
)
logging.info(f"WebSocket server listening on localhost:{CONTROL_SERVER_PORT}")
async def run(self):
"""Run the control server."""
# Connect to LED server
await self.lighting_controller.led_controller.connect()
# Start servers and heartbeat task
await asyncio.gather(
self.start_websocket_server(),
self.start_tcp_server(),
self._heartbeat_loop()
)
async def _heartbeat_loop(self):
"""Send periodic heartbeats to keep LED connection alive."""
try:
while True:
await asyncio.sleep(5) # Send heartbeat every 5 seconds
if self.lighting_controller.led_controller.is_connected:
# Send a simple heartbeat to keep connection alive
heartbeat_data = {
"d": {
"t": "h", # heartbeat type
}
}
await self.lighting_controller.led_controller.send_data(heartbeat_data)
except asyncio.CancelledError:
logging.info("Heartbeat loop cancelled")
except Exception as e:
logging.error(f"Heartbeat loop error: {e}")
async def main():
"""Main entry point."""
server = ControlServer()
try:
await server.run()
except KeyboardInterrupt:
logging.info("Server interrupted by user")
except Exception as e:
logging.error(f"Server error: {e}")
finally:
await server.lighting_controller.led_controller.close()
if __name__ == "__main__":
asyncio.run(main())

820
src/flask_app.py Normal file
View File

@@ -0,0 +1,820 @@
"""
Flask web application for the lighting controller.
Provides REST API and serves the web UI.
"""
import asyncio
import json
import os
import math
from flask import Flask, render_template, request, jsonify
from flask_cors import CORS
from networking import WebSocketClient
import color_utils
from settings import Settings
app = Flask(__name__,
template_folder='../templates',
static_folder='../static')
CORS(app)
# Global settings and WebSocket client
settings = Settings()
websocket_client = None
websocket_uri = "ws://192.168.4.1:80/ws"
# Load current profile on startup
def load_current_profile():
"""Load the current profile if one is set."""
current_profile = settings.get("current_profile")
if current_profile:
profile_path = os.path.join("profiles", f"{current_profile}.json")
if os.path.exists(profile_path):
try:
with open(profile_path, 'r') as file:
profile_data = json.load(file)
# Update settings with profile data
profile_data.pop("current_profile", None)
patterns_backup = settings.get("patterns", {})
tab_password_backup = settings.get("tab_password", "")
settings.update(profile_data)
settings["patterns"] = patterns_backup
settings["current_profile"] = current_profile
# Ensure color_palette exists (default to empty array if not in profile)
if "color_palette" not in settings:
settings["color_palette"] = []
print(f"Loaded profile '{current_profile}' on startup.")
except Exception as e:
print(f"Error loading profile '{current_profile}': {e}")
# Load current profile when module is imported
load_current_profile()
# Presets file path
PRESETS_FILE = "presets.json"
def load_presets():
"""Load presets from presets.json file."""
try:
if os.path.exists(PRESETS_FILE):
with open(PRESETS_FILE, 'r') as file:
return json.load(file)
return {}
except Exception as e:
print(f"Error loading presets: {e}")
return {}
def save_presets(presets):
"""Save presets to presets.json file."""
try:
with open(PRESETS_FILE, 'w') as file:
json.dump(presets, file, indent=4)
print(f"Presets saved successfully.")
except Exception as e:
print(f"Error saving presets: {e}")
def delay_to_slider(delay_ms, min_delay=10, max_delay=10000):
"""Convert delay in ms to slider position (0-1000) using logarithmic scale."""
if delay_ms <= min_delay:
return 0
if delay_ms >= max_delay:
return 1000
if min_delay == max_delay:
return 0
return 1000 * math.log(delay_ms / min_delay) / math.log(max_delay / min_delay)
def slider_to_delay(slider_value, min_delay=10, max_delay=10000):
"""Convert slider position (0-1000) to delay in ms using logarithmic scale."""
if slider_value <= 0:
return min_delay
if slider_value >= 1000:
return max_delay
if min_delay == max_delay:
return min_delay
return int(min_delay * ((max_delay / min_delay) ** (slider_value / 1000)))
def get_pattern_settings(tab_name, pattern_name):
"""Get pattern-specific settings."""
light_settings = settings["lights"][tab_name]["settings"]
if "patterns" not in light_settings:
light_settings["patterns"] = {}
if pattern_name not in light_settings["patterns"]:
light_settings["patterns"][pattern_name] = {}
pattern_settings = light_settings["patterns"][pattern_name]
# Fall back to global settings if pattern-specific settings don't exist
global_colors = light_settings.get("colors", ["#000000"])
return {
"colors": pattern_settings.get("colors", global_colors),
"delay": pattern_settings.get("delay", light_settings.get("delay", 100)),
"n1": pattern_settings.get("n1", light_settings.get("n1", 10)),
"n2": pattern_settings.get("n2", light_settings.get("n2", 10)),
"n3": pattern_settings.get("n3", light_settings.get("n3", 10)),
"n4": pattern_settings.get("n4", light_settings.get("n4", 10)),
}
def save_pattern_settings(tab_name, pattern_name, colors=None, delay=None, n_params=None):
"""Save pattern-specific settings."""
light_settings = settings["lights"][tab_name]["settings"]
if "patterns" not in light_settings:
light_settings["patterns"] = {}
if pattern_name not in light_settings["patterns"]:
light_settings["patterns"][pattern_name] = {}
pattern_settings = light_settings["patterns"][pattern_name]
if colors is not None:
pattern_settings["colors"] = colors
if delay is not None:
pattern_settings["delay"] = delay
if n_params is not None:
for i in range(1, 5):
if f"n{i}" in n_params:
pattern_settings[f"n{i}"] = n_params[f"n{i}"]
def save_current_profile():
"""Save current settings to the active profile file."""
current_profile = settings.get("current_profile")
# If no profile is set, create/use a default profile
if not current_profile:
current_profile = "default"
settings["current_profile"] = current_profile
# Save current_profile to settings.json
settings.save()
try:
profiles_dir = "profiles"
os.makedirs(profiles_dir, exist_ok=True)
profile_path = os.path.join(profiles_dir, f"{current_profile}.json")
# Get current tab order
tab_order = settings.get("tab_order", [])
if "lights" in settings:
# Ensure all current tabs are in the order
current_tabs = set(settings["lights"].keys())
order_tabs = set(tab_order)
for tab in current_tabs:
if tab not in order_tabs:
tab_order.append(tab)
settings["tab_order"] = tab_order
# Save to profile file (exclude current_profile from profile)
profile_data = dict(settings)
profile_data.pop("current_profile", None)
profile_data.pop("patterns", None) # Patterns stay in settings.json
# Ensure color_palette is included if it exists
if "color_palette" not in profile_data:
profile_data["color_palette"] = settings.get("color_palette", [])
with open(profile_path, 'w') as file:
json.dump(profile_data, file, indent=4)
print(f"Profile '{current_profile}' saved successfully.")
except Exception as e:
print(f"Error saving profile: {e}")
async def send_to_lighting_controller(payload):
"""Send data to the lighting controller via WebSocket."""
global websocket_client
if websocket_client is None:
websocket_client = WebSocketClient(websocket_uri)
await websocket_client.connect()
if not websocket_client.is_connected:
await websocket_client.connect()
if websocket_client.is_connected:
await websocket_client.send_data(payload)
def run_async(coro):
"""Run async function in sync context."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
@app.route('/')
def index():
"""Serve the main web UI."""
return render_template('index.html')
@app.route('/api/state', methods=['GET'])
def get_state():
"""Get the current state of all lights."""
# Ensure a profile is set if we have lights but no profile
if settings.get("lights") and not settings.get("current_profile"):
current_profile = "default"
settings["current_profile"] = current_profile
settings.save()
# Create default profile file if it doesn't exist
profiles_dir = "profiles"
os.makedirs(profiles_dir, exist_ok=True)
profile_path = os.path.join(profiles_dir, f"{current_profile}.json")
if not os.path.exists(profile_path):
profile_data = {
"lights": settings.get("lights", {}),
"tab_order": settings.get("tab_order", []),
"tab_password": settings.get("tab_password", "")
}
with open(profile_path, 'w') as file:
json.dump(profile_data, file, indent=4)
return jsonify({
"lights": settings.get("lights", {}),
"patterns": settings.get("patterns", {}),
"tab_order": settings.get("tab_order", []),
"current_profile": settings.get("current_profile", ""),
"color_palette": settings.get("color_palette", []),
"presets": load_presets()
})
@app.route('/api/pattern', methods=['POST'])
def set_pattern():
"""Set the pattern for a light group."""
data = request.json
tab_name = data.get("tab_name")
pattern_name = data.get("pattern")
if not tab_name or not pattern_name:
return jsonify({"error": "Missing tab_name or pattern"}), 400
if tab_name not in settings.get("lights", {}):
return jsonify({"error": f"Tab '{tab_name}' not found"}), 404
# Save current pattern's settings before switching
old_pattern = settings["lights"][tab_name]["settings"].get("pattern", "on")
# Get current delay (would need to be passed from frontend)
current_delay = data.get("delay", 100)
current_n_params = {
f"n{i}": data.get(f"n{i}", 10) for i in range(1, 5)
}
save_pattern_settings(
tab_name,
old_pattern,
colors=data.get("colors", ["#000000"]),
delay=current_delay,
n_params=current_n_params
)
# Load new pattern's settings
new_pattern_settings = get_pattern_settings(tab_name, pattern_name)
# Update settings
settings["lights"][tab_name]["settings"]["pattern"] = pattern_name
# Prepare payload for lighting controller
names = settings["lights"][tab_name]["names"]
payload = {
"save": True,
"names": names,
"settings": {
"pattern": pattern_name,
"brightness": settings["lights"][tab_name]["settings"].get("brightness", 127),
"delay": new_pattern_settings["delay"],
"colors": new_pattern_settings["colors"],
**{f"n{i}": new_pattern_settings[f"n{i}"] for i in range(1, 5)}
}
}
# Send to lighting controller
run_async(send_to_lighting_controller(payload))
settings.save()
return jsonify({
"success": True,
"pattern": pattern_name,
"settings": new_pattern_settings
})
@app.route('/api/parameters', methods=['POST'])
def set_parameters():
"""Update parameters (RGB, brightness, delay, n params) for a light group."""
data = request.json
tab_name = data.get("tab_name")
if not tab_name:
return jsonify({"error": "Missing tab_name"}), 400
if tab_name not in settings.get("lights", {}):
return jsonify({"error": f"Tab '{tab_name}' not found"}), 404
current_pattern = settings["lights"][tab_name]["settings"].get("pattern", "on")
pattern_config = settings.get("patterns", {}).get(current_pattern, {})
min_delay = pattern_config.get("min_delay", 10)
max_delay = pattern_config.get("max_delay", 10000)
# Build settings payload
payload_settings = {}
# Handle RGB colors
if "red" in data or "green" in data or "blue" in data:
r = data.get("red", 0)
g = data.get("green", 0)
b = data.get("blue", 0)
hex_color = f"#{r:02x}{g:02x}{b:02x}"
# Update color in palette
pattern_settings = get_pattern_settings(tab_name, current_pattern)
colors = pattern_settings["colors"].copy()
selected_index = data.get("color_index", 0)
if 0 <= selected_index < len(colors):
colors[selected_index] = hex_color
else:
# If index is out of range, append the color
colors.append(hex_color)
# Save pattern settings to persist the color change
save_pattern_settings(tab_name, current_pattern, colors=colors)
payload_settings["colors"] = colors
# Handle brightness
if "brightness" in data:
brightness = int(data["brightness"])
settings["lights"][tab_name]["settings"]["brightness"] = brightness
payload_settings["brightness"] = brightness
# Handle delay
if "delay_slider" in data:
slider_value = int(data["delay_slider"])
delay = slider_to_delay(slider_value, min_delay, max_delay)
save_pattern_settings(tab_name, current_pattern, delay=delay)
payload_settings["delay"] = delay
# Handle n parameters
n_params = {}
for i in range(1, 5):
if f"n{i}" in data:
n_params[f"n{i}"] = int(data[f"n{i}"])
if n_params:
save_pattern_settings(tab_name, current_pattern, n_params=n_params)
payload_settings.update(n_params)
# Send to lighting controller
if payload_settings:
names = settings["lights"][tab_name]["names"]
payload = {
"save": True,
"names": names,
"settings": payload_settings
}
run_async(send_to_lighting_controller(payload))
# Save to settings.json (for patterns) and to profile file (for lights data)
settings.save()
save_current_profile()
return jsonify({"success": True})
@app.route('/api/tabs', methods=['GET'])
def get_tabs():
"""Get list of tabs."""
return jsonify({
"tabs": settings.get("tab_order", []),
"lights": settings.get("lights", {})
})
@app.route('/api/tabs', methods=['POST'])
def create_tab():
"""Create a new tab."""
data = request.json
tab_name = data.get("name")
ids = data.get("ids", ["1"])
if not tab_name:
return jsonify({"error": "Missing name"}), 400
if tab_name in settings.get("lights", {}):
return jsonify({"error": f"Tab '{tab_name}' already exists"}), 400
settings.setdefault("lights", {})[tab_name] = {
"names": ids if isinstance(ids, list) else [ids],
"settings": {
"pattern": "on",
"brightness": 127,
"colors": ["#000000"],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"patterns": {}
}
}
if "tab_order" not in settings:
settings["tab_order"] = []
settings["tab_order"].append(tab_name)
settings.save()
save_current_profile()
return jsonify({"success": True, "tab_name": tab_name})
@app.route('/api/tabs/<tab_name>', methods=['PUT'])
def update_tab(tab_name):
"""Update a tab."""
data = request.json
new_name = data.get("name", tab_name)
ids = data.get("ids")
if tab_name not in settings.get("lights", {}):
return jsonify({"error": f"Tab '{tab_name}' not found"}), 404
if new_name != tab_name:
if new_name in settings.get("lights", {}):
return jsonify({"error": f"Tab '{new_name}' already exists"}), 400
# Rename tab
settings["lights"][new_name] = settings["lights"][tab_name]
del settings["lights"][tab_name]
# Update tab order
if "tab_order" in settings and tab_name in settings["tab_order"]:
index = settings["tab_order"].index(tab_name)
settings["tab_order"][index] = new_name
tab_name = new_name
if ids is not None:
settings["lights"][tab_name]["names"] = ids if isinstance(ids, list) else [ids]
settings.save()
save_current_profile()
return jsonify({"success": True, "tab_name": tab_name})
@app.route('/api/tabs/<tab_name>', methods=['DELETE'])
def delete_tab(tab_name):
"""Delete a tab."""
if tab_name not in settings.get("lights", {}):
return jsonify({"error": f"Tab '{tab_name}' not found"}), 404
del settings["lights"][tab_name]
if "tab_order" in settings and tab_name in settings["tab_order"]:
settings["tab_order"].remove(tab_name)
settings.save()
save_current_profile()
return jsonify({"success": True})
@app.route('/api/profiles', methods=['GET'])
def get_profiles():
"""Get list of profiles."""
profiles_dir = "profiles"
profiles = []
if os.path.exists(profiles_dir):
for filename in os.listdir(profiles_dir):
if filename.endswith('.json'):
profiles.append(filename[:-5])
profiles.sort()
return jsonify({
"profiles": profiles,
"current_profile": settings.get("current_profile", ""),
"color_palette": settings.get("color_palette", [])
})
@app.route('/api/profiles', methods=['POST'])
def create_profile():
"""Create a new profile."""
data = request.json
profile_name = data.get("name")
if not profile_name:
return jsonify({"error": "Missing name"}), 400
profiles_dir = "profiles"
os.makedirs(profiles_dir, exist_ok=True)
profile_path = os.path.join(profiles_dir, f"{profile_name}.json")
if os.path.exists(profile_path):
return jsonify({"error": f"Profile '{profile_name}' already exists"}), 400
empty_profile = {
"lights": {},
"tab_password": "",
"tab_order": [],
"color_palette": []
}
with open(profile_path, 'w') as file:
json.dump(empty_profile, file, indent=4)
return jsonify({"success": True, "profile_name": profile_name})
@app.route('/api/profiles/<profile_name>', methods=['DELETE'])
def delete_profile(profile_name):
"""Delete a profile."""
profiles_dir = "profiles"
profile_path = os.path.join(profiles_dir, f"{profile_name}.json")
if not os.path.exists(profile_path):
return jsonify({"error": f"Profile '{profile_name}' not found"}), 404
# Prevent deleting the only existing profile to avoid leaving the app with no profiles
existing_profiles = [
f[:-5] for f in os.listdir(profiles_dir) if f.endswith('.json')
] if os.path.exists(profiles_dir) else []
if len(existing_profiles) <= 1:
return jsonify({"error": "Cannot delete the only existing profile"}), 400
# If deleting the current profile, clear current_profile and related state
if settings.get("current_profile") == profile_name:
settings["current_profile"] = ""
settings["lights"] = {}
settings["tab_order"] = []
settings["color_palette"] = []
# Persist to settings.json
settings_to_save = {
"tab_password": settings.get("tab_password", ""),
"current_profile": "",
"patterns": settings.get("patterns", {})
}
with open("settings.json", 'w') as f:
json.dump(settings_to_save, f, indent=4)
# Remove the profile file
os.remove(profile_path)
return jsonify({"success": True})
@app.route('/api/profiles/<profile_name>', methods=['POST'])
def load_profile(profile_name):
"""Load a profile."""
profile_path = os.path.join("profiles", f"{profile_name}.json")
if not os.path.exists(profile_path):
return jsonify({"error": f"Profile '{profile_name}' not found"}), 404
with open(profile_path, 'r') as file:
profile_data = json.load(file)
# Update settings with profile data
profile_data.pop("current_profile", None)
patterns_backup = settings.get("patterns", {})
tab_password_backup = settings.get("tab_password", "")
settings.update(profile_data)
settings["patterns"] = patterns_backup
settings["current_profile"] = profile_name
# Ensure color_palette exists (default to empty array if not in profile)
if "color_palette" not in settings:
settings["color_palette"] = []
settings_to_save = {
"tab_password": tab_password_backup,
"current_profile": profile_name,
"patterns": patterns_backup
}
with open("settings.json", 'w') as f:
json.dump(settings_to_save, f, indent=4)
return jsonify({"success": True})
@app.route('/api/profiles/<profile_name>/palette', methods=['GET'])
def get_profile_palette(profile_name):
"""Get the color palette for a profile."""
profile_path = os.path.join("profiles", f"{profile_name}.json")
if not os.path.exists(profile_path):
return jsonify({"error": f"Profile '{profile_name}' not found"}), 404
with open(profile_path, 'r') as file:
profile_data = json.load(file)
palette = profile_data.get("color_palette", [])
return jsonify({"color_palette": palette})
@app.route('/api/profiles/<profile_name>/palette', methods=['POST'])
def update_profile_palette(profile_name):
"""Update the color palette for a profile."""
data = request.json
color_palette = data.get("color_palette", [])
profile_path = os.path.join("profiles", f"{profile_name}.json")
if not os.path.exists(profile_path):
return jsonify({"error": f"Profile '{profile_name}' not found"}), 404
with open(profile_path, 'r') as file:
profile_data = json.load(file)
profile_data["color_palette"] = color_palette
with open(profile_path, 'w') as file:
json.dump(profile_data, file, indent=4)
# Update current settings if this is the active profile
if settings.get("current_profile") == profile_name:
settings["color_palette"] = color_palette
return jsonify({"success": True, "color_palette": color_palette})
@app.route('/api/profiles/<profile_name>/save', methods=['POST'])
def save_profile(profile_name):
"""Save current state to a profile."""
# Save current state to the specified profile
save_current_profile()
# If saving to a different profile, switch to it
if profile_name != settings.get("current_profile"):
settings["current_profile"] = profile_name
settings.save()
save_current_profile()
return jsonify({"success": True})
@app.route('/api/presets', methods=['GET'])
def get_presets():
"""Get list of all presets."""
presets = load_presets()
return jsonify({
"presets": presets
})
@app.route('/api/presets', methods=['POST'])
def create_preset():
"""Create a new preset."""
data = request.json
preset_name = data.get("name")
if not preset_name:
return jsonify({"error": "Missing name"}), 400
presets = load_presets()
if preset_name in presets:
return jsonify({"error": f"Preset '{preset_name}' already exists"}), 400
# Validate required fields
required_fields = ["pattern", "colors", "brightness", "delay", "n1", "n2", "n3", "n4"]
for field in required_fields:
if field not in data:
return jsonify({"error": f"Missing required field: {field}"}), 400
preset = {
"pattern": data["pattern"],
"colors": data["colors"],
"brightness": int(data["brightness"]),
"delay": int(data["delay"]),
"n1": int(data["n1"]),
"n2": int(data["n2"]),
"n3": int(data["n3"]),
"n4": int(data["n4"])
}
presets[preset_name] = preset
save_presets(presets)
return jsonify({"success": True, "preset": preset})
@app.route('/api/presets/<preset_name>', methods=['PUT'])
def update_preset(preset_name):
"""Update an existing preset."""
data = request.json
new_name = data.get("name", preset_name)
presets = load_presets()
if preset_name not in presets:
return jsonify({"error": f"Preset '{preset_name}' not found"}), 404
# If renaming, check if new name exists
if new_name != preset_name:
if new_name in presets:
return jsonify({"error": f"Preset '{new_name}' already exists"}), 400
# Rename preset
presets[new_name] = presets[preset_name]
del presets[preset_name]
preset_name = new_name
# Update preset fields
preset = presets[preset_name]
if "pattern" in data:
preset["pattern"] = data["pattern"]
if "colors" in data:
preset["colors"] = data["colors"]
if "brightness" in data:
preset["brightness"] = int(data["brightness"])
if "delay" in data:
preset["delay"] = int(data["delay"])
for i in range(1, 5):
if f"n{i}" in data:
preset[f"n{i}"] = int(data[f"n{i}"])
save_presets(presets)
return jsonify({"success": True, "preset": preset})
@app.route('/api/presets/<preset_name>', methods=['DELETE'])
def delete_preset(preset_name):
"""Delete a preset."""
presets = load_presets()
if preset_name not in presets:
return jsonify({"error": f"Preset '{preset_name}' not found"}), 404
del presets[preset_name]
save_presets(presets)
return jsonify({"success": True})
@app.route('/api/presets/<preset_name>/apply', methods=['POST'])
def apply_preset(preset_name):
"""Apply a preset to a tab."""
data = request.json
tab_name = data.get("tab_name")
if not tab_name:
return jsonify({"error": "Missing tab_name"}), 400
if tab_name not in settings.get("lights", {}):
return jsonify({"error": f"Tab '{tab_name}' not found"}), 404
presets = load_presets()
if preset_name not in presets:
return jsonify({"error": f"Preset '{preset_name}' not found"}), 404
preset = presets[preset_name]
# Apply preset to tab
light_settings = settings["lights"][tab_name]["settings"]
light_settings["pattern"] = preset["pattern"]
light_settings["brightness"] = preset["brightness"]
# Save pattern-specific settings
save_pattern_settings(
tab_name,
preset["pattern"],
colors=preset["colors"],
delay=preset["delay"],
n_params={f"n{i}": preset[f"n{i}"] for i in range(1, 5)}
)
# Prepare payload for lighting controller
names = settings["lights"][tab_name]["names"]
payload = {
"save": True,
"names": names,
"settings": {
"pattern": preset["pattern"],
"brightness": preset["brightness"],
"delay": preset["delay"],
"colors": preset["colors"],
**{f"n{i}": preset[f"n{i}"] for i in range(1, 5)}
}
}
# Send to lighting controller
run_async(send_to_lighting_controller(payload))
settings.save()
save_current_profile()
return jsonify({"success": True})
def init_websocket():
"""Initialize WebSocket connection in background."""
global websocket_client
if websocket_client is None:
websocket_client = WebSocketClient(websocket_uri)
run_async(websocket_client.connect())
if __name__ == '__main__':
# Initialize WebSocket connection
init_websocket()
app.run(host='0.0.0.0', port=5000, debug=True)

File diff suppressed because it is too large Load Diff

1054
src/main_textual.py Normal file

File diff suppressed because it is too large Load Diff

2135
src/main_tkinter.py.bak Normal file

File diff suppressed because it is too large Load Diff

665
src/microdot_app.py Normal file
View File

@@ -0,0 +1,665 @@
"""
Microdot web application for the lighting controller.
Provides REST API and serves the web UI.
"""
import asyncio
import json
import os
import math
from microdot import Microdot, Request, Response
from networking import WebSocketClient
import color_utils
from settings import Settings
app = Microdot()
# CORS middleware
@app.after_request()
def cors_handler(req, res):
"""Add CORS headers to all responses."""
res.headers['Access-Control-Allow-Origin'] = '*'
res.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
res.headers['Access-Control-Allow-Headers'] = 'Content-Type'
return res
@app.route('/<path:path>', methods=['OPTIONS'])
def options_handler(req, path):
"""Handle OPTIONS requests for CORS."""
return Response('', status_code=204, headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
})
# Global settings and WebSocket client
settings = Settings()
websocket_client = None
websocket_uri = "ws://192.168.4.1:80/ws"
# Load current profile on startup
def load_current_profile():
"""Load the current profile if one is set."""
current_profile = settings.get("current_profile")
if current_profile:
profile_path = os.path.join("profiles", f"{current_profile}.json")
if os.path.exists(profile_path):
try:
with open(profile_path, 'r') as file:
profile_data = json.load(file)
# Update settings with profile data
profile_data.pop("current_profile", None)
patterns_backup = settings.get("patterns", {})
tab_password_backup = settings.get("tab_password", "")
settings.update(profile_data)
settings["patterns"] = patterns_backup
settings["current_profile"] = current_profile
# Ensure color_palette exists (default to empty array if not in profile)
if "color_palette" not in settings:
settings["color_palette"] = []
print(f"Loaded profile '{current_profile}' on startup.")
except Exception as e:
print(f"Error loading profile '{current_profile}': {e}")
# Load current profile when module is imported
load_current_profile()
def delay_to_slider(delay_ms, min_delay=10, max_delay=10000):
"""Convert delay in ms to slider position (0-1000) using logarithmic scale."""
if delay_ms <= min_delay:
return 0
if delay_ms >= max_delay:
return 1000
if min_delay == max_delay:
return 0
return 1000 * math.log(delay_ms / min_delay) / math.log(max_delay / min_delay)
def slider_to_delay(slider_value, min_delay=10, max_delay=10000):
"""Convert slider position (0-1000) to delay in ms using logarithmic scale."""
if slider_value <= 0:
return min_delay
if slider_value >= 1000:
return max_delay
if min_delay == max_delay:
return min_delay
return int(min_delay * ((max_delay / min_delay) ** (slider_value / 1000)))
def get_pattern_settings(tab_name, pattern_name):
"""Get pattern-specific settings."""
light_settings = settings["lights"][tab_name]["settings"]
if "patterns" not in light_settings:
light_settings["patterns"] = {}
if pattern_name not in light_settings["patterns"]:
light_settings["patterns"][pattern_name] = {}
pattern_settings = light_settings["patterns"][pattern_name]
# Fall back to global settings if pattern-specific settings don't exist
global_colors = light_settings.get("colors", ["#000000"])
return {
"colors": pattern_settings.get("colors", global_colors),
"delay": pattern_settings.get("delay", light_settings.get("delay", 100)),
"n1": pattern_settings.get("n1", light_settings.get("n1", 10)),
"n2": pattern_settings.get("n2", light_settings.get("n2", 10)),
"n3": pattern_settings.get("n3", light_settings.get("n3", 10)),
"n4": pattern_settings.get("n4", light_settings.get("n4", 10)),
}
def save_pattern_settings(tab_name, pattern_name, colors=None, delay=None, n_params=None):
"""Save pattern-specific settings."""
light_settings = settings["lights"][tab_name]["settings"]
if "patterns" not in light_settings:
light_settings["patterns"] = {}
if pattern_name not in light_settings["patterns"]:
light_settings["patterns"][pattern_name] = {}
pattern_settings = light_settings["patterns"][pattern_name]
if colors is not None:
pattern_settings["colors"] = colors
if delay is not None:
pattern_settings["delay"] = delay
if n_params is not None:
for i in range(1, 5):
if f"n{i}" in n_params:
pattern_settings[f"n{i}"] = n_params[f"n{i}"]
def save_current_profile():
"""Save current settings to the active profile file."""
current_profile = settings.get("current_profile")
# If no profile is set, create/use a default profile
if not current_profile:
current_profile = "default"
settings["current_profile"] = current_profile
# Save current_profile to settings.json
settings.save()
try:
profiles_dir = "profiles"
os.makedirs(profiles_dir, exist_ok=True)
profile_path = os.path.join(profiles_dir, f"{current_profile}.json")
# Get current tab order
tab_order = settings.get("tab_order", [])
if "lights" in settings:
# Ensure all current tabs are in the order
current_tabs = set(settings["lights"].keys())
order_tabs = set(tab_order)
for tab in current_tabs:
if tab not in order_tabs:
tab_order.append(tab)
settings["tab_order"] = tab_order
# Save to profile file (exclude current_profile from profile)
profile_data = dict(settings)
profile_data.pop("current_profile", None)
profile_data.pop("patterns", None) # Patterns stay in settings.json
# Ensure color_palette is included if it exists
if "color_palette" not in profile_data:
profile_data["color_palette"] = settings.get("color_palette", [])
with open(profile_path, 'w') as file:
json.dump(profile_data, file, indent=4)
print(f"Profile '{current_profile}' saved successfully.")
except Exception as e:
print(f"Error saving profile: {e}")
async def send_to_lighting_controller(payload):
"""Send data to the lighting controller via WebSocket."""
global websocket_client
if websocket_client is None:
websocket_client = WebSocketClient(websocket_uri)
await websocket_client.connect()
if not websocket_client.is_connected:
await websocket_client.connect()
if websocket_client.is_connected:
await websocket_client.send_data(payload)
def run_async(coro):
"""Run async function in sync context."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
@app.route('/')
def index(req):
"""Serve the main web UI."""
return Response.send_file('templates/index.html', base_path='.')
@app.route('/api/state', methods=['GET'])
def get_state(req):
"""Get the current state of all lights."""
# Ensure a profile is set if we have lights but no profile
if settings.get("lights") and not settings.get("current_profile"):
current_profile = "default"
settings["current_profile"] = current_profile
settings.save()
# Create default profile file if it doesn't exist
profiles_dir = "profiles"
os.makedirs(profiles_dir, exist_ok=True)
profile_path = os.path.join(profiles_dir, f"{current_profile}.json")
if not os.path.exists(profile_path):
profile_data = {
"lights": settings.get("lights", {}),
"tab_order": settings.get("tab_order", []),
"tab_password": settings.get("tab_password", "")
}
with open(profile_path, 'w') as file:
json.dump(profile_data, file, indent=4)
return {
"lights": settings.get("lights", {}),
"patterns": settings.get("patterns", {}),
"tab_order": settings.get("tab_order", []),
"current_profile": settings.get("current_profile", ""),
"color_palette": settings.get("color_palette", [])
}
@app.route('/api/pattern', methods=['POST'])
def set_pattern(req):
"""Set the pattern for a light group."""
data = req.json
tab_name = data.get("tab_name")
pattern_name = data.get("pattern")
if not tab_name or not pattern_name:
return {"error": "Missing tab_name or pattern"}, 400
if tab_name not in settings.get("lights", {}):
return {"error": f"Tab '{tab_name}' not found"}, 404
# Save current pattern's settings before switching
old_pattern = settings["lights"][tab_name]["settings"].get("pattern", "on")
# Get current delay (would need to be passed from frontend)
current_delay = data.get("delay", 100)
current_n_params = {
f"n{i}": data.get(f"n{i}", 10) for i in range(1, 5)
}
save_pattern_settings(
tab_name,
old_pattern,
colors=data.get("colors", ["#000000"]),
delay=current_delay,
n_params=current_n_params
)
# Load new pattern's settings
new_pattern_settings = get_pattern_settings(tab_name, pattern_name)
# Update settings
settings["lights"][tab_name]["settings"]["pattern"] = pattern_name
# Prepare payload for lighting controller
names = settings["lights"][tab_name]["names"]
payload = {
"save": True,
"names": names,
"settings": {
"pattern": pattern_name,
"brightness": settings["lights"][tab_name]["settings"].get("brightness", 127),
"delay": new_pattern_settings["delay"],
"colors": new_pattern_settings["colors"],
**{f"n{i}": new_pattern_settings[f"n{i}"] for i in range(1, 5)}
}
}
# Send to lighting controller
run_async(send_to_lighting_controller(payload))
settings.save()
save_current_profile()
return {
"success": True,
"pattern": pattern_name,
"settings": new_pattern_settings
}
@app.route('/api/parameters', methods=['POST'])
def set_parameters(req):
"""Update parameters (RGB, brightness, delay, n params) for a light group."""
data = req.json
tab_name = data.get("tab_name")
if not tab_name:
return {"error": "Missing tab_name"}, 400
if tab_name not in settings.get("lights", {}):
return {"error": f"Tab '{tab_name}' not found"}, 404
current_pattern = settings["lights"][tab_name]["settings"].get("pattern", "on")
pattern_config = settings.get("patterns", {}).get(current_pattern, {})
min_delay = pattern_config.get("min_delay", 10)
max_delay = pattern_config.get("max_delay", 10000)
# Build settings payload
payload_settings = {}
# Handle RGB colors
if "red" in data or "green" in data or "blue" in data:
r = data.get("red", 0)
g = data.get("green", 0)
b = data.get("blue", 0)
hex_color = f"#{r:02x}{g:02x}{b:02x}"
# Update color in palette
pattern_settings = get_pattern_settings(tab_name, current_pattern)
colors = pattern_settings["colors"].copy()
selected_index = data.get("color_index", 0)
if 0 <= selected_index < len(colors):
colors[selected_index] = hex_color
else:
# If index is out of range, append the color
colors.append(hex_color)
# Save pattern settings to persist the color change
save_pattern_settings(tab_name, current_pattern, colors=colors)
payload_settings["colors"] = colors
# Handle brightness
if "brightness" in data:
brightness = int(data["brightness"])
settings["lights"][tab_name]["settings"]["brightness"] = brightness
payload_settings["brightness"] = brightness
# Handle delay
if "delay_slider" in data:
slider_value = int(data["delay_slider"])
delay = slider_to_delay(slider_value, min_delay, max_delay)
save_pattern_settings(tab_name, current_pattern, delay=delay)
payload_settings["delay"] = delay
# Handle n parameters
n_params = {}
for i in range(1, 5):
if f"n{i}" in data:
n_params[f"n{i}"] = int(data[f"n{i}"])
if n_params:
save_pattern_settings(tab_name, current_pattern, n_params=n_params)
payload_settings.update(n_params)
# Send to lighting controller
if payload_settings:
names = settings["lights"][tab_name]["names"]
payload = {
"save": True,
"names": names,
"settings": payload_settings
}
run_async(send_to_lighting_controller(payload))
# Save to settings.json (for patterns) and to profile file (for lights data)
settings.save()
save_current_profile()
return {"success": True}
@app.route('/api/tabs', methods=['GET'])
def get_tabs(req):
"""Get list of tabs."""
return {
"tabs": settings.get("tab_order", []),
"lights": settings.get("lights", {})
}
@app.route('/api/tabs', methods=['POST'])
def create_tab(req):
"""Create a new tab."""
data = req.json
tab_name = data.get("name")
ids = data.get("ids", ["1"])
if not tab_name:
return {"error": "Missing name"}, 400
if tab_name in settings.get("lights", {}):
return {"error": f"Tab '{tab_name}' already exists"}, 400
settings.setdefault("lights", {})[tab_name] = {
"names": ids if isinstance(ids, list) else [ids],
"settings": {
"pattern": "on",
"brightness": 127,
"colors": ["#000000"],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"patterns": {}
}
}
if "tab_order" not in settings:
settings["tab_order"] = []
settings["tab_order"].append(tab_name)
settings.save()
save_current_profile()
return {"success": True, "tab_name": tab_name}
@app.route('/api/tabs/<tab_name>', methods=['PUT'])
def update_tab(req, tab_name):
"""Update a tab."""
data = req.json
new_name = data.get("name", tab_name)
ids = data.get("ids")
if tab_name not in settings.get("lights", {}):
return {"error": f"Tab '{tab_name}' not found"}, 404
if new_name != tab_name:
if new_name in settings.get("lights", {}):
return {"error": f"Tab '{new_name}' already exists"}, 400
# Rename tab
settings["lights"][new_name] = settings["lights"][tab_name]
del settings["lights"][tab_name]
# Update tab order
if "tab_order" in settings and tab_name in settings["tab_order"]:
index = settings["tab_order"].index(tab_name)
settings["tab_order"][index] = new_name
tab_name = new_name
if ids is not None:
settings["lights"][tab_name]["names"] = ids if isinstance(ids, list) else [ids]
settings.save()
save_current_profile()
return {"success": True, "tab_name": tab_name}
@app.route('/api/tabs/<tab_name>', methods=['DELETE'])
def delete_tab(req, tab_name):
"""Delete a tab."""
if tab_name not in settings.get("lights", {}):
return {"error": f"Tab '{tab_name}' not found"}, 404
del settings["lights"][tab_name]
if "tab_order" in settings and tab_name in settings["tab_order"]:
settings["tab_order"].remove(tab_name)
settings.save()
save_current_profile()
return {"success": True}
@app.route('/api/profiles', methods=['GET'])
def get_profiles(req):
"""Get list of profiles."""
profiles_dir = "profiles"
profiles = []
if os.path.exists(profiles_dir):
for filename in os.listdir(profiles_dir):
if filename.endswith('.json'):
profiles.append(filename[:-5])
profiles.sort()
return {
"profiles": profiles,
"current_profile": settings.get("current_profile", ""),
"color_palette": settings.get("color_palette", [])
}
@app.route('/api/profiles', methods=['POST'])
def create_profile(req):
"""Create a new profile."""
data = req.json
profile_name = data.get("name")
if not profile_name:
return {"error": "Missing name"}, 400
profiles_dir = "profiles"
os.makedirs(profiles_dir, exist_ok=True)
profile_path = os.path.join(profiles_dir, f"{profile_name}.json")
if os.path.exists(profile_path):
return {"error": f"Profile '{profile_name}' already exists"}, 400
empty_profile = {
"lights": {},
"tab_password": "",
"tab_order": [],
"color_palette": []
}
with open(profile_path, 'w') as file:
json.dump(empty_profile, file, indent=4)
return {"success": True, "profile_name": profile_name}
@app.route('/api/profiles/<profile_name>', methods=['DELETE'])
def delete_profile(req, profile_name):
"""Delete a profile."""
profiles_dir = "profiles"
profile_path = os.path.join(profiles_dir, f"{profile_name}.json")
if not os.path.exists(profile_path):
return {"error": f"Profile '{profile_name}' not found"}, 404
# Prevent deleting the only existing profile to avoid leaving the app with no profiles
existing_profiles = [
f[:-5] for f in os.listdir(profiles_dir) if f.endswith('.json')
] if os.path.exists(profiles_dir) else []
if len(existing_profiles) <= 1:
return {"error": "Cannot delete the only existing profile"}, 400
# If deleting the current profile, clear current_profile and related state
if settings.get("current_profile") == profile_name:
settings["current_profile"] = ""
settings["lights"] = {}
settings["tab_order"] = []
settings["color_palette"] = []
# Persist to settings.json
settings_to_save = {
"tab_password": settings.get("tab_password", ""),
"current_profile": "",
"patterns": settings.get("patterns", {})
}
with open("settings.json", 'w') as f:
json.dump(settings_to_save, f, indent=4)
# Remove the profile file
os.remove(profile_path)
return {"success": True}
@app.route('/api/profiles/<profile_name>', methods=['POST'])
def load_profile(req, profile_name):
"""Load a profile."""
profile_path = os.path.join("profiles", f"{profile_name}.json")
if not os.path.exists(profile_path):
return {"error": f"Profile '{profile_name}' not found"}, 404
with open(profile_path, 'r') as file:
profile_data = json.load(file)
# Update settings with profile data
profile_data.pop("current_profile", None)
patterns_backup = settings.get("patterns", {})
tab_password_backup = settings.get("tab_password", "")
settings.update(profile_data)
settings["patterns"] = patterns_backup
settings["current_profile"] = profile_name
# Ensure color_palette exists (default to empty array if not in profile)
if "color_palette" not in settings:
settings["color_palette"] = []
settings_to_save = {
"tab_password": tab_password_backup,
"current_profile": profile_name,
"patterns": patterns_backup
}
with open("settings.json", 'w') as f:
json.dump(settings_to_save, f, indent=4)
return {"success": True}
@app.route('/api/profiles/<profile_name>/palette', methods=['GET'])
def get_profile_palette(req, profile_name):
"""Get the color palette for a profile."""
profile_path = os.path.join("profiles", f"{profile_name}.json")
if not os.path.exists(profile_path):
return {"error": f"Profile '{profile_name}' not found"}, 404
with open(profile_path, 'r') as file:
profile_data = json.load(file)
palette = profile_data.get("color_palette", [])
return {"color_palette": palette}
@app.route('/api/profiles/<profile_name>/palette', methods=['POST'])
def update_profile_palette(req, profile_name):
"""Update the color palette for a profile."""
data = req.json
color_palette = data.get("color_palette", [])
profile_path = os.path.join("profiles", f"{profile_name}.json")
if not os.path.exists(profile_path):
return {"error": f"Profile '{profile_name}' not found"}, 404
with open(profile_path, 'r') as file:
profile_data = json.load(file)
profile_data["color_palette"] = color_palette
with open(profile_path, 'w') as file:
json.dump(profile_data, file, indent=4)
# Update current settings if this is the active profile
if settings.get("current_profile") == profile_name:
settings["color_palette"] = color_palette
return {"success": True, "color_palette": color_palette}
@app.route('/api/profiles/<profile_name>/save', methods=['POST'])
def save_profile(req, profile_name):
"""Save current state to a profile."""
# Save current state to the specified profile
save_current_profile()
# If saving to a different profile, switch to it
if profile_name != settings.get("current_profile"):
settings["current_profile"] = profile_name
settings.save()
save_current_profile()
return {"success": True}
# Serve static files
@app.route('/static/<path:path>')
def serve_static(req, path):
"""Serve static files."""
return Response.send_file(f'static/{path}', base_path='.')
def init_websocket():
"""Initialize WebSocket connection in background."""
global websocket_client
if websocket_client is None:
websocket_client = WebSocketClient(websocket_uri)
run_async(websocket_client.connect())
if __name__ == '__main__':
# Initialize WebSocket connection
init_websocket()
print("Starting Lighting Controller Web App with Microdot...")
print("Open http://localhost:5000 in your browser")
app.run(host='0.0.0.0', port=5000, debug=True)

View File

@@ -1,570 +1,105 @@
import mido
import asyncio
import networking
import socket
import json
import logging # Added logging import
import time # Added for initial state read
import tkinter as tk
from tkinter import ttk, messagebox # Import messagebox for confirmations
from bar_config import LED_BAR_NAMES, DEFAULT_BAR_SETTINGS
# Pattern name mapping for shorter JSON payloads
PATTERN_NAMES = {
"flicker": "f",
"fill_range": "fr",
"n_chase": "nc",
"alternating": "a",
"pulse": "p",
"rainbow": "r",
"specto": "s",
"radiate": "rd",
}
import time
import networking # <--- This will now correctly import your module
# Configure logging
DEBUG_MODE = True # Set to False for INFO level logging
logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
async def midi_to_websocket_listener(midi_port_index: int, websocket_uri: str):
"""
Listens to a specific MIDI port and sends data to a WebSocket server
when Note 32 (and 33) is pressed.
"""
delay = 100 # Default delay value
# TCP Server Configuration
TCP_HOST = "127.0.0.1"
TCP_PORT = 65432
# Sound Control Server Configuration (for sending reset)
SOUND_CONTROL_HOST = "127.0.0.1"
SOUND_CONTROL_PORT = 65433
class MidiHandler:
def __init__(self, midi_port_index: int, websocket_uri: str):
self.midi_port_index = midi_port_index
self.websocket_uri = websocket_uri
self.ws_client = networking.WebSocketClient(websocket_uri)
self.delay = 100 # Default delay value, controlled by MIDI controller
self.brightness = 100 # Default brightness value, controlled by MIDI controller
self.tcp_host = TCP_HOST
self.tcp_port = TCP_PORT
self.beat_sending_enabled = True # New: Local flag for beat sending
self.sound_control_host = SOUND_CONTROL_HOST
self.sound_control_port = SOUND_CONTROL_PORT
# RGB controlled by CC 30/31/32 (default green)
self.color_r = 0
self.color_g = 255
self.color_b = 0
# Generic parameters controlled via CC
# Raw CC-driven parameters (0-127)
self.n1 = 10
self.n2 = 10
self.n3 = 1
# Additional knobs (CC38-45)
self.knob1 = 0
self.knob2 = 0
self.knob3 = 0
self.knob4 = 0
self.knob5 = 0
self.knob6 = 0
self.knob7 = 0
self.knob8 = 0
# Current state for GUI display
self.current_bpm: float | None = None
self.current_pattern: str = ""
self.beat_index: int = 0
# Rate limiting for parameter updates
self.last_param_update: float = 0.0
self.param_update_interval: float = 0.1 # 100ms minimum between updates
self.pending_param_update: bool = False
# Sequential pulse pattern state
self.sequential_pulse_enabled: bool = False
self.sequential_pulse_step: int = 0
def _current_color_rgb(self) -> tuple:
r = max(0, min(255, int(self.color_r)))
g = max(0, min(255, int(self.color_g)))
b = max(0, min(255, int(self.color_b)))
return (r, g, b)
async def _handle_sequential_pulse(self):
"""Handle sequential pulse pattern: each bar pulses for 1 beat, then next bar, mirrored"""
from bar_config import LEFT_BARS, RIGHT_BARS
# Calculate which bar should pulse based on beat (1 beat per bar)
bar_index = self.beat_index % 4 # 0-3, cycles every 4 beats
# Create minimal payload - defaults to off
payload = {
"d": { # Defaults - off for all bars
"t": "b", # Message type: beat
"pt": "o", # off
}
}
# Set specific bars to pulse
left_bar = LEFT_BARS[bar_index]
right_bar = RIGHT_BARS[bar_index]
payload[left_bar] = {"pt": "p"} # pulse
payload[right_bar] = {"pt": "p"} # pulse
# logging.debug(f"[Sequential Pulse] Beat {self.beat_index}, pulsing bars {left_bar} and {right_bar}")
await self.ws_client.send_data(payload)
async def _handle_alternating_phase(self):
"""Handle alternating pattern with phase offset: every second bar uses different step"""
from bar_config import LED_BAR_NAMES
# Create minimal payload - same n1/n2 for all bars
payload = {
"d": { # Defaults - pattern and n1/n2
"t": "b", # Message type: beat
"pt": "a", # alternating
"n1": self.n1,
"n2": self.n2,
"s": self.beat_index % 2, # Default step for in-phase bars
}
}
# Set step offset for every second bar (bars 101, 103, 105, 107)
swap_bars = ["101", "103", "105", "107"]
for bar_name in LED_BAR_NAMES:
if bar_name in swap_bars:
# Send step offset for out-of-phase bars
payload[bar_name] = {"s": (self.beat_index + 1) % 2}
else:
# In-phase bars use defaults (no override needed)
payload[bar_name] = {}
# logging.debug(f"[Alternating Phase] Beat {self.beat_index}, step offset for bars {swap_bars}")
await self.ws_client.send_data(payload)
async def _send_full_parameters(self):
"""Send all parameters to bars - may require multiple packets due to size limit"""
from bar_config import LED_BAR_NAMES
# Calculate packet size for full parameters
full_payload = {
"d": {
"t": "u", # Message type: update
"pt": PATTERN_NAMES.get(self.current_pattern, self.current_pattern),
"dl": self.delay,
"cl": [self._current_color_rgb()],
"br": self.brightness,
"n1": self.n1,
"n2": self.n2,
"n3": self.n3,
"s": self.beat_index % 256, # Use full range for rainbow patterns
}
}
# Estimate size: ~200 bytes for defaults + 8 bars * 2 bytes = ~216 bytes
# This should fit in one packet, but let's be safe
payload_size = len(str(full_payload))
if payload_size > 200: # Split into 2 packets if too large
# Packet 1: Pattern and timing parameters
payload1 = {
"d": {
"t": "u", # Message type: update
"pt": PATTERN_NAMES.get(self.current_pattern, self.current_pattern),
"dl": self.delay,
"br": self.brightness,
}
}
for bar_name in LED_BAR_NAMES:
payload1[bar_name] = {}
# Packet 2: Color and pattern parameters
payload2 = {
"d": {
"t": "u", # Message type: update
"cl": [self._current_color_rgb()],
"n1": self.n1,
"n2": self.n2,
"n3": self.n3,
"s": self.beat_index % 2, # Keep step small (0 or 1) for alternating patterns
}
}
for bar_name in LED_BAR_NAMES:
payload2[bar_name] = {}
# logging.debug(f"[Full Params] Sending in 2 packets due to size ({payload_size} bytes)")
await self.ws_client.send_data(payload1)
await asyncio.sleep(0.01) # Small delay between packets
await self.ws_client.send_data(payload2)
else:
# Single packet
for bar_name in LED_BAR_NAMES:
full_payload[bar_name] = {}
# logging.debug(f"[Full Params] Sending single packet ({payload_size} bytes)")
await self.ws_client.send_data(full_payload)
async def _request_param_update(self):
"""Request a parameter update with rate limiting"""
import time
current_time = time.time()
if current_time - self.last_param_update >= self.param_update_interval:
# Can send immediately
self.last_param_update = current_time
await self._send_full_parameters()
# logging.debug("[Rate Limit] Parameter update sent immediately")
else:
# Rate limited - mark as pending
self.pending_param_update = True
# logging.debug("[Rate Limit] Parameter update queued (rate limited)")
async def _send_normal_pattern(self):
"""Send normal pattern to all bars - include required parameters"""
# Patterns that need specific parameters
patterns_needing_params = ["alternating", "flicker", "n_chase", "rainbow", "radiate"]
payload = {
"d": { # Defaults
"t": "b", # Message type: beat
"pt": PATTERN_NAMES.get(self.current_pattern, self.current_pattern),
}
}
# Add required parameters for patterns that need them
if self.current_pattern in patterns_needing_params:
payload["d"].update({
"n1": self.n1,
"n2": self.n2,
"n3": self.n3,
"dl": self.delay,
"s": self.beat_index % 256, # Use full range for rainbow patterns
})
# Add empty entries for each bar (they'll use defaults)
for bar_name in LED_BAR_NAMES:
payload[bar_name] = {}
# logging.debug(f"[Beat] Triggering '{self.current_pattern}' for {len(LED_BAR_NAMES)} bars")
await self.ws_client.send_data(payload)
async def _send_reset_to_sound(self):
try:
reader, writer = await asyncio.open_connection(self.sound_control_host, self.sound_control_port)
cmd = "RESET_TEMPO\n".encode('utf-8')
writer.write(cmd)
await writer.drain()
resp = await reader.read(100)
logging.info(f"[MidiHandler - Control] Sent RESET_TEMPO, response: {resp.decode().strip()}")
writer.close()
await writer.wait_closed()
except Exception as e:
logging.error(f"[MidiHandler - Control] Failed to send RESET_TEMPO: {e}")
async def _handle_tcp_client(self, reader, writer):
addr = writer.get_extra_info('peername')
logging.info(f"[MidiHandler - TCP Server] Connected by {addr}") # Changed to info
try:
while True:
data = await reader.read(4096) # Read up to 4KB of data
if not data:
logging.info(f"[MidiHandler - TCP Server] Client {addr} disconnected.") # Changed to info
break
message = data.decode().strip()
# logging.debug(f"[MidiHandler - TCP Server] Received from {addr}: {message}") # Changed to debug
if self.beat_sending_enabled:
try:
# Attempt to parse as float (BPM) from sound.py
bpm_value = float(message)
self.current_bpm = bpm_value
# On each beat, trigger currently selected pattern(s)
if not self.current_pattern:
pass # No pattern selected yet; ignoring beat
else:
self.beat_index = (self.beat_index + 1) % 1000000
# Send periodic parameter updates every 8 beats
if self.beat_index % 8 == 0:
await self._send_full_parameters()
# Check for pending parameter updates (rate limited)
if self.pending_param_update:
import time
current_time = time.time()
if current_time - self.last_param_update >= self.param_update_interval:
self.last_param_update = current_time
self.pending_param_update = False
await self._send_full_parameters()
# logging.debug("[Rate Limit] Pending parameter update sent")
if self.current_pattern == "sequential_pulse":
# Sequential pulse pattern: each bar pulses for 1 beat, then next bar, mirrored
await self._handle_sequential_pulse()
elif self.current_pattern == "alternating_phase":
# Alternating pattern with phase offset: every second bar is out of phase
await self._handle_alternating_phase()
elif self.current_pattern:
# Normal pattern mode - run on all bars
await self._send_normal_pattern()
except ValueError:
logging.warning(f"[MidiHandler - TCP Server] Received non-BPM message from {addr}, not forwarding: {message}") # Changed to warning
except Exception as e:
logging.error(f"[MidiHandler - TCP Server] Error processing received message from {addr}: {e}") # Changed to error
else:
pass # Beat sending disabled
except asyncio.CancelledError:
logging.info(f"[MidiHandler - TCP Server] Client handler for {addr} cancelled.") # Changed to info
except Exception as e:
logging.error(f"[MidiHandler - TCP Server] Error handling client {addr}: {e}") # Changed to error
finally:
logging.info(f"[MidiHandler - TCP Server] Closing connection for {addr}") # Changed to info
writer.close()
await writer.wait_closed()
async def _midi_tcp_server(self):
server = await asyncio.start_server(
lambda r, w: self._handle_tcp_client(r, w), self.tcp_host, self.tcp_port)
addrs = ', '.join(str(sock.getsockname()) for sock in server.sockets)
logging.info(f"[MidiHandler - TCP Server] Serving on {addrs}") # Changed to info
async with server:
await server.serve_forever()
async def _read_initial_cc_state(self, port, timeout_s: float = 0.5):
"""Read initial CC values from the MIDI device for a short period to populate state."""
start = time.time()
while time.time() - start < timeout_s:
msg = port.receive(block=False)
if msg and msg.type == 'control_change':
if msg.control == 36:
self.n3 = max(1, msg.value)
logging.info(f"[Init] n3 set to {self.n3} from CC36")
elif msg.control == 37:
self.delay = msg.value * 4
logging.info(f"[Init] Delay set to {self.delay} ms from CC37")
elif msg.control == 39:
self.delay = msg.value * 4
logging.info(f"[Init] Delay set to {self.delay} ms from CC39")
elif msg.control == 33:
self.brightness = round((msg.value / 127) * 100)
logging.info(f"[Init] Brightness set to {self.brightness} from CC33")
elif msg.control == 30:
self.color_r = round((msg.value / 127) * 255)
logging.info(f"[Init] Red set to {self.color_r} from CC30")
elif msg.control == 31:
self.color_g = round((msg.value / 127) * 255)
logging.info(f"[Init] Green set to {self.color_g} from CC31")
elif msg.control == 32:
self.color_b = round((msg.value / 127) * 255)
logging.info(f"[Init] Blue set to {self.color_b} from CC32")
elif msg.control == 34:
self.n1 = int(msg.value)
logging.info(f"[Init] n1 set to {self.n1} from CC34")
elif msg.control == 35:
self.n2 = int(msg.value)
logging.info(f"[Init] n2 set to {self.n2} from CC35")
elif msg.control == 27:
self.beat_sending_enabled = (msg.value == 127)
logging.info(f"[Init] Beat sending {'ENABLED' if self.beat_sending_enabled else 'DISABLED'} from CC27")
await asyncio.sleep(0.001)
async def _midi_listener(self):
logging.info("Midi function") # Changed to info
"""
Listens to a specific MIDI port and sends data to a WebSocket server
when Note 32 (and 33) is pressed.
"""
# 1. Get MIDI port name
port_names = mido.get_input_names()
if not port_names:
logging.warning("No MIDI input ports found. Please connect your device.") # Changed to warning
return
if not (0 <= self.midi_port_index < len(port_names)):
logging.error(f"Error: MIDI port index {self.midi_port_index} out of range. Available ports: {port_names}") # Changed to error
logging.info("Available ports:") # Changed to info
for i, name in enumerate(port_names):
logging.info(f" {i}: {name}") # Changed to info
return
midi_port_name = port_names[self.midi_port_index]
logging.info(f"Selected MIDI input port: {midi_port_name}") # Changed to info
try:
with mido.open_input(midi_port_name) as port:
logging.info(f"MIDI port '{midi_port_name}' opened. Press Ctrl+C to stop.") # Changed to info
# Read initial controller state briefly
await self._read_initial_cc_state(port)
while True:
msg = port.receive(block=False) # Non-blocking read
if msg:
# logging.debug(msg) # Changed to debug
match msg.type:
case 'note_on':
# logging.debug(f" Note ON: Note={msg.note}, Velocity={msg.velocity}, Channel={msg.channel}") # Changed to debug
# Bank1 patterns starting at MIDI note 36
pattern_bindings: list[str] = [
# Pulse patterns (row 1)
"pulse",
"sequential_pulse",
# Alternating patterns (row 2)
"alternating",
"alternating_phase",
# Chase/movement patterns (row 3)
"n_chase",
"rainbow",
# Effect patterns (row 4)
"flicker",
"radiate",
]
idx = msg.note - 36
if 0 <= idx < len(pattern_bindings):
pattern_name = pattern_bindings[idx]
self.current_pattern = pattern_name
logging.info(f"[Select] Pattern selected via note {msg.note}: {self.current_pattern} (n1={self.n1}, n2={self.n2})")
# Send full parameters when pattern changes
await self._send_full_parameters()
else:
pass # Note not bound to patterns
case 'control_change':
match msg.control:
case 36:
self.n3 = max(1, msg.value) # Update n3 step rate
logging.info(f"n3 set to {self.n3} by MIDI controller (CC36)")
await self._request_param_update()
case 37:
self.delay = msg.value * 4 # Update instance delay
logging.info(f"Delay set to {self.delay} ms by MIDI controller (CC37)")
await self._request_param_update()
case 38:
self.n1 = msg.value # pulse n1 for pulse patterns
logging.info(f"Pulse n1 set to {self.n1} by MIDI controller (CC38)")
await self._request_param_update()
case 39:
self.n2 = msg.value # pulse n2 for pulse patterns
logging.info(f"Pulse n2 set to {self.n2} by MIDI controller (CC39)")
await self._request_param_update()
case 40:
self.n1 = msg.value # n1 for alternating patterns
logging.info(f"Alternating n1 set to {self.n1} by MIDI controller (CC40)")
await self._request_param_update()
case 41:
self.n2 = msg.value # n2 for alternating patterns
logging.info(f"Alternating n2 set to {self.n2} by MIDI controller (CC41)")
await self._request_param_update()
case 42:
self.n1 = msg.value # radiate n1 for radiate patterns
logging.info(f"Radiate n1 set to {self.n1} by MIDI controller (CC42)")
await self._request_param_update()
case 43:
self.delay = msg.value * 4 # delay for radiate patterns
logging.info(f"Delay set to {self.delay} ms by MIDI controller (CC43)")
await self._request_param_update()
case 44:
self.knob7 = msg.value
logging.info(f"Knob7 set to {self.knob7} by MIDI controller (CC44)")
await self._request_param_update()
case 45:
self.knob8 = msg.value
logging.info(f"Knob8 set to {self.knob8} by MIDI controller (CC45)")
await self._request_param_update()
case 27:
if msg.value == 127:
self.beat_sending_enabled = True
logging.info("[MidiHandler - Listener] Beat sending ENABLED by MIDI control.") # Changed to info
elif msg.value == 0:
self.beat_sending_enabled = False
logging.info("[MidiHandler - Listener] Beat sending DISABLED by MIDI control.") # Changed to info
case 29:
if msg.value == 127:
logging.info("[MidiHandler - Listener] RESET_TEMPO requested by control 29.")
await self._send_reset_to_sound()
case 33:
# Map 0-127 to 0-100 brightness scale
self.brightness = round((msg.value / 127) * 100)
logging.info(f"Brightness set to {self.brightness} by MIDI controller (CC33)")
await self._request_param_update()
case 30:
# Red 0-127 -> 0-255
self.color_r = round((msg.value / 127) * 255)
logging.info(f"Red set to {self.color_r}")
await self._request_param_update()
case 31:
# Green 0-127 -> 0-255
self.color_g = round((msg.value / 127) * 255)
logging.info(f"Green set to {self.color_g}")
await self._request_param_update()
case 32:
# Blue 0-127 -> 0-255
self.color_b = round((msg.value / 127) * 255)
logging.info(f"Blue set to {self.color_b}")
await self._request_param_update()
case 34:
self.n1 = int(msg.value)
logging.info(f"n1 set to {self.n1} by MIDI controller (CC34)")
await self._request_param_update()
case 35:
self.n2 = int(msg.value)
logging.info(f"n2 set to {self.n2} by MIDI controller (CC35)")
await self._request_param_update()
await asyncio.sleep(0.001) # Important: Yield control to asyncio event loop
except mido.PortsError as e:
logging.error(f"Error opening MIDI port '{midi_port_name}': {e}") # Changed to error
except asyncio.CancelledError:
logging.info(f"MIDI listener cancelled.") # Changed to info
except Exception as e:
logging.error(f"An unexpected error occurred in MIDI listener: {e}") # Changed to error
async def run(self):
try:
await self.ws_client.connect()
logging.info(f"[MidiHandler] WebSocket client connected to {self.ws_client.uri}") # Changed to info
# List available MIDI ports for debugging
print(f"Available MIDI input ports: {mido.get_input_names()}")
print(f"Trying to open MIDI port index {self.midi_port_index}")
await asyncio.gather(
self._midi_listener(),
self._midi_tcp_server()
)
except mido.PortsError as e:
logging.error(f"[MidiHandler] Error opening MIDI port: {e}") # Changed to error
print(f"MIDI Port Error: {e}")
print(f"Available MIDI ports: {mido.get_input_names()}")
print("Please check your MIDI device connection and port index")
except asyncio.CancelledError:
logging.info("[MidiHandler] Tasks cancelled due to program shutdown.") # Changed to info
except KeyboardInterrupt:
logging.info("\n[MidiHandler] Program interrupted by user.") # Changed to info
finally:
logging.info("[MidiHandler] Main program finished. Closing WebSocket client...") # Changed to info
await self.ws_client.close()
logging.info("[MidiHandler] WebSocket client closed.") # Changed to info
def print_midi_ports():
logging.info("\n--- Available MIDI Input Ports ---") # Changed to info
# 1. Get MIDI port name
port_names = mido.get_input_names()
if not port_names:
logging.warning("No MIDI input ports found.") # Changed to warning
else:
print("No MIDI input ports found. Please connect your device.")
return
if not (0 <= midi_port_index < len(port_names)):
print(f"Error: MIDI port index {midi_port_index} out of range. Available ports: {port_names}")
print("Available ports:")
for i, name in enumerate(port_names):
logging.info(f" {i}: {name}") # Changed to info
logging.info("----------------------------------") # Changed to info
print(f" {i}: {name}")
return
midi_port_name = port_names[midi_port_index]
print(f"Selected MIDI input port: {midi_port_name}")
# 2. Initialize WebSocket client (using your actual networking.py)
ws_client = networking.WebSocketClient(websocket_uri)
try:
# 3. Connect WebSocket
await ws_client.connect()
print(f"WebSocket client connected to {ws_client.uri}")
# 4. Open MIDI port and start listening loop
with mido.open_input(midi_port_name) as port:
print(f"MIDI port '{midi_port_name}' opened. Press Ctrl+C to stop.")
while True:
msg = port.receive(block=False) # Non-blocking read
if msg:
match msg.type:
case 'note_on':
print(f" Note ON: Note={msg.note}, Velocity={msg.velocity}, Channel={msg.channel}")
match msg.note:
case 32:
await ws_client.send_data({
"names": ["1"],
"settings": {
"pattern": "pulse",
"delay": delay,
"colors": ["#00ff00"],
"brightness": 100,
"num_leds": 200,
}
})
case 33:
await ws_client.send_data({
"names": ["2"],
"settings": {
"pattern": "chase",
"speed": 10,
"color": "#00FFFF",
}
})
case 'control_change':
match msg.control:
case 36:
delay = msg.value * 4
print(f"Delay set to {delay} ms")
await asyncio.sleep(0.001) # Important: Yield control to asyncio event loop
except mido.PortsError as e:
print(f"Error opening MIDI port '{midi_port_name}': {e}")
except asyncio.CancelledError:
print(f"MIDI listener cancelled.")
except Exception as e:
print(f"An unexpected error occurred: {e}")
finally:
# 5. Disconnect WebSocket and clean up
# This assumes your WebSocketClient has a ._connected attribute or similar way to check state.
# If your client's disconnect method is safe to call even if not connected, you can simplify.
await ws_client.close()
print("MIDI listener stopped and cleaned up.")
async def main():
print_midi_ports()
# --- Configuration ---
MIDI_PORT_INDEX = 1 # <--- IMPORTANT: Change this to the correct index for your device
WEBSOCKET_SERVER_URI = "ws://192.168.4.1:80/ws"
# --- End Configuration ---
midi_handler = MidiHandler(MIDI_PORT_INDEX, WEBSOCKET_SERVER_URI)
await midi_handler.run()
try:
await midi_to_websocket_listener(MIDI_PORT_INDEX, WEBSOCKET_SERVER_URI)
except KeyboardInterrupt:
print("\nProgram interrupted by user.")
finally:
print("Main program finished.")
if __name__ == "__main__":
asyncio.run(main())

67
src/settings.py Normal file
View File

@@ -0,0 +1,67 @@
import json
class Settings(dict):
SETTINGS_FILE = "settings.json"
def __init__(self):
super().__init__()
self.load() # Load settings from file during initialization
def save(self):
try:
# Create a copy without lights and tab_order (these belong in profiles, not settings.json)
# But keep patterns, tab_password, and current_profile
settings_to_save = {k: v for k, v in self.items() if k not in ["lights", "tab_order"]}
# Ensure patterns are always included if they exist
if "patterns" in self:
settings_to_save["patterns"] = self["patterns"]
j = json.dumps(settings_to_save, indent=4)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
print("Settings saved successfully.")
except Exception as e:
print(f"Error saving settings: {e}")
def load(self):
try:
with open(self.SETTINGS_FILE, 'r') as file:
loaded_settings = json.load(file)
self.update(loaded_settings)
# Ensure patterns exist (they should always be in settings.json)
if "patterns" not in self:
# Initialize with default patterns if missing
self["patterns"] = {
"on": {"min_delay": 10, "max_delay": 10000},
"off": {"min_delay": 10, "max_delay": 10000},
"rainbow": {"Step Rate": "n1", "min_delay": 10, "max_delay": 10000},
"transition": {"min_delay": 10, "max_delay": 10000},
"chase": {
"Colour 1 Length": "n1",
"Colour 2 Length": "n2",
"Step 1": "n3",
"Step 2": "n4",
"min_delay": 10,
"max_delay": 10000
},
"pulse": {
"Attack": "n1",
"Hold": "n2",
"Decay": "n3",
"min_delay": 10,
"max_delay": 10000
},
"circle": {
"Head Rate": "n1",
"Max Length": "n2",
"Tail Rate": "n3",
"Min Length": "n4",
"min_delay": 10,
"max_delay": 10000
},
"blink": {"min_delay": 10, "max_delay": 10000}
}
self.save() # Save to persist the default patterns
print("Settings loaded successfully.")
except Exception as e:
print(f"Error loading settings {e}")
self.save()

View File

@@ -4,207 +4,121 @@ import pyaudio
import aubio
import numpy as np
from time import sleep
import websocket # pip install websocket-client
import json
import socket
import time
import logging # Added logging import
import asyncio # Re-added asyncio import
import threading # Added threading for control server
# Configure logging
DEBUG_MODE = True # Set to False for INFO level logging
logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
seconds = 10 # how long this script should run (if not using infinite loop)
# TCP Server Configuration (assuming midi.py runs this)
MIDI_TCP_HOST = "127.0.0.1"
MIDI_TCP_PORT = 65432
bufferSize = 512
windowSizeMultiple = 2 # or 4 for higher accuracy, but more computational cost
# Sound Control Server Configuration (for midi.py to control sound.py)
SOUND_CONTROL_HOST = "127.0.0.1"
SOUND_CONTROL_PORT = 65433
audioInputDeviceIndex = 7 # use 'arecord -l' to check available audio devices
audioInputChannels = 1
class SoundBeatDetector:
def __init__(self, tcp_host: str, tcp_port: int):
self.tcp_host = tcp_host
self.tcp_port = tcp_port
self.tcp_socket = None
self.connected_to_midi = False
self.reconnect_delay = 1 # seconds
# Note: beat_sending_enabled is not used in this simplified flow
pa = pyaudio.PyAudio()
self.bufferSize = 512
self.windowSizeMultiple = 2
self.audioInputDeviceIndex = 7
self.audioInputChannels = 1
print("Available audio input devices:")
info = pa.get_host_api_info_by_index(0)
num_devices = info.get('deviceCount')
found_device = False
for i in range(0, num_devices):
device_info = pa.get_device_info_by_host_api_device_index(0, i)
if (device_info.get('maxInputChannels')) > 0:
print(f" Input Device id {i} - {device_info.get('name')}")
if i == audioInputDeviceIndex:
found_device = True
self.pa = pyaudio.PyAudio()
if not found_device:
print(f"Warning: Audio input device index {audioInputDeviceIndex} not found or has no input channels.")
# Consider exiting or picking a default if necessary
logging.info("Available audio input devices:")
info = self.pa.get_host_api_info_by_index(0)
num_devices = info.get('deviceCount')
found_device = False
for i in range(0, num_devices):
device_info = self.pa.get_device_info_by_host_api_device_index(0, i)
if (device_info.get('maxInputChannels')) > 0:
logging.info(f" Input Device id {i} - {device_info.get('name')}")
if i == self.audioInputDeviceIndex:
found_device = True
try:
audioInputDevice = pa.get_device_info_by_index(audioInputDeviceIndex)
audioInputSampleRate = int(audioInputDevice['defaultSampleRate'])
except Exception as e:
print(f"Error getting audio device info for index {audioInputDeviceIndex}: {e}")
pa.terminate()
exit()
if not found_device:
logging.warning(f"Audio input device index {self.audioInputDeviceIndex} not found or has no input channels.")
# create the aubio tempo detection:
hopSize = bufferSize
winSize = hopSize * windowSizeMultiple
tempoDetection = aubio.tempo(method='default', buf_size=winSize, hop_size=hopSize, samplerate=audioInputSampleRate)
try:
audioInputDevice = self.pa.get_device_info_by_index(self.audioInputDeviceIndex)
self.audioInputSampleRate = int(audioInputDevice['defaultSampleRate'])
except Exception as e:
logging.error(f"Error getting audio device info for index {self.audioInputDeviceIndex}: {e}")
self.pa.terminate()
exit()
# --- WebSocket Setup ---
websocket_url = "ws://192.168.4.1:80/ws"
ws = None
try:
ws = websocket.create_connection(websocket_url)
print(f"Successfully connected to WebSocket at {websocket_url}")
except Exception as e:
print(f"Failed to connect to WebSocket: {e}. Data will not be sent over WebSocket.")
# --- End WebSocket Setup ---
self.hopSize = self.bufferSize
self.winSize = self.hopSize * self.windowSizeMultiple
self.tempoDetection = aubio.tempo(method='default', buf_size=self.winSize, hop_size=self.hopSize, samplerate=self.audioInputSampleRate)
# this function gets called by the input stream, as soon as enough samples are collected from the audio input:
def readAudioFrames(in_data, frame_count, time_info, status):
global ws # Allow modification of the global ws variable
self.inputStream = None
self._control_thread = None
signal = np.frombuffer(in_data, dtype=np.float32)
self._connect_to_midi_server()
self._start_control_server() # Start control server in background
beat = tempoDetection(signal)
if beat:
bpm = tempoDetection.get_bpm()
print(f"beat! (running with {bpm:.2f} bpm)") # Use f-string for cleaner formatting, removed extra bells
data_to_send = {
"names": ["1"],
"settings": {
"pattern": "pulse",
"delay": 10,
"colors": ["#00ff00"],
"brightness": 10,
"num_leds": 200,
},
}
def reset_tempo_detection(self):
"""Re-initializes the aubio tempo detection object."""
logging.info("[SoundBeatDetector] Resetting tempo detection.")
self.tempoDetection = aubio.tempo(method='default', buf_size=self.winSize, hop_size=self.hopSize, samplerate=self.audioInputSampleRate)
def _control_server_loop(self):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((SOUND_CONTROL_HOST, SOUND_CONTROL_PORT))
srv.listen(5)
logging.info(f"[SoundBeatDetector - Control] Listening on {SOUND_CONTROL_HOST}:{SOUND_CONTROL_PORT}")
while True:
conn, addr = srv.accept()
with conn:
logging.info(f"[SoundBeatDetector - Control] Connection from {addr}")
try:
data = conn.recv(1024)
if not data:
continue
command = data.decode().strip()
logging.debug(f"[SoundBeatDetector - Control] Received command: {command}")
if command == "RESET_TEMPO":
self.reset_tempo_detection()
response = "OK: Tempo reset\n"
else:
response = "ERROR: Unknown command\n"
conn.sendall(response.encode('utf-8'))
except Exception as e:
logging.error(f"[SoundBeatDetector - Control] Error handling control message: {e}")
except Exception as e:
logging.error(f"[SoundBeatDetector - Control] Server error: {e}")
finally:
if ws: # Only send if the websocket connection is established
try:
srv.close()
except Exception:
pass
ws.send(json.dumps(data_to_send))
# print("Sent data over WebSocket") # Optional: for debugging
except websocket.WebSocketConnectionClosedException:
print("WebSocket connection closed, attempting to reconnect...")
ws = None # Mark as closed, connection will need to be re-established if desired
except Exception as e:
print(f"Error sending over WebSocket: {e}")
def _start_control_server(self):
if self._control_thread and self._control_thread.is_alive():
return
self._control_thread = threading.Thread(target=self._control_server_loop, daemon=True)
self._control_thread.start()
return (in_data, pyaudio.paContinue)
def _connect_to_midi_server(self):
if self.tcp_socket:
self.tcp_socket.close()
self.tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.tcp_socket.settimeout(self.reconnect_delay)
try:
logging.info(f"[SoundBeatDetector] Attempting to connect to MIDI TCP server at {self.tcp_host}:{self.tcp_port}...")
self.tcp_socket.connect((self.tcp_host, self.tcp_port))
self.tcp_socket.setblocking(0)
self.connected_to_midi = True
logging.info(f"[SoundBeatDetector] Successfully connected to MIDI TCP server.")
except (socket.error, socket.timeout) as e:
logging.error(f"[SoundBeatDetector] Failed to connect to MIDI TCP server: {e}")
self.connected_to_midi = False
# Removed _handle_control_client and _control_server (replaced by simple threaded server)
# create and start the input stream
try:
inputStream = pa.open(format=pyaudio.paFloat32,
input=True,
channels=audioInputChannels,
input_device_index=audioInputDeviceIndex,
frames_per_buffer=bufferSize,
rate=audioInputSampleRate,
stream_callback=readAudioFrames)
def readAudioFrames(self, in_data, frame_count, time_info, status):
signal = np.frombuffer(in_data, dtype=np.float32)
inputStream.start_stream()
print("\nAudio stream started. Detecting beats. Press Ctrl+C to stop.")
beat = self.tempoDetection(signal)
if beat:
bpm = self.tempoDetection.get_bpm()
logging.debug(f"beat! (running with {bpm:.2f} bpm)") # Changed to debug
bpm_message = str(bpm)
# Loop to keep the script running, allowing graceful shutdown
while inputStream.is_active():
sleep(0.1) # Small delay to prevent busy-waiting
if self.connected_to_midi and self.tcp_socket:
try:
message_bytes = (bpm_message + "\n").encode('utf-8')
self.tcp_socket.sendall(message_bytes)
logging.debug(f"[SoundBeatDetector] Sent BPM to MIDI TCP server: {bpm_message}") # Changed to debug
except socket.error as e:
logging.error(f"[SoundBeatDetector] Error sending BPM to MIDI TCP server: {e}. Attempting to reconnect...")
self.connected_to_midi = False
self._connect_to_midi_server()
elif not self.connected_to_midi:
logging.warning("[SoundBeatDetector] Not connected to MIDI TCP server, attempting to reconnect...") # Changed to warning
self._connect_to_midi_server()
else:
logging.warning("[SoundBeatDetector] TCP socket not initialized, cannot send BPM.") # Changed to warning
except KeyboardInterrupt:
print("\nKeyboardInterrupt: Stopping script gracefully.")
except Exception as e:
print(f"An error occurred with the audio stream: {e}")
finally:
# Ensure streams and resources are closed
if 'inputStream' in locals() and inputStream.is_active():
inputStream.stop_stream()
if 'inputStream' in locals() and not inputStream.is_stopped():
inputStream.close()
pa.terminate()
if ws:
print("Closing WebSocket connection.")
ws.close()
return (in_data, pyaudio.paContinue)
def start_stream(self):
try:
self.inputStream = self.pa.open(format=pyaudio.paFloat32,
input=True,
channels=self.audioInputChannels,
input_device_index=self.audioInputDeviceIndex,
frames_per_buffer=self.bufferSize,
rate=self.audioInputSampleRate,
stream_callback=self.readAudioFrames)
self.inputStream.start_stream()
logging.info("\nAudio stream started. Detecting beats. Press Ctrl+C to stop.")
while self.inputStream.is_active():
sleep(0.1)
except KeyboardInterrupt:
logging.info("\nKeyboardInterrupt: Stopping script gracefully.")
except Exception as e:
logging.error(f"An error occurred with the audio stream: {e}")
finally:
self.stop_stream()
def stop_stream(self):
if self.inputStream and self.inputStream.is_active():
self.inputStream.stop_stream()
if self.inputStream and not self.inputStream.is_stopped():
self.inputStream.close()
self.pa.terminate()
if self.tcp_socket and self.connected_to_midi:
logging.info("[SoundBeatDetector] Closing TCP socket.")
self.tcp_socket.close()
self.connected_to_midi = False
logging.info("SoundBeatDetector stopped.")
# Removed async def run(self)
if __name__ == "__main__":
# TCP Server Configuration (should match midi.py)
MIDI_TCP_HOST = "127.0.0.1"
MIDI_TCP_PORT = 65432
sound_detector = SoundBeatDetector(MIDI_TCP_HOST, MIDI_TCP_PORT)
logging.info("Starting SoundBeatDetector...")
try:
sound_detector.start_stream()
except KeyboardInterrupt:
logging.info("\nProgram interrupted by user.")
except Exception as e:
logging.error(f"An error occurred during main execution: {e}")
print("Script finished.")

View File

@@ -1,602 +0,0 @@
#!/usr/bin/env python3
"""
UI Client for Lighting Controller
Handles the user interface and MIDI controller input.
Communicates with the control server via WebSocket.
"""
import asyncio
import tkinter as tk
from tkinter import ttk, messagebox
import json
import os
import mido
import logging
from async_tkinter_loop import async_handler, async_mainloop
import websockets
import websocket
# Configuration
CONFIG_FILE = "config.json"
CONTROL_SERVER_URI = "ws://localhost:8765"
# Dark theme colors
bg_color = "#2e2e2e"
fg_color = "white"
trough_color_red = "#4a0000"
trough_color_green = "#004a00"
trough_color_blue = "#00004a"
trough_color_brightness = "#4a4a4a"
trough_color_delay = "#4a4a4a"
active_bg_color = "#4a4a4a"
highlight_pattern_color = "#6a5acd"
active_palette_color_border = "#FFD700"
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
class WebSocketClient:
"""WebSocket client for communicating with the control server."""
def __init__(self, uri):
self.uri = uri
self.websocket = None
self.is_connected = False
self.reconnect_task = None
async def connect(self):
"""Establish WebSocket connection to control server."""
if self.is_connected and self.websocket:
return
try:
logging.info(f"Connecting to control server at {self.uri}...")
self.websocket = await websockets.connect(self.uri)
self.is_connected = True
logging.info("Connected to control server")
except Exception as e:
logging.error(f"Failed to connect to control server: {e}")
self.is_connected = False
self.websocket = None
async def send_message(self, message_type, data=None):
"""Send a message to the control server."""
if not self.is_connected or not self.websocket:
logging.warning("Not connected to control server")
return
try:
message = {
"type": message_type,
"data": data or {}
}
await self.websocket.send(json.dumps(message))
logging.debug(f"Sent message: {message}")
except Exception as e:
logging.error(f"Failed to send message: {e}")
self.is_connected = False
async def close(self):
"""Close WebSocket connection."""
if self.websocket and self.is_connected:
await self.websocket.close()
self.is_connected = False
self.websocket = None
logging.info("Disconnected from control server")
class MidiController:
"""Handles MIDI controller input and sends commands to control server."""
def __init__(self, websocket_client):
self.websocket_client = websocket_client
self.midi_port_index = 0
self.available_ports = []
self.midi_port = None
self.midi_task = None
# MIDI state
self.current_pattern = ""
self.delay = 100
self.brightness = 100
self.color_r = 0
self.color_g = 255
self.color_b = 0
self.n1 = 10
self.n2 = 10
self.n3 = 1
self.knob7 = 0
self.knob8 = 0
self.beat_sending_enabled = True
def get_midi_ports(self):
"""Get list of available MIDI input ports."""
try:
return mido.get_input_names()
except Exception as e:
logging.error(f"Error getting MIDI ports: {e}")
return []
def load_midi_preference(self):
"""Load saved MIDI device preference."""
try:
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r') as f:
config = json.load(f)
return config.get('midi_device_index', 0)
except Exception as e:
logging.error(f"Error loading MIDI preference: {e}")
return 0
def save_midi_preference(self):
"""Save current MIDI device preference."""
try:
config = {
'midi_device_index': self.midi_port_index,
'midi_device_name': self.available_ports[self.midi_port_index] if self.available_ports else None
}
with open(CONFIG_FILE, 'w') as f:
json.dump(config, f, indent=2)
except Exception as e:
logging.error(f"Error saving MIDI preference: {e}")
async def initialize_midi(self):
"""Initialize MIDI port connection."""
self.available_ports = self.get_midi_ports()
self.midi_port_index = self.load_midi_preference()
if not self.available_ports:
logging.warning("No MIDI ports available")
return False
if not (0 <= self.midi_port_index < len(self.available_ports)):
self.midi_port_index = 0
try:
port_name = self.available_ports[self.midi_port_index]
self.midi_port = mido.open_input(port_name)
logging.info(f"Connected to MIDI port: {port_name}")
return True
except Exception as e:
logging.error(f"Failed to open MIDI port: {e}")
return False
async def start_midi_listener(self):
"""Start listening for MIDI messages."""
if not self.midi_port:
return
try:
while True:
msg = self.midi_port.receive(block=False)
if msg:
await self.handle_midi_message(msg)
await asyncio.sleep(0.001)
except asyncio.CancelledError:
logging.info("MIDI listener cancelled")
except Exception as e:
logging.error(f"MIDI listener error: {e}")
async def handle_midi_message(self, msg):
"""Handle incoming MIDI message and send to control server."""
if msg.type == 'note_on':
# Pattern selection (notes 36-51)
logging.info(f"MIDI Note {msg.note}: {msg.velocity}")
pattern_bindings = [
"pulse", "sequential_pulse", "alternating", "alternating_phase",
"n_chase", "rainbow", "flicker", "radiate"
]
idx = msg.note - 36
if 0 <= idx < len(pattern_bindings):
self.current_pattern = pattern_bindings[idx]
await self.websocket_client.send_message("pattern_change", {
"pattern": self.current_pattern
})
logging.info(f"Pattern changed to: {self.current_pattern}")
elif msg.type == 'control_change':
# Handle control change messages
control = msg.control
value = msg.value
logging.info(f"MIDI CC {control}: {value}")
if control == 30: # Red
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)
await self.websocket_client.send_message("brightness_change", {
"brightness": self.brightness
})
elif control == 34: # n1
self.n1 = int(value)
await self.websocket_client.send_message("parameter_change", {
"n1": self.n1
})
elif control == 35: # n2
self.n2 = int(value)
await self.websocket_client.send_message("parameter_change", {
"n2": self.n2
})
elif control == 36: # n3
self.n3 = max(1, value)
await self.websocket_client.send_message("parameter_change", {
"n3": self.n3
})
elif control == 37: # Delay
self.delay = value * 4
await self.websocket_client.send_message("delay_change", {
"delay": self.delay
})
elif control == 27: # Beat sending toggle
self.beat_sending_enabled = (value == 127)
await self.websocket_client.send_message("beat_toggle", {
"enabled": self.beat_sending_enabled
})
def close(self):
"""Close MIDI connection."""
if self.midi_port:
self.midi_port.close()
self.midi_port = None
class UIClient:
"""Main UI client application."""
def __init__(self):
self.root = tk.Tk()
self.root.configure(bg=bg_color)
self.root.title("Lighting Controller - UI Client")
# WebSocket client
self.websocket_client = WebSocketClient(CONTROL_SERVER_URI)
# MIDI controller
self.midi_controller = MidiController(self.websocket_client)
# UI state
self.current_pattern = ""
self.delay = 100
self.brightness = 100
self.color_r = 0
self.color_g = 255
self.color_b = 0
self.n1 = 10
self.n2 = 10
self.n3 = 1
self.setup_ui()
self.setup_async_tasks()
def setup_ui(self):
"""Setup the user interface."""
# Configure ttk style
style = ttk.Style()
style.theme_use("alt")
style.configure(".", background=bg_color, foreground=fg_color, font=("Arial", 14))
style.configure("TNotebook", background=bg_color, borderwidth=0)
style.configure("TNotebook.Tab", background=bg_color, foreground=fg_color, font=("Arial", 30), padding=[10, 5])
# MIDI Controller Selection
midi_frame = ttk.LabelFrame(self.root, text="MIDI Controller")
midi_frame.pack(padx=16, pady=8, fill="x")
# MIDI port dropdown
self.midi_port_var = tk.StringVar()
midi_dropdown = ttk.Combobox(
midi_frame,
textvariable=self.midi_port_var,
values=[],
state="readonly",
font=("Arial", 12)
)
midi_dropdown.pack(padx=8, pady=4, fill="x")
midi_dropdown.bind("<<ComboboxSelected>>", self.on_midi_port_change)
# Refresh MIDI ports button
refresh_button = ttk.Button(
midi_frame,
text="Refresh MIDI Ports",
command=self.refresh_midi_ports
)
refresh_button.pack(padx=8, pady=4)
# MIDI connection status
self.midi_status_label = tk.Label(
midi_frame,
text="Status: Disconnected",
bg=bg_color,
fg="red",
font=("Arial", 10)
)
self.midi_status_label.pack(padx=8, pady=2)
# Controls overview
controls_frame = ttk.Frame(self.root)
controls_frame.pack(padx=16, pady=8, fill="both")
# Dials display
dials_frame = ttk.LabelFrame(controls_frame, text="Dials (CC30-37)")
dials_frame.pack(side="left", padx=12)
for c in range(2):
dials_frame.grid_columnconfigure(c, minsize=140)
for rr in range(4):
dials_frame.grid_rowconfigure(rr, minsize=70)
self.dials_boxes = []
placeholders = {
(0, 0): "n3\n-", (0, 1): "Delay\n-",
(1, 0): "n1\n-", (1, 1): "n2\n-",
(2, 0): "B\n-", (2, 1): "Bright\n-",
(3, 0): "R\n-", (3, 1): "G\n-",
}
for r in range(4):
for c in range(2):
lbl = tk.Label(
dials_frame,
text=placeholders.get((r, c), "-"),
bg=bg_color,
fg=fg_color,
font=("Arial", 14),
padx=6, pady=6,
borderwidth=2, relief="ridge",
width=14, height=4,
anchor="center", justify="center",
)
lbl.grid(row=r, column=c, padx=6, pady=6, sticky="nsew")
self.dials_boxes.append(lbl)
# Knobs display
knobs_frame = ttk.LabelFrame(controls_frame, text="Knobs (CC38-45)")
knobs_frame.pack(side="left", padx=12)
for c in range(2):
knobs_frame.grid_columnconfigure(c, minsize=140)
for rr in range(4):
knobs_frame.grid_rowconfigure(rr, minsize=70)
self.knobs_boxes = []
knob_placeholders = {
(0, 0): "CC44\n-", (0, 1): "CC45\n-",
(1, 0): "Rad n1\n-", (1, 1): "Rad delay\n-",
(2, 0): "Alt n1\n-", (2, 1): "Alt n2\n-",
(3, 0): "Pulse n1\n-", (3, 1): "Pulse n2\n-",
}
for r in range(4):
for c in range(2):
lbl = tk.Label(
knobs_frame,
text=knob_placeholders.get((r, c), "-"),
bg=bg_color,
fg=fg_color,
font=("Arial", 14),
padx=6, pady=6,
borderwidth=2, relief="ridge",
width=14, height=4,
anchor="center", justify="center",
)
lbl.grid(row=r, column=c, padx=6, pady=6, sticky="nsew")
self.knobs_boxes.append(lbl)
# Buttons display
buttons_frame = ttk.Frame(controls_frame)
buttons_frame.pack(side="left", padx=12)
buttons1_frame = ttk.LabelFrame(buttons_frame, text="Buttons (notes 36-51)")
buttons1_frame.pack(side="top", pady=8)
for c in range(4):
buttons1_frame.grid_columnconfigure(c, minsize=140)
for rr in range(1, 5):
buttons1_frame.grid_rowconfigure(rr, minsize=70)
self.button1_cells = []
for r in range(4):
for c in range(4):
lbl = tk.Label(
buttons1_frame,
text="",
bg=bg_color,
fg=fg_color,
font=("Arial", 14),
padx=6, pady=6,
borderwidth=2, relief="ridge",
width=14, height=4,
anchor="center", justify="center",
)
lbl.grid(row=1 + (3 - r), column=c, padx=6, pady=6, sticky="nsew")
self.button1_cells.append(lbl)
# Connection status
self.connection_status = tk.Label(
self.root,
text="Control Server: Disconnected",
bg=bg_color,
fg="red",
font=("Arial", 12)
)
self.connection_status.pack(pady=8)
# Schedule periodic UI updates
self.root.after(200, self.update_status_labels)
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
def setup_async_tasks(self):
"""Setup async tasks for WebSocket and MIDI."""
# Connect to control server
self.root.after(100, async_handler(self.websocket_client.connect))
# Initialize MIDI
self.root.after(200, async_handler(self.initialize_midi))
@async_handler
async def initialize_midi(self):
"""Initialize MIDI controller."""
success = await self.midi_controller.initialize_midi()
if success:
# Update UI
self.midi_controller.available_ports = self.midi_controller.get_midi_ports()
if self.midi_controller.available_ports:
self.midi_port_var.set(self.midi_controller.available_ports[self.midi_controller.midi_port_index])
# Update dropdown
for child in self.root.winfo_children():
if isinstance(child, ttk.LabelFrame) and child.cget("text") == "MIDI Controller":
for widget in child.winfo_children():
if isinstance(widget, ttk.Combobox):
widget['values'] = self.midi_controller.available_ports
break
break
self.midi_status_label.config(
text=f"Status: Connected to {self.midi_controller.available_ports[self.midi_controller.midi_port_index]}",
fg="green"
)
# Start MIDI listener
self.midi_controller.midi_task = asyncio.create_task(
self.midi_controller.start_midi_listener()
)
def refresh_midi_ports(self):
"""Refresh MIDI ports list."""
old_ports = self.midi_controller.available_ports.copy()
self.midi_controller.available_ports = self.midi_controller.get_midi_ports()
# Update dropdown
for child in self.root.winfo_children():
if isinstance(child, ttk.LabelFrame) and child.cget("text") == "MIDI Controller":
for widget in child.winfo_children():
if isinstance(widget, ttk.Combobox):
widget['values'] = self.midi_controller.available_ports
if (self.midi_controller.available_ports and
self.midi_port_var.get() not in self.midi_controller.available_ports):
self.midi_port_var.set(self.midi_controller.available_ports[0])
self.midi_controller.midi_port_index = 0
self.midi_controller.save_midi_preference()
break
break
def on_midi_port_change(self, event):
"""Handle MIDI port selection change."""
selected_port = self.midi_port_var.get()
if selected_port in self.midi_controller.available_ports:
self.midi_controller.midi_port_index = self.midi_controller.available_ports.index(selected_port)
self.midi_controller.save_midi_preference()
# Restart MIDI connection
asyncio.create_task(self.restart_midi())
@async_handler
async def restart_midi(self):
"""Restart MIDI connection with new port."""
if self.midi_controller.midi_task:
self.midi_controller.midi_task.cancel()
if self.midi_controller.midi_port:
self.midi_controller.midi_port.close()
success = await self.midi_controller.initialize_midi()
if success:
self.midi_controller.midi_task = asyncio.create_task(
self.midi_controller.start_midi_listener()
)
def update_status_labels(self):
"""Update UI status labels."""
# Update connection status
if self.websocket_client.is_connected:
self.connection_status.config(text="Control Server: Connected", fg="green")
else:
self.connection_status.config(text="Control Server: Disconnected", fg="red")
# Update dial displays
dial_values = [
("n3", self.midi_controller.n3), ("Delay", self.midi_controller.delay),
("n1", self.midi_controller.n1), ("n2", self.midi_controller.n2),
("B", self.midi_controller.color_b), ("Brightness", self.midi_controller.brightness),
("R", self.midi_controller.color_r), ("G", self.midi_controller.color_g),
]
for idx, (label, value) in enumerate(dial_values):
if idx < len(self.dials_boxes):
self.dials_boxes[idx].config(text=f"{label}\n{value}")
# Update knobs
knob_values = [
("CC44", self.midi_controller.knob7), ("CC45", self.midi_controller.knob8),
("Rad n1", self.midi_controller.n1), ("Rad delay", self.midi_controller.delay),
("Alt n1", self.midi_controller.n1), ("Alt n2", self.midi_controller.n2),
("Pulse n1", self.midi_controller.n1), ("Pulse n2", self.midi_controller.n2),
]
for idx, (label, value) in enumerate(knob_values):
if idx < len(self.knobs_boxes):
self.knobs_boxes[idx].config(text=f"{label}\n{value}")
# Update buttons
icon_for = {
"pulse": "💥", "flicker": "", "alternating": "↔️",
"n_chase": "🏃", "rainbow": "🌈", "radiate": "🌟",
"sequential_pulse": "🔄", "alternating_phase": "", "-": "",
}
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 = {
"pulse": "pulse",
"sequential_pulse": "sequential\npulse",
"alternating": "alternating",
"alternating_phase": "alternating\nphase",
"n_chase": "n chase",
"rainbow": "rainbow",
"flicker": "flicker",
"radiate": "radiate",
}
current_pattern = self.midi_controller.current_pattern
for idx, lbl in enumerate(self.button1_cells):
pattern_name = bank1_patterns[idx]
is_selected = (current_pattern == pattern_name and pattern_name != "-")
display_name = display_names.get(pattern_name, pattern_name)
icon = icon_for.get(pattern_name, "")
text = f"{icon} {display_name}" if pattern_name != "-" else ""
if is_selected:
lbl.config(text=text, bg=highlight_pattern_color)
else:
lbl.config(text=text, bg=bg_color)
# Reschedule
self.root.after(200, self.update_status_labels)
def on_closing(self):
"""Handle application closing."""
logging.info("Closing UI client...")
if self.midi_controller.midi_task:
self.midi_controller.midi_task.cancel()
self.midi_controller.close()
asyncio.create_task(self.websocket_client.close())
self.root.destroy()
def run(self):
"""Run the UI client."""
async_mainloop(self.root)
if __name__ == "__main__":
app = UIClient()
app.run()

View File

@@ -1,116 +0,0 @@
#!/usr/bin/env python3
"""
Startup script for the separated lighting controller architecture.
Starts the control server, sound detector, and UI client.
"""
import subprocess
import sys
import time
import signal
import os
from pathlib import Path
def start_process(command, name, cwd=None):
"""Start a subprocess and return the process object."""
print(f"Starting {name}...")
try:
process = subprocess.Popen(
command,
shell=True,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
preexec_fn=os.setsid if os.name != 'nt' else None
)
print(f"{name} started with PID {process.pid}")
return process
except Exception as e:
print(f"Failed to start {name}: {e}")
return None
def main():
"""Main startup function."""
print("Starting Lighting Controller (Separated Architecture)")
print("=" * 50)
# Get the project directory
project_dir = Path(__file__).parent
processes = []
try:
# Start control server
control_process = start_process(
"python src/control_server.py",
"Control Server",
cwd=project_dir
)
if control_process:
processes.append(("Control Server", control_process))
# Wait a moment for the control server to start
time.sleep(2)
# Start sound detector
sound_process = start_process(
"python src/sound.py",
"Sound Detector",
cwd=project_dir
)
if sound_process:
processes.append(("Sound Detector", sound_process))
# Wait a moment for the sound detector to start
time.sleep(1)
# Start UI client
ui_process = start_process(
"python src/ui_client.py",
"UI Client",
cwd=project_dir
)
if ui_process:
processes.append(("UI Client", ui_process))
print("\nAll components started successfully!")
print("Press Ctrl+C to stop all components...")
# Wait for processes
try:
while True:
time.sleep(1)
# Check if any process has died
for name, process in processes:
if process.poll() is not None:
print(f"Warning: {name} has stopped unexpectedly")
except KeyboardInterrupt:
print("\nShutting down all components...")
except Exception as e:
print(f"Error during startup: {e}")
finally:
# Clean up all processes
for name, process in processes:
if process and process.poll() is None:
print(f"Stopping {name}...")
try:
if os.name != 'nt':
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
else:
process.terminate()
process.wait(timeout=5)
except subprocess.TimeoutExpired:
print(f"Force killing {name}...")
if os.name != 'nt':
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
else:
process.kill()
except Exception as e:
print(f"Error stopping {name}: {e}")
print("All components stopped.")
if __name__ == "__main__":
main()

1650
static/app.js Normal file

File diff suppressed because it is too large Load Diff

548
static/style.css Normal file
View File

@@ -0,0 +1,548 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #2e2e2e;
color: white;
height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
}
header {
background-color: #1a1a1a;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #4a4a4a;
}
header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #4a4a4a;
color: white;
}
.btn-primary:hover {
background-color: #5a5a5a;
}
.btn-secondary {
background-color: #3a3a3a;
color: white;
}
.btn-secondary:hover {
background-color: #4a4a4a;
}
.btn-danger {
background-color: #d32f2f;
color: white;
}
.btn-danger:hover {
background-color: #c62828;
}
.btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tabs-container {
background-color: #1a1a1a;
border-bottom: 2px solid #4a4a4a;
padding: 0.5rem 1rem;
}
.tabs-list {
display: flex;
gap: 0.5rem;
overflow-x: auto;
}
.tab-button {
padding: 0.5rem 1rem;
background-color: #3a3a3a;
color: white;
border: none;
border-radius: 4px 4px 0 0;
cursor: pointer;
font-size: 0.9rem;
white-space: nowrap;
transition: background-color 0.2s;
}
.tab-button:hover {
background-color: #4a4a4a;
}
.tab-button.active {
background-color: #6a5acd;
color: white;
}
.tab-content {
flex: 1;
display: flex;
overflow: hidden;
padding: 1rem;
gap: 1rem;
}
.left-panel {
flex: 0 0 50%;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
border-right: 2px solid #4a4a4a;
padding-right: 1rem;
}
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
padding-left: 1rem;
}
.ids-display {
padding: 0.5rem;
background-color: #3a3a3a;
border-radius: 4px;
font-size: 0.9rem;
}
.left-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.left-panel-toggle {
padding: 0.25rem 0.5rem;
min-width: 32px;
}
.left-panel-body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.left-panel.collapsed {
flex: 0 0 48px;
padding-right: 0.5rem;
}
.left-panel.collapsed .left-panel-body {
display: none;
}
.left-panel.collapsed .left-panel-toggle {
transform: rotate(180deg);
}
.controls-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.control-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-group label {
min-width: 100px;
font-weight: 500;
}
.slider {
flex: 1;
height: 8px;
background-color: #3a3a3a;
border-radius: 4px;
outline: none;
-webkit-appearance: none;
margin: 0 0.5rem;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background-color: #6a5acd;
border-radius: 50%;
cursor: pointer;
transition: background-color 0.2s;
}
.slider::-webkit-slider-thumb:hover {
background-color: #7a6add;
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
background-color: #6a5acd;
border-radius: 50%;
cursor: pointer;
border: none;
transition: background-color 0.2s;
}
.slider::-moz-range-thumb:hover {
background-color: #7a6add;
}
/* Red slider */
#red-slider {
accent-color: #ff0000;
}
#red-slider::-webkit-slider-thumb {
background-color: #ff0000;
}
#red-slider::-moz-range-thumb {
background-color: #ff0000;
}
/* Green slider */
#green-slider {
accent-color: #00ff00;
}
#green-slider::-webkit-slider-thumb {
background-color: #00ff00;
}
#green-slider::-moz-range-thumb {
background-color: #00ff00;
}
/* Blue slider */
#blue-slider {
accent-color: #0000ff;
}
#blue-slider::-webkit-slider-thumb {
background-color: #0000ff;
}
#blue-slider::-moz-range-thumb {
background-color: #0000ff;
}
/* Brightness slider */
#brightness-slider {
accent-color: #ffff00;
}
#brightness-slider::-webkit-slider-thumb {
background-color: #ffff00;
}
#brightness-slider::-moz-range-thumb {
background-color: #ffff00;
}
.slider-value {
min-width: 50px;
text-align: right;
font-weight: 500;
font-size: 0.9rem;
}
.n-params-section {
margin-top: 1rem;
}
.n-params-section h3 {
margin-bottom: 0.5rem;
font-size: 1rem;
}
.n-params-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.n-param-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.n-param-group label {
min-width: 40px;
font-weight: 500;
}
.n-input {
flex: 1;
padding: 0.5rem;
background-color: #3a3a3a;
color: white;
border: 1px solid #4a4a4a;
border-radius: 4px;
font-size: 1rem;
}
.n-input:focus {
outline: none;
border-color: #6a5acd;
}
.patterns-section,
.presets-section,
.color-palette-section {
background-color: #1a1a1a;
border: 2px solid #4a4a4a;
border-radius: 4px;
padding: 1rem;
}
.patterns-section h3,
.presets-section h3,
.color-palette-section h3 {
margin-bottom: 1rem;
font-size: 1.1rem;
}
.patterns-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.presets-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.pattern-button {
padding: 0.75rem;
background-color: #3a3a3a;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
text-align: left;
transition: background-color 0.2s;
}
.pattern-button:hover {
background-color: #4a4a4a;
}
.pattern-button.active {
background-color: #6a5acd;
color: white;
}
.pattern-button.default-preset {
border: 2px solid #6a5acd;
}
.color-palette {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
max-height: 300px;
overflow-y: auto;
}
.color-swatch {
display: flex;
align-items: center;
padding: 0.5rem;
background-color: #3a3a3a;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.2s;
gap: 0.5rem;
}
.color-swatch:hover {
border-color: #6a5acd;
}
.color-swatch.selected {
border-color: #FFD700;
border-width: 3px;
}
.color-swatch-preview {
width: 40px;
height: 40px;
border-radius: 4px;
border: 1px solid #4a4a4a;
flex-shrink: 0;
}
.color-swatch-label {
flex: 1;
font-size: 0.9rem;
min-width: 80px;
}
.color-picker-input {
width: 60px;
height: 40px;
border: 1px solid #4a4a4a;
border-radius: 4px;
cursor: pointer;
background: none;
padding: 0;
flex-shrink: 0;
}
.color-picker-input::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-picker-input::-webkit-color-swatch {
border: none;
border-radius: 4px;
}
.color-picker-input::-moz-color-swatch {
border: none;
border-radius: 4px;
}
.palette-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background-color: #2e2e2e;
padding: 2rem;
border-radius: 8px;
min-width: 400px;
max-width: 500px;
}
.modal-content h2 {
margin-bottom: 1rem;
font-size: 1.3rem;
}
.modal-content label {
display: block;
margin-top: 1rem;
margin-bottom: 0.5rem;
font-weight: 500;
}
.modal-content input {
width: 100%;
padding: 0.5rem;
background-color: #3a3a3a;
color: white;
border: 1px solid #4a4a4a;
border-radius: 4px;
font-size: 1rem;
}
.modal-content input:focus {
outline: none;
border-color: #6a5acd;
}
.modal-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
justify-content: flex-end;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1a1a1a;
}
::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}

279
templates/index.html Normal file
View File

@@ -0,0 +1,279 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lighting Controller</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app-container">
<header>
<h1>Lighting Controller</h1>
<div class="header-actions">
<button id="add-tab-btn" class="btn btn-primary">+ Add Tab</button>
<button id="edit-tab-btn" class="btn btn-secondary">Edit Tab</button>
<button id="delete-tab-btn" class="btn btn-danger">Delete Tab</button>
<button id="color-palette-btn" class="btn btn-secondary">Color Palette</button>
<button id="presets-btn" class="btn btn-secondary">Presets</button>
<button id="profiles-btn" class="btn btn-secondary">Profiles</button>
</div>
</header>
<div class="main-content">
<div class="tabs-container">
<div id="tabs-list" class="tabs-list"></div>
</div>
<div id="tab-content" class="tab-content">
<div class="left-panel">
<div class="left-panel-header">
<div class="ids-display">
<label>IDs: </label>
<span id="current-ids"></span>
</div>
<button id="toggle-left-panel" class="btn btn-small left-panel-toggle" title="Collapse/expand controls"></button>
</div>
<div class="left-panel-body">
<div class="color-palette-section">
<h3>Color Palette</h3>
<div id="color-palette" class="color-palette"></div>
<div class="palette-actions">
<button id="add-color-btn" class="btn btn-small">Add Color</button>
<button id="remove-color-btn" class="btn btn-small">Remove Selected</button>
</div>
</div>
<div class="controls-section">
<div class="control-group">
<label for="brightness-slider">Brightness:</label>
<input type="range" id="brightness-slider" min="0" max="255" value="127" class="slider">
<span id="brightness-value" class="slider-value">127</span>
</div>
<div class="control-group">
<label for="delay-slider">Delay:</label>
<input type="range" id="delay-slider" min="0" max="1000" value="0" class="slider">
<span id="delay-value" class="slider-value">100 ms</span>
</div>
</div>
<div class="n-params-section">
<h3>N Parameters</h3>
<div class="n-params-grid">
<div class="n-param-group">
<label for="n1-input">n1:</label>
<input type="number" id="n1-input" min="0" max="255" value="10" class="n-input">
</div>
<div class="n-param-group">
<label for="n2-input">n2:</label>
<input type="number" id="n2-input" min="0" max="255" value="10" class="n-input">
</div>
<div class="n-param-group">
<label for="n3-input">n3:</label>
<input type="number" id="n3-input" min="0" max="255" value="10" class="n-input">
</div>
<div class="n-param-group">
<label for="n4-input">n4:</label>
<input type="number" id="n4-input" min="0" max="255" value="10" class="n-input">
</div>
</div>
</div>
</div>
</div>
<div class="right-panel">
<div class="presets-section">
<h3>Presets</h3>
<div id="presets-list-tab" class="presets-list"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Modals -->
<div id="add-tab-modal" class="modal">
<div class="modal-content">
<h2>Add New Tab</h2>
<label>Tab Name:</label>
<input type="text" id="new-tab-name" placeholder="Enter tab name">
<label>Device IDs (comma-separated):</label>
<input type="text" id="new-tab-ids" placeholder="1,2,3" value="1">
<div class="modal-actions">
<button id="add-tab-confirm" class="btn btn-primary">Add</button>
<button id="add-tab-cancel" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<div id="edit-tab-modal" class="modal">
<div class="modal-content">
<h2>Edit Tab</h2>
<label>Tab Name:</label>
<input type="text" id="edit-tab-name" placeholder="Enter tab name">
<label>Device IDs (comma-separated):</label>
<input type="text" id="edit-tab-ids" placeholder="1,2,3">
<div class="modal-actions">
<button id="edit-tab-confirm" class="btn btn-primary">Update</button>
<button id="edit-tab-cancel" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<div id="profiles-modal" class="modal">
<div class="modal-content" style="min-width: 500px;">
<h2>Profiles</h2>
<div id="profiles-list-container" style="margin: 1rem 0; max-height: 400px; overflow-y: auto;">
<div id="profiles-list"></div>
</div>
<div style="margin-top: 1rem;">
<label>New Profile Name:</label>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="text" id="new-profile-name" placeholder="Enter profile name" style="flex: 1;">
<button id="create-profile-btn" class="btn btn-primary">Create</button>
</div>
</div>
<div style="margin-top: 1rem;">
<label>Current Profile:</label>
<div id="current-profile-display" style="padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; margin-top: 0.5rem;">
<span id="current-profile-name">None</span>
</div>
</div>
<div style="margin-top: 1.5rem;">
<label>Profile Color Palette:</label>
<div id="profile-palette-container" style="margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; min-height: 60px;">
<!-- Palette colors will be rendered here -->
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="color" id="new-palette-color" value="#000000" style="width: 60px; height: 40px; border: 1px solid #4a4a4a; border-radius: 4px; cursor: pointer;">
<button id="add-palette-color-btn" class="btn btn-small">Add to Palette</button>
</div>
</div>
<div class="modal-actions">
<button id="profiles-close-btn" class="btn btn-secondary">Close</button>
</div>
</div>
</div>
<div id="color-palette-modal" class="modal">
<div class="modal-content" style="min-width: 500px;">
<h2>Color Palette</h2>
<div style="margin-top: 1rem;">
<label>Current Profile:</label>
<div id="palette-current-profile-display" style="padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; margin-top: 0.5rem;">
<span id="palette-current-profile-name">None</span>
</div>
</div>
<div style="margin-top: 1.5rem;">
<label>Profile Color Palette:</label>
<div id="palette-container" style="margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; min-height: 60px;">
<!-- Palette colors will be rendered here -->
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="color" id="palette-new-color" value="#000000" style="width: 60px; height: 40px; border: 1px solid #4a4a4a; border-radius: 4px; cursor: pointer;">
<button id="palette-add-color-btn" class="btn btn-small">Add to Palette</button>
</div>
</div>
<div class="modal-actions">
<button id="color-palette-close-btn" class="btn btn-secondary">Close</button>
</div>
</div>
</div>
<div id="quick-palette-modal" class="modal">
<div class="modal-content" style="min-width: 500px; max-width: 600px;">
<h2>Select Color from Palette</h2>
<div id="quick-palette-container" style="margin-top: 1rem; display: flex; flex-wrap: wrap; gap: 0.75rem; padding: 1rem; background-color: #3a3a3a; border-radius: 4px; min-height: 200px; max-height: 500px; overflow-y: auto;">
<!-- Palette colors will be rendered here -->
</div>
<div class="modal-actions" style="margin-top: 1rem;">
<button id="quick-palette-use-picker-btn" class="btn btn-secondary">Use Color Picker</button>
<button id="quick-palette-close-btn" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<div id="presets-modal" class="modal">
<div class="modal-content" style="min-width: 600px; max-width: 800px;">
<h2>Presets</h2>
<div id="presets-list-container" style="margin: 1rem 0; max-height: 400px; overflow-y: auto;">
<div id="presets-list"></div>
</div>
<div class="modal-actions">
<button id="create-preset-btn" class="btn btn-primary">Create Preset</button>
<button id="presets-close-btn" class="btn btn-secondary">Close</button>
</div>
</div>
</div>
<div id="preset-editor-modal" class="modal">
<div class="modal-content" style="min-width: 600px; max-width: 800px; max-height: 90vh; overflow-y: auto;">
<h2 id="preset-editor-title">Create Preset</h2>
<div style="display: flex; flex-direction: column; gap: 1rem;">
<div>
<label>Preset Name:</label>
<input type="text" id="preset-name-input" placeholder="Enter preset name" style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
</div>
<div>
<label>Pattern:</label>
<select id="preset-pattern-select" style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
<!-- Patterns will be populated dynamically -->
</select>
</div>
<div>
<label>Brightness:</label>
<div style="display: flex; align-items: center; gap: 1rem; margin-top: 0.5rem;">
<input type="range" id="preset-brightness-slider" min="0" max="255" value="127" class="slider" style="flex: 1;">
<span id="preset-brightness-value" class="slider-value">127</span>
</div>
</div>
<div>
<label>Delay (ms):</label>
<input type="number" id="preset-delay-input" min="10" max="10000" value="100" style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
</div>
<div>
<label>N Parameters:</label>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; margin-top: 0.5rem;">
<div>
<label for="preset-n1-input">n1:</label>
<input type="number" id="preset-n1-input" min="0" max="255" value="10" style="width: 100%; padding: 0.5rem;">
</div>
<div>
<label for="preset-n2-input">n2:</label>
<input type="number" id="preset-n2-input" min="0" max="255" value="10" style="width: 100%; padding: 0.5rem;">
</div>
<div>
<label for="preset-n3-input">n3:</label>
<input type="number" id="preset-n3-input" min="0" max="255" value="10" style="width: 100%; padding: 0.5rem;">
</div>
<div>
<label for="preset-n4-input">n4:</label>
<input type="number" id="preset-n4-input" min="0" max="255" value="10" style="width: 100%; padding: 0.5rem;">
</div>
</div>
</div>
<div>
<label>Colors:</label>
<div id="preset-colors-container" style="margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; min-height: 60px;">
<!-- Colors will be rendered here -->
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="color" id="preset-new-color" value="#000000" style="width: 60px; height: 40px; border: 1px solid #4a4a4a; border-radius: 4px; cursor: pointer;">
<button id="preset-add-color-btn" class="btn btn-small">Add Color</button>
<button id="preset-remove-color-btn" class="btn btn-small">Remove Selected</button>
</div>
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
<button id="preset-editor-from-current-btn" class="btn btn-secondary" style="flex: 1;">Create from Current</button>
<button id="preset-editor-save-btn" class="btn btn-primary" style="flex: 1;">Save Preset</button>
<button id="preset-editor-cancel-btn" class="btn btn-secondary" style="flex: 1;">Cancel</button>
</div>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

4
tmp_explanation.txt Normal file
View File

@@ -0,0 +1,4 @@
This is just a placeholder to satisfy the tool requirement; actual code changes are in other files.