Files
lighting-controller/src/control_server.py

996 lines
37 KiB
Python

#!/usr/bin/env python3
"""
Control Server for Lighting Controller
Handles lighting control logic and communicates with LED bars via SPI or WebSocket.
Receives commands from UI client via WebSocket.
"""
import asyncio
import json
import logging
import socket
import threading
import time
import argparse
import os
from aiohttp import web
from dotenv import load_dotenv
from bar_config import LED_BAR_NAMES, DEFAULT_BAR_SETTINGS
from color_utils import adjust_brightness
from networking import SPIClient, WebSocketClient
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Configuration
CONTROL_SERVER_PORT = int(os.getenv("CONTROL_SERVER_PORT", "8765"))
HTTP_API_PORT = int(os.getenv("HTTP_API_PORT", "8766"))
SOUND_CONTROL_HOST = os.getenv("SOUND_CONTROL_HOST", "127.0.0.1")
SOUND_CONTROL_PORT = int(os.getenv("SOUND_CONTROL_PORT", "65433"))
CONFIG_FILE = "lighting_config.json"
# Pattern name mapping for shorter JSON payloads
# Frontend sends shortnames, backend can use either long or short names
# These map to the shortnames defined in led-bar/src/patterns.py
PATTERN_NAMES = {
# Long names to short names (for backend use)
"off": "o",
"flicker": "f",
"fill_range": "fr",
"n_chase": "nc",
"alternating": "a",
"pulse": "p",
"rainbow": "r",
"specto": "s",
"radiate": "rd",
"segmented_movement": "sm",
# New: alternate two palette colors by pulsing per beat (backend-only logical name)
"alternating_pulse": "apu",
# Short names pass through (for frontend use)
"o": "o",
"f": "f",
"fr": "fr",
"nc": "nc",
"a": "a",
"p": "p",
"r": "r",
"s": "s",
"rd": "rd",
"sm": "sm",
# Backend-specific patterns
"sequential_pulse": "sp",
"alternating_phase": "ap",
}
class LEDController:
"""Handles communication with LED bars via SPI or WebSocket."""
def __init__(self, transport="spi", **kwargs):
if transport == "spi":
self.client = SPIClient(
bus=kwargs.get('spi_bus', 0),
device=kwargs.get('spi_device', 0),
speed_hz=kwargs.get('spi_speed_hz', 1_000_000)
)
elif transport == "websocket":
self.client = WebSocketClient(uri=kwargs.get('uri', 'ws://192.168.4.1/ws'))
else:
raise ValueError(f"Invalid transport: {transport}. Must be 'spi' or 'websocket'")
@property
def is_connected(self) -> bool:
return getattr(self.client, "is_connected", False)
async def connect(self):
await self.client.connect()
async def send_data(self, data):
await self.client.send_data(data)
async def close(self):
await self.client.close()
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, transport="spi", **transport_kwargs):
self.led_controller = LEDController(transport=transport, **transport_kwargs)
self.sound_controller = SoundController(SOUND_CONTROL_HOST, SOUND_CONTROL_PORT)
# Lighting state
self.current_pattern = ""
self.brightness = 100
self.color_r = 0
self.color_g = 255
self.color_b = 0
self.beat_index = 0
self.beat_sending_enabled = True
# Per-pattern parameters (pattern_name -> {delay, n1, n2, n3, n4})
self.pattern_parameters = {}
# Default parameters for new patterns
self.default_params = {
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 1,
"n4": 1
}
# Current active parameters (loaded from current pattern)
self.delay = self.default_params["delay"]
self.n1 = self.default_params["n1"]
self.n2 = self.default_params["n2"]
self.n3 = self.default_params["n3"]
self.n4 = self.default_params["n4"]
# Color palette (8 slots, 2 selected)
self.color_palette = [
{"r": 255, "g": 0, "b": 0}, # Red
{"r": 0, "g": 255, "b": 0}, # Green
{"r": 0, "g": 0, "b": 255}, # Blue
{"r": 255, "g": 255, "b": 0}, # Yellow
{"r": 255, "g": 0, "b": 255}, # Magenta
{"r": 0, "g": 255, "b": 255}, # Cyan
{"r": 255, "g": 128, "b": 0}, # Orange
{"r": 255, "g": 255, "b": 255}, # White
]
self.selected_color_indices = [0, 1] # Default: Red and Green
# Load config
self._load_config()
# Rate limiting
self.last_param_update = 0.0
self.param_update_interval = 0.1
self.pending_param_update = False
def _load_pattern_parameters(self, pattern_name):
"""Load parameters for a specific pattern."""
if pattern_name in self.pattern_parameters:
params = self.pattern_parameters[pattern_name]
self.delay = params.get("delay", self.default_params["delay"])
self.n1 = params.get("n1", self.default_params["n1"])
self.n2 = params.get("n2", self.default_params["n2"])
self.n3 = params.get("n3", self.default_params["n3"])
self.n4 = params.get("n4", self.default_params["n4"])
else:
# Use defaults for new pattern
self.delay = self.default_params["delay"]
self.n1 = self.default_params["n1"]
self.n2 = self.default_params["n2"]
self.n3 = self.default_params["n3"]
self.n4 = self.default_params["n4"]
def _save_pattern_parameters(self, pattern_name):
"""Save current parameters for the active pattern."""
if pattern_name:
self.pattern_parameters[pattern_name] = {
"delay": self.delay,
"n1": self.n1,
"n2": self.n2,
"n3": self.n3,
"n4": self.n4
}
self._save_config()
def _current_color_rgb(self):
"""Get current RGB color tuple from selected palette color (index 0)."""
# Use the first selected color from the palette
if self.selected_color_indices and len(self.selected_color_indices) > 0:
color_index = self.selected_color_indices[0]
if 0 <= color_index < len(self.color_palette):
color = self.color_palette[color_index]
return (color['r'], color['g'], color['b'])
# Fallback to legacy color sliders if palette not set
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)
def _palette_color(self, selected_index_position: int):
"""Return RGB tuple for the selected palette color at given position (0 or 1)."""
if not self.selected_color_indices or selected_index_position >= len(self.selected_color_indices):
return self._current_color_rgb()
color_index = self.selected_color_indices[selected_index_position]
if 0 <= color_index < len(self.color_palette):
color = self.color_palette[color_index]
return (color['r'], color['g'], color['b'])
return self._current_color_rgb()
def _load_config(self):
"""Load configuration from file."""
try:
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r') as f:
config = json.load(f)
# Load color palette
if "color_palette" in config:
self.color_palette = config["color_palette"]
# Load selected color indices
if "selected_color_indices" in config:
self.selected_color_indices = config["selected_color_indices"]
# Load per-pattern parameters
if "pattern_parameters" in config:
self.pattern_parameters = config["pattern_parameters"]
logging.info(f"Loaded config from {CONFIG_FILE}")
except Exception as e:
logging.error(f"Error loading config: {e}")
def _save_config(self):
"""Save configuration to file."""
try:
config = {
"color_palette": self.color_palette,
"selected_color_indices": self.selected_color_indices,
"pattern_parameters": self.pattern_parameters,
}
with open(CONFIG_FILE, 'w') as f:
json.dump(config, f, indent=2)
logging.info(f"Saved config to {CONFIG_FILE}")
except Exception as e:
logging.error(f"Error saving config: {e}")
def get_color_palette_config(self):
"""Get current color palette configuration."""
return {
"palette": self.color_palette,
"selected_indices": self.selected_color_indices
}
def set_color_palette(self, palette_data):
"""Set color palette configuration."""
if "palette" in palette_data:
# Validate palette has 8 colors
if len(palette_data["palette"]) == 8:
self.color_palette = palette_data["palette"]
else:
logging.warning(f"Invalid palette size: {len(palette_data['palette'])}, expected 8")
if "selected_indices" in palette_data:
# Validate indices
indices = palette_data["selected_indices"]
if len(indices) == 2 and all(0 <= i < 8 for i in indices):
self.selected_color_indices = indices
else:
logging.warning(f"Invalid selected indices: {indices}")
self._save_config()
logging.info(f"Color palette updated: selected indices {self.selected_color_indices}")
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,
"n4": self.n4,
"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 that need parameters (both long and short names)
patterns_needing_params = [
"alternating", "a",
"flicker", "f",
"n_chase", "nc",
"rainbow", "r",
"radiate", "rd",
"segmented_movement", "sm"
]
payload = {
"d": {
"t": "b", # Message type: beat
"pt": PATTERN_NAMES.get(self.current_pattern, self.current_pattern),
"cl": [self._current_color_rgb()], # Always send color
}
}
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."""
# Determine two colors from selected palette (fallback to current color if not set)
color_a = self._palette_color(0)
color_b = self._palette_color(1)
phase = self.beat_index % 2
# Set the default color based on phase so both bar groups swap each beat
default_color = color_a if phase == 0 else color_b
alt_color_for_swap = color_b if phase == 0 else color_a
# Avoid pure white edge-case on some bars by slightly reducing to 254
if default_color == (255, 255, 255):
default_color = (254, 254, 254)
if alt_color_for_swap == (255, 255, 255):
alt_color_for_swap = (254, 254, 254)
payload = {
"d": {
"t": "b",
"pt": "a", # alternating
"n1": self.n1,
"n2": self.n2,
# Default color for non-swapped bars changes with phase
"cl": [default_color],
"s": phase,
}
}
# Bars in this list will have inverted phase and explicit color override
# Flip grouping so first four bars (100-103) use default color (color_a on even beats)
swap_bars = ["104", "105", "106", "107"]
# Only include overrides for swapped bars to minimize payload size
for bar_name in swap_bars:
inv_phase = (phase + 1) % 2
payload[bar_name] = {"s": inv_phase, "cl": [alt_color_for_swap]}
await self.led_controller.send_data(payload)
async def _handle_alternating_pulse(self):
"""Handle APU: color1 on odd beat for odd bars, color2 on even beat for even bars."""
phase = self.beat_index % 2
color1 = self._palette_color(0)
color2 = self._palette_color(1)
if color1 == (255, 255, 255):
color1 = (254, 254, 254)
if color2 == (255, 255, 255):
color2 = (254, 254, 254)
# Define bar groups by numeric parity
even_bars = ["100", "102", "104", "106"]
odd_bars = ["101", "103", "105", "107"]
# Default: turn bars off this beat
payload = {
"d": {
"t": "b",
"pt": "o", # off by default
# Provide pulse envelope params in defaults for bars we enable
"n1": self.n1,
"n2": self.n2,
"dl": self.delay,
}
}
# Activate the correct half with the correct color
if phase == 0:
# Even beat -> even bars use color2
active_bars = even_bars
active_color = color2
else:
# Odd beat -> odd bars use color1
active_bars = odd_bars
active_color = color1
for bar_name in active_bars:
payload[bar_name] = {"pt": "p", "cl": [active_color]}
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
# 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 == "alternating_pulse":
await self._handle_alternating_pulse()
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":
# Save current pattern's parameters before switching
if self.current_pattern:
self._save_pattern_parameters(self.current_pattern)
# Switch to new pattern and load its parameters
self.current_pattern = data.get("pattern", "")
self._load_pattern_parameters(self.current_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"]
if "n4" in data:
self.n4 = data["n4"]
# Save parameters for current pattern
if self.current_pattern:
self._save_pattern_parameters(self.current_pattern)
await self._request_param_update()
elif message_type == "delay_change":
self.delay = data.get("delay", self.delay)
# Save parameters for current pattern
if self.current_pattern:
self._save_pattern_parameters(self.current_pattern)
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()
elif message_type == "get_color_palette":
# Return color palette configuration
return self.get_color_palette_config()
elif message_type == "set_color_palette":
# Set color palette configuration
self.set_color_palette(data)
return {"status": "ok", "palette": self.get_color_palette_config()}
class ControlServer:
"""WebSocket server for UI client communication and TCP server for sound."""
def __init__(self, transport="spi", enable_heartbeat=False, **transport_kwargs):
self.lighting_controller = LightingController(transport=transport, **transport_kwargs)
self.clients = set()
self.tcp_server = None
self.http_app = None
self.http_runner = None
self.enable_heartbeat = enable_heartbeat
async def handle_websocket(self, request):
"""Handle WebSocket connection for UI client."""
ws = web.WebSocketResponse()
await ws.prepare(request)
self.clients.add(ws)
client_addr = request.remote
logging.info(f"UI client connected: {client_addr}")
try:
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
message_type = data.get("type")
message_data = data.get("data", {})
response = await self.lighting_controller.handle_ui_command(message_type, message_data)
# Send response if command returned data
if response is not None:
await ws.send_json({
"type": f"{message_type}_response",
"data": response
})
except json.JSONDecodeError:
logging.error(f"Invalid JSON from client {client_addr}: {msg.data}")
except Exception as e:
logging.error(f"Error handling message from client {client_addr}: {e}")
elif msg.type == web.WSMsgType.ERROR:
logging.error(f"WebSocket error from {client_addr}: {ws.exception()}")
except Exception as e:
logging.error(f"Error in WebSocket handler: {e}")
finally:
self.clients.discard(ws)
logging.info(f"UI client disconnected: {client_addr}")
return ws
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}")
# HTTP API Handlers
# Color Palette API
async def http_get_color_palette(self, request):
"""HTTP GET /api/color-palette"""
palette_config = self.lighting_controller.get_color_palette_config()
return web.json_response(palette_config)
async def http_set_color_palette(self, request):
"""HTTP POST/PUT /api/color-palette"""
try:
data = await request.json()
self.lighting_controller.set_color_palette(data)
palette_config = self.lighting_controller.get_color_palette_config()
return web.json_response({
"status": "ok",
"palette": palette_config
})
except json.JSONDecodeError:
return web.json_response(
{"status": "error", "message": "Invalid JSON"},
status=400
)
except Exception as e:
return web.json_response(
{"status": "error", "message": str(e)},
status=500
)
# Pattern API
async def http_set_pattern(self, request):
"""HTTP POST /api/pattern"""
try:
data = await request.json()
logging.info(f"API received pattern change: {data}")
pattern = data.get("pattern")
if not pattern:
return web.json_response(
{"status": "error", "message": "Pattern name required"},
status=400
)
# Save current pattern's parameters before switching
if self.lighting_controller.current_pattern:
self.lighting_controller._save_pattern_parameters(self.lighting_controller.current_pattern)
# Switch to new pattern and load its parameters
# Normalize shortnames for backend-only patterns
if pattern == "ap":
pattern = "alternating_pulse"
self.lighting_controller.current_pattern = pattern
self.lighting_controller._load_pattern_parameters(pattern)
await self.lighting_controller._send_full_parameters()
logging.info(f"Pattern changed to: {pattern} with params: delay={self.lighting_controller.delay}, n1={self.lighting_controller.n1}, n2={self.lighting_controller.n2}, n3={self.lighting_controller.n3}, n4={self.lighting_controller.n4}")
return web.json_response({
"status": "ok",
"pattern": self.lighting_controller.current_pattern,
"parameters": {
"delay": self.lighting_controller.delay,
"n1": self.lighting_controller.n1,
"n2": self.lighting_controller.n2,
"n3": self.lighting_controller.n3,
"n4": self.lighting_controller.n4
}
})
except Exception as e:
return web.json_response(
{"status": "error", "message": str(e)},
status=500
)
async def http_get_pattern(self, request):
"""HTTP GET /api/pattern"""
return web.json_response({
"pattern": self.lighting_controller.current_pattern
})
# Parameters API
async def http_set_parameters(self, request):
"""HTTP POST /api/parameters"""
try:
data = await request.json()
logging.info(f"API received parameter update: {data}")
# Update any provided parameters
if "brightness" in data:
self.lighting_controller.brightness = int(data["brightness"])
if "delay" in data:
self.lighting_controller.delay = int(data["delay"])
if "n1" in data:
self.lighting_controller.n1 = int(data["n1"])
if "n2" in data:
self.lighting_controller.n2 = int(data["n2"])
if "n3" in data:
self.lighting_controller.n3 = int(data["n3"])
if "n4" in data:
self.lighting_controller.n4 = int(data["n4"])
logging.info(f"Updated parameters for pattern '{self.lighting_controller.current_pattern}': brightness={self.lighting_controller.brightness}, delay={self.lighting_controller.delay}, n1={self.lighting_controller.n1}, n2={self.lighting_controller.n2}, n3={self.lighting_controller.n3}, n4={self.lighting_controller.n4}")
# Save parameters for current pattern
if self.lighting_controller.current_pattern:
self.lighting_controller._save_pattern_parameters(self.lighting_controller.current_pattern)
# Send updated parameters to LED bars
await self.lighting_controller._send_full_parameters()
return web.json_response({
"status": "ok",
"parameters": {
"brightness": self.lighting_controller.brightness,
"delay": self.lighting_controller.delay,
"n1": self.lighting_controller.n1,
"n2": self.lighting_controller.n2,
"n3": self.lighting_controller.n3,
"n4": self.lighting_controller.n4
}
})
except Exception as e:
return web.json_response(
{"status": "error", "message": str(e)},
status=500
)
async def http_get_parameters(self, request):
"""HTTP GET /api/parameters"""
return web.json_response({
"brightness": self.lighting_controller.brightness,
"delay": self.lighting_controller.delay,
"n1": self.lighting_controller.n1,
"n2": self.lighting_controller.n2,
"n3": self.lighting_controller.n3,
"n4": self.lighting_controller.n4
})
# State API
async def http_get_state(self, request):
"""HTTP GET /api/state - Get complete system state"""
palette_config = self.lighting_controller.get_color_palette_config()
return web.json_response({
"pattern": self.lighting_controller.current_pattern,
"parameters": {
"brightness": self.lighting_controller.brightness,
"delay": self.lighting_controller.delay,
"n1": self.lighting_controller.n1,
"n2": self.lighting_controller.n2,
"n3": self.lighting_controller.n3,
"n4": self.lighting_controller.n4
},
"color_palette": palette_config,
"beat_index": self.lighting_controller.beat_index
})
# Tempo API
async def http_reset_tempo(self, request):
"""HTTP POST /api/tempo/reset"""
try:
await self.lighting_controller.sound_controller.send_reset_tempo()
return web.json_response({"status": "ok", "message": "Tempo reset sent"})
except Exception as e:
return web.json_response(
{"status": "error", "message": str(e)},
status=500
)
async def start_http_server(self):
"""Start combined HTTP and WebSocket server."""
self.http_app = web.Application()
# WebSocket endpoint (legacy support)
self.http_app.router.add_get('/ws', self.handle_websocket)
# REST API endpoints
# Color Palette
self.http_app.router.add_get('/api/color-palette', self.http_get_color_palette)
self.http_app.router.add_post('/api/color-palette', self.http_set_color_palette)
self.http_app.router.add_put('/api/color-palette', self.http_set_color_palette)
# Pattern
self.http_app.router.add_get('/api/pattern', self.http_get_pattern)
self.http_app.router.add_post('/api/pattern', self.http_set_pattern)
# Parameters
self.http_app.router.add_get('/api/parameters', self.http_get_parameters)
self.http_app.router.add_post('/api/parameters', self.http_set_parameters)
# State (complete system state)
self.http_app.router.add_get('/api/state', self.http_get_state)
# Tempo
self.http_app.router.add_post('/api/tempo/reset', self.http_reset_tempo)
self.http_runner = web.AppRunner(self.http_app)
await self.http_runner.setup()
host = os.getenv("CONTROL_SERVER_HOST", "0.0.0.0")
# Start WebSocket server on CONTROL_SERVER_PORT
ws_site = web.TCPSite(self.http_runner, host, CONTROL_SERVER_PORT)
await ws_site.start()
logging.info(f"WebSocket server listening on {host}:{CONTROL_SERVER_PORT}/ws")
logging.info(f"HTTP API server listening on {host}:{CONTROL_SERVER_PORT}")
# Also start on HTTP_API_PORT for backward compatibility
if HTTP_API_PORT != CONTROL_SERVER_PORT:
api_site = web.TCPSite(self.http_runner, host, HTTP_API_PORT)
await api_site.start()
logging.info(f"HTTP API also available on {host}:{HTTP_API_PORT}")
async def run(self):
"""Run the control server."""
# Connect to LED server
await self.lighting_controller.led_controller.connect()
# Start servers (optionally include heartbeat)
tcp_task = asyncio.create_task(self._tcp_server_task())
http_task = asyncio.create_task(self._http_server_task()) # Handles both WebSocket and HTTP
tasks = [tcp_task, http_task]
if self.enable_heartbeat:
heartbeat_task = asyncio.create_task(self._heartbeat_loop())
tasks.append(heartbeat_task)
await asyncio.gather(*tasks)
async def _tcp_server_task(self):
"""Keep TCP server running."""
await self.start_tcp_server()
# Keep the server running indefinitely
while True:
await asyncio.sleep(1)
async def _http_server_task(self):
"""Keep HTTP and WebSocket server running."""
await self.start_http_server()
# Keep the server running indefinitely
while True:
await asyncio.sleep(1)
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."""
args = parse_arguments()
transport_kwargs = {}
if args.transport == "spi":
transport_kwargs = {
'spi_bus': args.spi_bus,
'spi_device': args.spi_device,
'spi_speed_hz': args.spi_speed
}
elif args.transport == "websocket":
transport_kwargs = {
'uri': args.uri
}
server = ControlServer(
transport=args.transport,
enable_heartbeat=args.enable_heartbeat,
**transport_kwargs
)
try:
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()
def parse_arguments():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description="Control Server for Lighting Controller")
# Transport selection
transport_group = parser.add_argument_group("Transport Options")
default_transport = os.getenv("TRANSPORT", "spi")
transport_group.add_argument(
"--transport",
choices=["spi", "websocket"],
default=default_transport,
help=f"Transport method for LED communication (default from .env or {default_transport})"
)
# Control options
control_group = parser.add_argument_group("Control Options")
control_group.add_argument(
"--enable-heartbeat",
action="store_true",
help="Enable heartbeat system (may cause pattern interruptions)"
)
# SPI options
spi_group = parser.add_argument_group("SPI Options")
spi_group.add_argument(
"--spi-bus",
type=int,
default=0,
help="SPI bus number (default: 0)"
)
spi_group.add_argument(
"--spi-device",
type=int,
default=0,
help="SPI device number (default: 0)"
)
spi_group.add_argument(
"--spi-speed",
type=int,
default=1_000_000,
help="SPI speed in Hz (default: 1000000)"
)
# WebSocket options
ws_group = parser.add_argument_group("WebSocket Options")
ws_group.add_argument(
"--uri",
type=str,
default="ws://192.168.4.1/ws",
help="WebSocket URI for LED communication (default: ws://192.168.4.1/ws)"
)
return parser.parse_args()
if __name__ == "__main__":
asyncio.run(main())
#