Last active
May 4, 2026 06:39
-
-
Save maxnowack/4c8f1e7ac88d183f83f637c5c469bfec to your computer and use it in GitHub Desktop.
Hyperion LUT Proxy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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