Skip to content

Instantly share code, notes, and snippets.

@maxnowack
Last active May 4, 2026 06:39
Show Gist options
  • Select an option

  • Save maxnowack/4c8f1e7ac88d183f83f637c5c469bfec to your computer and use it in GitHub Desktop.

Select an option

Save maxnowack/4c8f1e7ac88d183f83f637c5c469bfec to your computer and use it in GitHub Desktop.
Hyperion LUT Proxy
#!/usr/bin/env python3
import json
import os
import signal
import socket
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
from rpi_ws281x import PixelStrip, Color, ws
# ---------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------
BASE_DIR = Path(__file__).resolve().parent
CONFIG_PATH = BASE_DIR / "hyperion_lut_config.json"
# ---------------------------------------------------------------------
# Hardware / WS281x settings
# ---------------------------------------------------------------------
HARDWARE_LED_COUNT = 231
ACTIVE_LED_COUNT = 231
LED_PIN = 12
LED_FREQ_HZ = 800000
LED_DMA = 5
LED_BRIGHTNESS = 255
LED_INVERT = False
LED_CHANNEL = 0
LED_STRIP_TYPE = ws.WS2811_STRIP_GRB
# ---------------------------------------------------------------------
# DDP settings
# ---------------------------------------------------------------------
DDP_HOST = "127.0.0.1"
DDP_PORT = 4048
# ---------------------------------------------------------------------
# Web UI settings
# ---------------------------------------------------------------------
WEB_HOST = "0.0.0.0"
WEB_PORT = 8099
DEBUG = True
# ---------------------------------------------------------------------
# Default LUT from your current setup
# ---------------------------------------------------------------------
DEFAULT_CURVE = {
"r": [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2,
2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5,
5, 6, 6, 6, 6, 8, 8, 8, 9, 9, 9, 10, 10, 12, 12, 12,
13, 13, 15, 15, 16, 16, 16, 18, 18, 20, 20, 22, 22, 24, 24, 26,
26, 28, 28, 30, 32, 32, 34, 34, 36, 38, 38, 40, 40, 42, 44, 44,
46, 47, 47, 49, 51, 52, 52, 54, 55, 55, 56, 57, 59, 60, 60, 61,
62, 63, 64, 64, 65, 66, 67, 68, 69, 70, 70, 70, 71, 72, 73, 74,
74, 75, 76, 77, 77, 78, 79, 80, 80, 81, 82, 82, 83, 84, 84, 85,
86, 87, 88, 89, 89, 90, 91, 92, 93, 94, 95, 96, 96, 98, 99, 99,
100, 101, 103, 103, 104, 106, 107, 107, 109, 110, 111, 112, 113, 114, 116, 116,
117, 119, 120, 121, 122, 124, 125, 127, 128, 130, 131, 132, 133, 135, 136, 138,
139, 141, 142, 143, 145, 146, 148, 149, 151, 152, 153, 155, 157, 158, 160, 162,
163, 164, 166, 168, 168, 170, 172, 173, 175, 176, 178, 180, 182, 184, 186, 188,
189, 191, 193, 195, 197, 199, 201, 202, 205, 206, 209, 212, 214, 216, 218, 220,
222, 224, 226, 228, 230, 232, 234, 237, 239, 241, 243, 245, 247, 250, 251, 255
],
"g": [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2,
3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6,
6, 6, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9, 9, 11, 11, 11,
12, 12, 14, 14, 16, 16, 16, 18, 18, 19, 19, 21, 21, 23, 23, 23,
23, 25, 25, 27, 29, 29, 31, 31, 33, 35, 35, 38, 38, 40, 42, 42,
44, 46, 46, 48, 48, 49, 49, 51, 52, 52, 54, 55, 57, 58, 58, 59,
60, 62, 63, 63, 63, 64, 65, 66, 67, 68, 69, 69, 70, 71, 72, 72,
73, 74, 74, 75, 76, 77, 77, 78, 79, 80, 80, 81, 82, 82, 82, 83,
84, 85, 86, 86, 87, 88, 89, 89, 91, 91, 91, 92, 93, 94, 95, 96,
97, 97, 99, 99, 100, 101, 102, 103, 104, 105, 106, 108, 109, 109, 110, 111,
112, 114, 115, 116, 117, 118, 119, 120, 121, 123, 124, 125, 126, 128, 128, 130,
131, 133, 133, 135, 137, 138, 139, 140, 141, 143, 144, 146, 148, 148, 149, 151,
153, 154, 156, 158, 159, 160, 161, 163, 165, 166, 168, 169, 170, 172, 173, 175,
176, 178, 179, 181, 183, 185, 187, 188, 191, 191, 194, 196, 198, 200, 202, 203,
205, 207, 209, 211, 213, 214, 216, 219, 221, 223, 225, 226, 228, 231, 232, 235
],
"b": [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4,
4, 4, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 8, 8, 8,
8, 9, 9, 9, 11, 11, 11, 11, 12, 12, 12, 12, 12, 14, 14, 14,
16, 16, 16, 18, 18, 20, 20, 20, 20, 20, 22, 22, 24, 24, 24, 26,
26, 26, 28, 28, 28, 31, 31, 33, 33, 35, 35, 35, 35, 37, 37, 40,
40, 42, 42, 44, 44, 44, 46, 46, 49, 49, 51, 51, 51, 53, 53, 55,
55, 56, 58, 58, 58, 60, 60, 61, 63, 63, 64, 64, 64, 66, 67, 67,
69, 69, 70, 70, 71, 73, 73, 74, 74, 75, 75, 77, 78, 78, 79, 79,
80, 82, 82, 83, 83, 84, 85, 86, 86, 87, 87, 88, 89, 91, 91, 92,
92, 93, 94, 95, 95, 96, 97, 97, 97, 98, 98, 99, 100, 101, 102, 102,
103, 103, 104, 104, 105, 106, 107, 107, 107, 108, 109, 109, 110, 110, 111, 111,
112, 112, 113, 113, 114, 115, 115, 115, 117, 117, 117, 118, 119, 119, 120, 121,
121, 122, 122, 123, 123, 124, 125, 126, 126, 127, 128, 128, 129, 130, 130, 131
],
}
DEFAULT_CONFIG = {
"input_gain": 1.0,
"output_gain": 1.0,
"gain": {
"r": 1.0,
"g": 1.0,
"b": 1.0,
},
"disabled_leds": [],
"curve": DEFAULT_CURVE,
}
# ---------------------------------------------------------------------
# Shared state
# ---------------------------------------------------------------------
config_lock = threading.RLock()
config = None
frame = bytearray(ACTIVE_LED_COUNT * 3)
strip = None
sock = None
httpd = None
packet_counter = 0
def log(message):
if DEBUG:
print(message, flush=True)
def clamp_u8(value):
return max(0, min(255, int(round(value))))
def validate_curve(curve):
if not isinstance(curve, dict):
raise ValueError("curve must be an object")
for channel in ("r", "g", "b"):
values = curve.get(channel)
if not isinstance(values, list):
raise ValueError(f"curve.{channel} must be a list")
if len(values) != 256:
raise ValueError(f"curve.{channel} has {len(values)} entries, expected 256")
for index, value in enumerate(values):
if not isinstance(value, int):
raise ValueError(f"curve.{channel}[{index}] must be an integer")
if value < 0 or value > 255:
raise ValueError(f"curve.{channel}[{index}] must be in range 0..255")
def normalize_config(raw):
if not isinstance(raw, dict):
raise ValueError("config must be an object")
merged = json.loads(json.dumps(DEFAULT_CONFIG))
if "input_gain" in raw:
merged["input_gain"] = float(raw["input_gain"])
if "output_gain" in raw:
merged["output_gain"] = float(raw["output_gain"])
if "gain" in raw and isinstance(raw["gain"], dict):
for channel in ("r", "g", "b"):
if channel in raw["gain"]:
merged["gain"][channel] = float(raw["gain"][channel])
if "disabled_leds" in raw:
if not isinstance(raw["disabled_leds"], list):
raise ValueError("disabled_leds must be a list")
disabled_leds = []
seen = set()
for value in raw["disabled_leds"]:
if not isinstance(value, int):
raise ValueError("disabled_leds must contain only integers")
if value < 0 or value >= HARDWARE_LED_COUNT:
raise ValueError(f"disabled_leds contains {value}, expected 0..{HARDWARE_LED_COUNT - 1}")
if value not in seen:
disabled_leds.append(value)
seen.add(value)
merged["disabled_leds"] = sorted(disabled_leds)
if "curve" in raw:
merged["curve"] = raw["curve"]
if merged["input_gain"] < 0 or merged["input_gain"] > 5:
raise ValueError("input_gain must be between 0 and 5")
if merged["output_gain"] < 0 or merged["output_gain"] > 5:
raise ValueError("output_gain must be between 0 and 5")
for channel in ("r", "g", "b"):
if merged["gain"][channel] < 0 or merged["gain"][channel] > 5:
raise ValueError(f"gain.{channel} must be between 0 and 5")
validate_curve(merged["curve"])
return merged
def atomic_write_json(path, data):
tmp_path = path.with_suffix(path.suffix + ".tmp")
with tmp_path.open("w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
os.replace(tmp_path, path)
def load_config():
global config
if not CONFIG_PATH.exists():
cfg = normalize_config(DEFAULT_CONFIG)
atomic_write_json(CONFIG_PATH, cfg)
else:
with CONFIG_PATH.open("r", encoding="utf-8") as f:
cfg = normalize_config(json.load(f))
with config_lock:
config = cfg
print(f"Loaded config from {CONFIG_PATH}", flush=True)
def save_and_apply_config(new_config):
global config
cfg = normalize_config(new_config)
atomic_write_json(CONFIG_PATH, cfg)
with config_lock:
config = cfg
print(f"Applied and saved config to {CONFIG_PATH}", flush=True)
return cfg
def get_config_copy():
with config_lock:
return json.loads(json.dumps(config))
# ---------------------------------------------------------------------
# Web UI
# ---------------------------------------------------------------------
INDEX_HTML = r"""
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hyperion LUT Proxy</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #101114;
color: #f1f1f1;
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
}
h1 { margin: 0 0 6px; font-size: 28px; }
.sub { color: #aaa; margin-bottom: 24px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
.card {
background: #1a1c22;
border: 1px solid #2b2e38;
border-radius: 16px;
padding: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,0.25);
}
label { display: block; font-size: 14px; color: #c9c9c9; margin-bottom: 8px; }
.row { display: grid; grid-template-columns: 1fr 90px; gap: 10px; align-items: center; margin: 14px 0; }
input[type="range"] { width: 100%; }
input[type="number"] {
width: 100%; box-sizing: border-box; padding: 8px;
background: #0e0f12; color: #fff; border: 1px solid #333845; border-radius: 10px;
}
textarea {
width: 100%; min-height: 300px; box-sizing: border-box;
background: #0e0f12; color: #e8e8e8; border: 1px solid #333845; border-radius: 12px;
padding: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px; line-height: 1.35;
}
button {
border: 0; border-radius: 12px; padding: 10px 14px; cursor: pointer;
background: #4f7cff; color: white; font-weight: 650; margin-right: 8px; margin-top: 10px;
}
button.secondary { background: #303440; }
button.danger { background: #b23b3b; }
.status { margin-top: 14px; color: #9fd49f; min-height: 22px; }
.error { color: #ff9d9d; }
.preview {
height: 36px; border-radius: 12px; border: 1px solid #333845;
background: rgb(255,235,131); margin-top: 10px;
}
.small { font-size: 13px; color: #aaa; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
</style>
</head>
<body>
<main>
<h1>Hyperion LUT Proxy</h1>
<div class="sub">Änderungen werden in <span class="mono">hyperion_lut_config.json</span> gespeichert und sofort im laufenden Proxy verwendet.</div>
<div class="grid">
<section class="card">
<h2>Gain</h2>
<div class="small">Input Gain wirkt vor der LUT. Output/Channel Gain wirken nach der LUT.</div>
<div id="gain-controls"></div>
<button class="secondary" onclick="loadConfig()">Neu laden</button>
<div id="gain-status" class="status"></div>
</section>
<section class="card">
<h2>LEDs deaktivieren</h2>
<div class="small">Index ist nullbasiert: erste LED = 0, letzte LED = 230. Hinzufügen oder Entfernen speichert sofort.</div>
<div class="row">
<div>
<label>LED-Index</label>
<input id="disabled-led-input" type="number" min="0" max="230" step="1" value="0" onkeydown="handleDisabledLedKeydown(event)">
</div>
<div style="align-self:end">
<button onclick="addDisabledLed()" style="margin-top:0">Hinzufügen</button>
</div>
</div>
<div id="disabled-led-list" style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;"></div>
<div id="disabled-status" class="status"></div>
</section>
<section class="card">
<h2>Weiß-Test</h2>
<div class="small">Vorschau für Input 255/255/255 nach LUT + Gain.</div>
<div id="white-values" class="mono"></div>
<div id="white-preview" class="preview"></div>
</section>
</div>
<section class="card" style="margin-top:16px">
<h2>Kurven bearbeiten</h2>
<div class="small">Format: JSON-Objekt mit <span class="mono">r</span>, <span class="mono">g</span>, <span class="mono">b</span>, jeweils exakt 256 Integer von 0 bis 255.</div>
<textarea id="curve-text"></textarea>
<button onclick="saveCurve()">Kurven speichern</button>
<button class="secondary" onclick="formatCurve()">Formatieren</button>
<button class="danger" onclick="resetDefault()">Default wiederherstellen</button>
<div id="curve-status" class="status"></div>
</section>
</main>
<script>
let cfg = null;
const gainFields = [
["input_gain", "Input Gain", 0, 3, 0.01],
["output_gain", "Output Gain", 0, 3, 0.01],
["gain.r", "Rot Gain", 0, 3, 0.01],
["gain.g", "Grün Gain", 0, 3, 0.01],
["gain.b", "Blau Gain", 0, 3, 0.01],
];
function getPath(obj, path) {
return path.split('.').reduce((o, p) => o[p], obj);
}
function setPath(obj, path, value) {
const parts = path.split('.');
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]];
cur[parts[parts.length - 1]] = value;
}
function setStatus(id, text, isError=false) {
const el = document.getElementById(id);
el.textContent = text;
el.className = isError ? 'status error' : 'status';
}
let autoSaveTimer = null;
function autoSaveConfig(statusId) {
setStatus(statusId, 'Speichere...');
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(async () => {
try {
await postConfig(cfg, statusId, { rerender: false });
} catch (e) {
setStatus(statusId, e.message, true);
}
}, 250);
}
function renderGainControls() {
const root = document.getElementById('gain-controls');
root.innerHTML = '';
for (const [path, label, min, max, step] of gainFields) {
const value = getPath(cfg, path);
const wrap = document.createElement('div');
wrap.className = 'row';
wrap.innerHTML = `
<div>
<label>${label}</label>
<input id="${path}-range" type="range" min="${min}" max="${max}" step="${step}" value="${value}">
</div>
<input id="${path}-num" type="number" min="${min}" max="${max}" step="${step}" value="${value}">
`;
root.appendChild(wrap);
const range = document.getElementById(`${path}-range`);
const num = document.getElementById(`${path}-num`);
const sync = (source, target) => {
target.value = source.value;
setPath(cfg, path, parseFloat(source.value));
renderWhitePreview();
autoSaveConfig('gain-status');
};
range.addEventListener('input', () => sync(range, num));
num.addEventListener('input', () => sync(num, range));
}
}
function renderDisabledLeds() {
const list = Array.isArray(cfg.disabled_leds) ? cfg.disabled_leds : [];
const root = document.getElementById('disabled-led-list');
root.innerHTML = '';
if (list.length === 0) {
const empty = document.createElement('div');
empty.className = 'small';
empty.textContent = 'Keine LEDs deaktiviert.';
root.appendChild(empty);
return;
}
for (const led of list) {
const item = document.createElement('div');
item.style.display = 'inline-flex';
item.style.alignItems = 'center';
item.style.gap = '8px';
item.style.padding = '6px 8px';
item.style.border = '1px solid #333845';
item.style.borderRadius = '999px';
item.style.background = '#0e0f12';
item.innerHTML = `<span class="mono">${led}</span>`;
const remove = document.createElement('button');
remove.textContent = '×';
remove.title = 'LED ' + led + ' entfernen';
remove.style.margin = '0';
remove.style.padding = '2px 7px';
remove.style.borderRadius = '999px';
remove.style.background = '#303440';
remove.onclick = () => removeDisabledLed(led);
item.appendChild(remove);
root.appendChild(item);
}
}
async function saveDisabledLedsNow(message) {
try {
cfg.disabled_leds = [...new Set(cfg.disabled_leds || [])]
.filter(n => Number.isInteger(n) && n >= 0 && n <= 230)
.sort((a, b) => a - b);
await postConfig(cfg, 'disabled-status');
if (message) setStatus('disabled-status', message);
} catch (e) {
setStatus('disabled-status', e.message, true);
}
}
function handleDisabledLedKeydown(event) {
if (event.key === 'Enter') {
event.preventDefault();
addDisabledLed();
}
}
async function addDisabledLed() {
try {
const input = document.getElementById('disabled-led-input');
const n = Number(input.value);
if (!Number.isInteger(n) || n < 0 || n > 230) throw new Error('Index muss zwischen 0 und 230 liegen.');
const current = Array.isArray(cfg.disabled_leds) ? cfg.disabled_leds : [];
if (!current.includes(n)) current.push(n);
cfg.disabled_leds = [...new Set(current)].sort((a, b) => a - b);
renderDisabledLeds();
await saveDisabledLedsNow('LED ' + n + ' deaktiviert und gespeichert.');
} catch (e) {
setStatus('disabled-status', e.message, true);
}
}
async function removeDisabledLed(led) {
cfg.disabled_leds = (cfg.disabled_leds || []).filter(n => n !== led).sort((a, b) => a - b);
renderDisabledLeds();
await saveDisabledLedsNow('LED ' + led + ' entfernt und gespeichert.');
}
function renderWhitePreview() {
if (!cfg) return;
const r0 = Math.min(255, Math.round(255 * cfg.input_gain));
const g0 = Math.min(255, Math.round(255 * cfg.input_gain));
const b0 = Math.min(255, Math.round(255 * cfg.input_gain));
const r = Math.min(255, Math.round(cfg.curve.r[r0] * cfg.output_gain * cfg.gain.r));
const g = Math.min(255, Math.round(cfg.curve.g[g0] * cfg.output_gain * cfg.gain.g));
const b = Math.min(255, Math.round(cfg.curve.b[b0] * cfg.output_gain * cfg.gain.b));
document.getElementById('white-values').textContent = `Input nach Gain: (${r0}, ${g0}, ${b0}) → Output: (${r}, ${g}, ${b})`;
document.getElementById('white-preview').style.background = `rgb(${r},${g},${b})`;
}
async function loadConfig() {
const res = await fetch('/api/config');
cfg = await res.json();
if (!Array.isArray(cfg.disabled_leds)) cfg.disabled_leds = [];
renderGainControls();
renderDisabledLeds();
document.getElementById('curve-text').value = JSON.stringify(cfg.curve, null, 2);
renderWhitePreview();
}
async function postConfig(newCfg, statusId, options = {}) {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newCfg),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error || 'Speichern fehlgeschlagen');
cfg = body;
if (!Array.isArray(cfg.disabled_leds)) cfg.disabled_leds = [];
if (options.rerender !== false) {
renderGainControls();
renderDisabledLeds();
document.getElementById('curve-text').value = JSON.stringify(cfg.curve, null, 2);
}
renderWhitePreview();
setStatus(statusId, 'Gespeichert und aktiv.');
}
async function saveGain() {
try {
await postConfig(cfg, 'gain-status');
} catch (e) {
setStatus('gain-status', e.message, true);
}
}
async function saveCurve() {
try {
const curve = JSON.parse(document.getElementById('curve-text').value);
cfg.curve = curve;
await postConfig(cfg, 'curve-status');
} catch (e) {
setStatus('curve-status', e.message, true);
}
}
function formatCurve() {
try {
const curve = JSON.parse(document.getElementById('curve-text').value);
document.getElementById('curve-text').value = JSON.stringify(curve, null, 2);
setStatus('curve-status', 'Formatiert.');
} catch (e) {
setStatus('curve-status', e.message, true);
}
}
async function resetDefault() {
if (!confirm('Default-Kurven wirklich wiederherstellen?')) return;
try {
const res = await fetch('/api/default');
const defaults = await res.json();
cfg.curve = defaults.curve;
await postConfig(cfg, 'curve-status');
} catch (e) {
setStatus('curve-status', e.message, true);
}
}
loadConfig().catch(e => {
document.body.innerHTML = '<main><h1>Fehler</h1><p>' + e.message + '</p></main>';
});
</script>
</body>
</html>
"""
class WebHandler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
if DEBUG:
print(f"WEB {self.address_string()} - {fmt % args}", flush=True)
def send_json(self, status, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def send_html(self, html):
body = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = urlparse(self.path).path
if path == "/":
self.send_html(INDEX_HTML)
elif path == "/api/config":
self.send_json(200, get_config_copy())
elif path == "/api/default":
self.send_json(200, DEFAULT_CONFIG)
else:
self.send_json(404, {"error": "not found"})
def do_POST(self):
path = urlparse(self.path).path
if path != "/api/config":
self.send_json(404, {"error": "not found"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length).decode("utf-8")
new_config = json.loads(raw)
saved = save_and_apply_config(new_config)
self.send_json(200, saved)
except Exception as exc:
self.send_json(400, {"error": str(exc)})
def start_web_server():
global httpd
httpd = ThreadingHTTPServer((WEB_HOST, WEB_PORT), WebHandler)
print(f"Web UI listening on http://{WEB_HOST}:{WEB_PORT}", flush=True)
httpd.serve_forever()
# ---------------------------------------------------------------------
# LED output
# ---------------------------------------------------------------------
def clear_strip():
if strip is None:
return
for i in range(HARDWARE_LED_COUNT):
strip.setPixelColor(i, Color(0, 0, 0))
strip.show()
def show_frame():
with config_lock:
cfg = config
curve = cfg["curve"]
input_gain = cfg["input_gain"]
output_gain = cfg["output_gain"]
gain_r = cfg["gain"]["r"]
gain_g = cfg["gain"]["g"]
gain_b = cfg["gain"]["b"]
disabled_leds = set(cfg.get("disabled_leds", []))
for i in range(ACTIVE_LED_COUNT):
if i in disabled_leds:
strip.setPixelColor(i, Color(0, 0, 0))
continue
r = frame[i * 3 + 0]
g = frame[i * 3 + 1]
b = frame[i * 3 + 2]
r_in = clamp_u8(r * input_gain)
g_in = clamp_u8(g * input_gain)
b_in = clamp_u8(b * input_gain)
rr = clamp_u8(curve["r"][r_in] * output_gain * gain_r)
gg = clamp_u8(curve["g"][g_in] * output_gain * gain_g)
bb = clamp_u8(curve["b"][b_in] * output_gain * gain_b)
strip.setPixelColor(i, Color(rr, gg, bb))
for i in range(ACTIVE_LED_COUNT, HARDWARE_LED_COUNT):
strip.setPixelColor(i, Color(0, 0, 0))
strip.show()
# ---------------------------------------------------------------------
# DDP handling
# ---------------------------------------------------------------------
def handle_ddp_packet(packet: bytes):
global packet_counter
if len(packet) < 10:
log(f"Short packet ignored: len={len(packet)}")
return
offset = int.from_bytes(packet[4:8], "big")
length = int.from_bytes(packet[8:10], "big")
payload = packet[10:10 + length]
if not payload:
return
if offset >= len(frame):
log(f"Offset outside frame ignored: offset={offset} frame_len={len(frame)}")
return
end = min(offset + len(payload), len(frame))
frame[offset:end] = payload[:end - offset]
packet_counter += 1
if DEBUG and packet_counter <= 10:
log(
f"DDP packet #{packet_counter}: packet_len={len(packet)} "
f"offset={offset} length={length} payload={len(payload)} frame_len={len(frame)}"
)
elif DEBUG and packet_counter == 11:
log("DDP packets received; suppressing further per-packet logs")
show_frame()
# ---------------------------------------------------------------------
# Shutdown / main
# ---------------------------------------------------------------------
def shutdown(_signum=None, _frame=None):
print("Shutting down, clearing LEDs", flush=True)
try:
clear_strip()
except Exception as exc:
print(f"Error while clearing LEDs: {exc}", flush=True)
if sock is not None:
try:
sock.close()
except OSError:
pass
if httpd is not None:
try:
httpd.shutdown()
except Exception:
pass
sys.exit(0)
def main():
global strip, sock
load_config()
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
print("Starting Hyperion LUT Proxy", flush=True)
print(f"Config: {CONFIG_PATH}", flush=True)
print(f"DDP listen: {DDP_HOST}:{DDP_PORT}", flush=True)
print(f"Web UI: http://<raspberry-pi-ip>:{WEB_PORT}", flush=True)
print(f"Active LEDs: {ACTIVE_LED_COUNT}", flush=True)
print(f"Hardware LEDs: {HARDWARE_LED_COUNT}", flush=True)
print(f"GPIO: {LED_PIN}", flush=True)
print(f"DMA: {LED_DMA}", flush=True)
print(f"PWM channel: {LED_CHANNEL}", flush=True)
strip = PixelStrip(
HARDWARE_LED_COUNT,
LED_PIN,
LED_FREQ_HZ,
LED_DMA,
LED_INVERT,
LED_BRIGHTNESS,
LED_CHANNEL,
LED_STRIP_TYPE,
)
strip.begin()
print("WS281x initialized", flush=True)
clear_strip()
web_thread = threading.Thread(target=start_web_server, daemon=True)
web_thread.start()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((DDP_HOST, DDP_PORT))
print("Listening for DDP packets", flush=True)
while True:
packet, _addr = sock.recvfrom(2048)
handle_ddp_packet(packet)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment