Separate UI and control logic with WebSocket communication
- Create UI client (src/ui_client.py) with MIDI controller integration - Create control server (src/control_server.py) with lighting logic - Implement WebSocket protocol between UI and control server - Add startup script (start_lighting_controller.py) for all components - Update Pipfile with new scripts for separated architecture - Add comprehensive documentation (README_SEPARATED.md) - Fix LED connection stability with heartbeat mechanism - Fix UI knob display and button highlighting - Maintain backward compatibility with existing MIDI mappings
This commit is contained in:
443
src/control_server.py
Normal file
443
src/control_server.py
Normal file
@@ -0,0 +1,443 @@
|
||||
#!/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())
|
Reference in New Issue
Block a user