Nicole Portas 1 місяць тому
коміт
44c7ff3407
1 змінених файлів з 1066 додано та 0 видалено
  1. 1066 0
      matrix_billboard.py

+ 1066 - 0
matrix_billboard.py

@@ -0,0 +1,1066 @@
+#!/usr/bin/env python3
+import argparse
+import colorsys
+import datetime
+import json
+import math
+import os
+import socket
+import sys
+import threading
+import time
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from PIL import Image, ImageDraw, ImageEnhance, ImageFont
+
+# --- DDP Network Engine ---
+CHUNK_SIZE = 1440
+
+COLOR_MAP = {
+    "red": "#FF0000", "green": "#00FF00", "blue": "#0000FF",
+    "cyan": "#00FFFF", "magenta": "#FF00FF", "yellow": "#FFFF00",
+    "white": "#FFFFFF", "black": "#000000", "orange": "#FF7F00",
+    "purple": "#800080", "pink": "#FFC0CB", "hotpink": "#FF1493",
+    "lime": "#00FF00", "teal": "#008080", "navy": "#000080",
+    "amber": "#FFBF00", "violet": "#8A2BE2", "turquoise": "#40E0D0"
+}
+
+def is_monospace_font(path):
+    try:
+        font = ImageFont.truetype(path, 20)
+        w_i = font.getlength('i')
+        w_w = font.getlength('W')
+        w_m = font.getlength('m')
+        return abs(w_i - w_w) < 0.1 and abs(w_i - w_m) < 0.1
+    except Exception:
+        name = os.path.basename(path).lower()
+        return any(k in name for k in ["mono", "code", "term", "courier", "consolas", "fixed"])
+
+def scan_system_fonts():
+    font_dirs = [
+        "/usr/share/fonts/truetype",
+        "/usr/local/share/fonts",
+        os.path.expanduser("~/.local/share/fonts"),
+        os.path.expanduser("~/.fonts")
+    ]
+    all_fonts = {}
+    mono_fonts = {}
+    for directory in font_dirs:
+        if os.path.exists(directory):
+            for root, _, files in os.walk(directory):
+                for file in files:
+                    if file.lower().endswith(".ttf"):
+                        path = os.path.join(root, file)
+                        key = file.lower().replace(".ttf", "")
+                        all_fonts[key] = path
+                        if is_monospace_font(path):
+                            mono_fonts[key] = path
+    if not all_fonts:
+        default_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
+        all_fonts["default"] = default_path
+        mono_fonts["default"] = default_path
+    return dict(sorted(all_fonts.items())), dict(sorted(mono_fonts.items()))
+
+ALL_FONT_MAP, MONO_FONT_MAP = scan_system_fonts()
+
+def parse_rgb(rgb_val):
+    if isinstance(rgb_val, (list, tuple)):
+        if len(rgb_val) == 3 and all(isinstance(x, int) and 0 <= x <= 255 for x in rgb_val):
+            return tuple(rgb_val)
+        raise ValueError("RGB tuple/list must contain 3 integers between 0 and 255.")
+    val = str(rgb_val).strip().lower()
+    if val in COLOR_MAP:
+        val = COLOR_MAP[val]
+    try:
+        if val.startswith("#"):
+            val = val.lstrip("#")
+        if len(val) == 6:
+            return tuple(int(val[i:i+2], 16) for i in (0, 2, 4))
+        parts = [int(x.strip()) for x in str(rgb_val).split(",")]
+        if len(parts) == 3:
+            return tuple(parts)
+    except Exception:
+        pass
+    raise ValueError(f"Invalid color '{rgb_val}'. Use a valid name, hex '#RRGGBB', or 'R,G,B'.")
+
+def send_ddp_frame(sock, raw_rgb, ip, port, seq):
+    total_bytes = len(raw_rgb)
+    offset = 0
+    while offset < total_bytes:
+        chunk = raw_rgb[offset : offset + CHUNK_SIZE]
+        is_last = (offset + len(chunk)) >= total_bytes
+        flags = 0x41 if is_last else 0x40
+        header = bytearray([
+            flags, seq & 0x0F, 0x01, 0x01,
+            (offset >> 24) & 0xFF, (offset >> 16) & 0xFF,
+            (offset >> 8) & 0xFF, offset & 0xFF,
+            (len(chunk) >> 8) & 0xFF, len(chunk) & 0xFF
+        ])
+        sock.sendto(header + chunk, (ip, port))
+        offset += len(chunk)
+
+def clear_matrix(sock, ip, port, width, height):
+    send_ddp_frame(sock, bytes([0, 0, 0] * (width * height)), ip, port, 0)
+
+# --- Graphics & Render Engine ---
+def generate_effect_layer(w, h, effect, color1, color2, frame_idx):
+    layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
+    draw = ImageDraw.Draw(layer)
+
+    if effect == "solid":
+        draw.rectangle([(0, 0), (w, h)], fill=color1 + (255,))
+    elif effect == "rainbow":
+        for x in range(w):
+            hue = ((x / float(max(1, w))) + (frame_idx * 0.02)) % 1.0
+            r, g, b = [int(c * 255) for c in colorsys.hsv_to_rgb(hue, 1.0, 1.0)]
+            draw.line([(x, 0), (x, h)], fill=(r, g, b, 255))
+    elif effect == "gradient-h":
+        for x in range(w):
+            t = x / float(max(1, w - 1))
+            r = int(color1[0] + (color2[0] - color1[0]) * t)
+            g = int(color1[1] + (color2[1] - color1[1]) * t)
+            b = int(color1[2] + (color2[2] - color1[2]) * t)
+            draw.line([(x, 0), (x, h)], fill=(r, g, b, 255))
+    elif effect == "gradient-v":
+        for y in range(h):
+            t = y / float(max(1, h - 1))
+            r = int(color1[0] + (color2[0] - color1[0]) * t)
+            g = int(color1[1] + (color2[1] - color1[1]) * t)
+            b = int(color1[2] + (color2[2] - color1[2]) * t)
+            draw.line([(0, y), (w, y)], fill=(r, g, b, 255))
+    elif effect == "pulse":
+        factor = (math.sin(frame_idx * 0.15) + 1.0) / 2.0
+        r = int(color1[0] * (0.15 + 0.85 * factor))
+        g = int(color1[1] * (0.15 + 0.85 * factor))
+        b = int(color1[2] * (0.15 + 0.85 * factor))
+        draw.rectangle([(0, 0), (w, h)], fill=(r, g, b, 255))
+
+    return layer
+
+def apply_text_effect(base_banner, effect, color1, color2, frame_idx):
+    w, h = base_banner.size
+    alpha_mask = base_banner.split()[3]
+    
+    overlay = generate_effect_layer(w, h, effect, color1, color2, frame_idx)
+    overlay.putalpha(alpha_mask)
+    
+    if getattr(base_banner, "is_analog_clock", False):
+        final = Image.alpha_composite(overlay, base_banner)
+        final.putalpha(alpha_mask)
+        return final
+        
+    return overlay
+
+def render_base_text(text, font_path, target_height):
+    try:
+        font = ImageFont.truetype(font_path, 80)
+    except IOError:
+        font = ImageFont.load_default()
+
+    temp_img = Image.new("RGBA", (3000, 250), (0, 0, 0, 0))
+    temp_draw = ImageDraw.Draw(temp_img)
+    temp_draw.text((10, 10), text, font=font, fill=(255, 255, 255, 255))
+    bbox = temp_img.getbbox()
+    
+    if not bbox:
+        return Image.new("RGBA", (1, target_height), (0, 0, 0, 0))
+    
+    cropped = temp_img.crop(bbox)
+    scale_factor = target_height / float(cropped.size[1])
+    new_w = max(1, int(cropped.size[0] * scale_factor))
+    return cropped.resize((new_w, target_height), Image.Resampling.LANCZOS)
+
+def render_fitted_text(text, font_path, max_w, max_h):
+    try:
+        font = ImageFont.truetype(font_path, 80)
+    except IOError:
+        font = ImageFont.load_default()
+        
+    temp_img = Image.new("RGBA", (2000, 300), (0, 0, 0, 0))
+    temp_draw = ImageDraw.Draw(temp_img)
+    temp_draw.text((10, 10), text, font=font, fill=(255, 255, 255, 255))
+    bbox = temp_img.getbbox()
+    
+    if not bbox:
+        return Image.new("RGBA", (1, 1), (0, 0, 0, 0))
+        
+    cropped = temp_img.crop(bbox)
+    scale = min(max_w / float(cropped.size[0]), max_h / float(cropped.size[1]))
+    new_w = max(1, int(cropped.size[0] * scale))
+    new_h = max(1, int(cropped.size[1] * scale))
+    return cropped.resize((new_w, new_h), Image.Resampling.LANCZOS)
+
+def render_analog_clock(width, height):
+    now = datetime.datetime.now()
+    img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
+    draw = ImageDraw.Draw(img)
+    
+    center_x, center_y = width // 2, height // 2
+    radius = min(center_x, center_y) - 1
+    
+    draw.ellipse([center_x - radius, center_y - radius, center_x + radius, center_y + radius], outline=(255, 255, 255, 255), width=1)
+    
+    sec_angle = math.radians(now.second * 6 - 90)
+    min_angle = math.radians(now.minute * 6 - 90)
+    hr_angle = math.radians((now.hour % 12) * 30 + (now.minute / 2) - 90)
+    
+    draw.line([center_x, center_y, center_x + int(radius * 0.5) * math.cos(hr_angle), center_y + int(radius * 0.5) * math.sin(hr_angle)], fill=(255, 255, 255, 255), width=2)
+    draw.line([center_x, center_y, center_x + int(radius * 0.8) * math.cos(min_angle), center_y + int(radius * 0.8) * math.sin(min_angle)], fill=(255, 255, 255, 255), width=1)
+    draw.line([center_x, center_y, center_x + int(radius * 0.9) * math.cos(sec_angle), center_y + int(radius * 0.9) * math.sin(sec_angle)], fill=(255, 0, 0, 255), width=1)
+    
+    img.is_analog_clock = True
+    return img
+
+def render_digital_clock(width, height, font_path, show_date=True, show_seconds=True, blink=True):
+    now = datetime.datetime.now()
+    img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
+    
+    fmt = "%H:%M:%S" if show_seconds else "%H:%M"
+    time_str = now.strftime(fmt)
+
+    if blink and now.second % 2 == 0:
+        time_str = time_str.replace(":", " ")
+
+    max_w = width - 2
+    max_h = (height // 2) - 2 if show_date else height - 2
+    
+    time_img = render_fitted_text(time_str, font_path, max_w, max_h)
+
+    t_x = (width - time_img.size[0]) // 2
+    t_y = ((height // 2) - time_img.size[1]) // 2 if show_date else (height - time_img.size[1]) // 2
+
+    img.paste(time_img, (t_x, t_y), time_img)
+
+    if show_date:
+        date_str = now.strftime("%a %d %b")
+        date_img = render_fitted_text(date_str, font_path, max_w, max_h)
+        
+        d_x = (width - date_img.size[0]) // 2
+        d_y = (height // 2) + (((height // 2) - date_img.size[1]) // 2)
+        
+        img.paste(date_img, (d_x, d_y), date_img)
+    
+    return img
+
+# --- Thread-Safe Billboard Animation Manager ---
+class BillboardDaemon:
+    def __init__(self, default_ip, default_port, default_width, default_height, default_font):
+        self.default_ip = default_ip
+        self.default_port = default_port
+        self.default_width = default_width
+        self.default_height = default_height
+        self.default_font = default_font
+        
+        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+        self.stop_event = threading.Event()
+        self.worker_thread = None
+        self.lock = threading.Lock()
+
+    def start_animation(self, params):
+        with self.lock:
+            if self.worker_thread and self.worker_thread.is_alive():
+                self.stop_event.set()
+                self.worker_thread.join()
+
+            self.stop_event.clear()
+            self.worker_thread = threading.Thread(
+                target=self._render_loop,
+                args=(params,),
+                daemon=True
+            )
+            self.worker_thread.start()
+
+    def stop_animation(self):
+        with self.lock:
+            if self.worker_thread and self.worker_thread.is_alive():
+                self.stop_event.set()
+                self.worker_thread.join()
+            clear_matrix(self.sock, self.default_ip, self.default_port, self.default_width, self.default_height)
+
+    def _render_loop(self, params):
+        mode = params.get("mode", "scroll")
+        ip = params.get("ip", self.default_ip)
+        port = params.get("port", self.default_port)
+        width = params.get("width", self.default_width)
+        height = params.get("height", self.default_height)
+        font_path = params.get("font", self.default_font)
+        
+        effect = params.get("effect", "rainbow")
+        color1 = params.get("color", (255, 0, 255))
+        color2 = params.get("color2", (0, 255, 255))
+        
+        bg_effect = params.get("bg_effect", "solid")
+        bg_color = params.get("bg_color", (0, 0, 0))
+        bg_color2 = params.get("bg_color2", (0, 0, 128))
+        
+        brightness = max(0, min(255, params.get("brightness", 255))) / 255.0
+        
+        seq = 0
+        frame_idx = 0
+
+        if mode in ["clock_digital", "clock_analog"]:
+            show_date = params.get("clock_date", True)
+            show_seconds = params.get("clock_seconds", True)
+            blink = params.get("clock_blink", True)
+            
+            while not self.stop_event.is_set():
+                if mode == "clock_analog":
+                    base_mask = render_analog_clock(width, height)
+                else:
+                    base_mask = render_digital_clock(width, height, font_path, show_date, show_seconds, blink)
+                
+                styled_mask = apply_text_effect(base_mask, effect, color1, color2, frame_idx)
+                
+                bg_layer = generate_effect_layer(width, height, bg_effect, bg_color, bg_color2, frame_idx).convert("RGB")
+                bg_layer.paste(styled_mask, (0, 0), styled_mask)
+                
+                if brightness < 1.0:
+                    bg_layer = ImageEnhance.Brightness(bg_layer).enhance(brightness)
+
+                send_ddp_frame(self.sock, bg_layer.tobytes(), ip, port, seq)
+                seq = (seq + 1) % 16
+                frame_idx += 1
+                
+                if self.stop_event.wait(0.1):
+                    break
+            
+            clear_matrix(self.sock, ip, port, width, height)
+            return
+
+        text = params.get("text", "WOMAN CAVE")
+        speed = params.get("speed", 0.05)
+        loops = params.get("loops", 0)
+        sweep_pause = params.get("sweep_pause", 2.0)
+
+        base_banner = render_base_text(text, font_path, height)
+        banner_width = base_banner.size[0]
+        loop_count = 0
+
+        while not self.stop_event.is_set():
+            for x_pos in range(width, -banner_width - 1, -1):
+                if self.stop_event.is_set():
+                    break
+
+                styled_banner = apply_text_effect(base_banner, effect, color1, color2, frame_idx)
+                
+                bg_layer = generate_effect_layer(width, height, bg_effect, bg_color, bg_color2, frame_idx).convert("RGB")
+                bg_layer.paste(styled_banner, (x_pos, 0), styled_banner)
+
+                if brightness < 1.0:
+                    bg_layer = ImageEnhance.Brightness(bg_layer).enhance(brightness)
+
+                send_ddp_frame(self.sock, bg_layer.tobytes(), ip, port, seq)
+                seq = (seq + 1) % 16
+                frame_idx += 1
+                
+                if self.stop_event.wait(speed):
+                    break
+
+            if sweep_pause > 0 and not self.stop_event.is_set():
+                pause_end = time.time() + sweep_pause
+                while time.time() < pause_end and not self.stop_event.is_set():
+                    bg_layer = generate_effect_layer(width, height, bg_effect, bg_color, bg_color2, frame_idx).convert("RGB")
+                    if brightness < 1.0:
+                        bg_layer = ImageEnhance.Brightness(bg_layer).enhance(brightness)
+                        
+                    send_ddp_frame(self.sock, bg_layer.tobytes(), ip, port, seq)
+                    seq = (seq + 1) % 16
+                    frame_idx += 1
+                    
+                    if self.stop_event.wait(0.1):
+                        break
+
+            loop_count += 1
+            if loops > 0 and loop_count >= loops:
+                break
+
+        clear_matrix(self.sock, ip, port, width, height)
+
+
+# --- HTML Documentation Template ---
+HTML_DOCS_TEMPLATE = """<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>WLED Billboard Daemon API</title>
+    <style>
+        :root { --bg: #0e1117; --card: #161b22; --border: #30363d; --text: #c9d1d9; --accent: #00ffff; --accent2: #ff00ff; --danger: #ff4444; }
+        body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 2rem; line-height: 1.5; }
+        .container { max-width: 950px; margin: 0 auto; }
+        h1 { color: #fff; font-size: 2.2rem; border-bottom: 2px solid var(--border); padding-bottom: 0.5rem; margin-top: 0; }
+        h2 { color: var(--accent); margin-top: 1.5rem; }
+        .card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem; }
+        code, pre { background: #000; font-family: monospace; border-radius: 4px; }
+        code { color: var(--accent); padding: 0.2rem 0.4rem; }
+        pre { padding: 1rem; overflow-x: auto; border: 1px solid var(--border); color: #00ff66; white-space: pre-wrap; word-break: break-all; }
+        table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
+        th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--border); }
+        th { color: var(--accent2); }
+        .btn { padding: 0.6rem 1.2rem; border: none; border-radius: 4px; font-weight: bold; cursor: pointer; transition: 0.2s; }
+        .btn-primary { background: var(--accent); color: #000; }
+        .btn-danger { background: var(--danger); color: #fff; margin-left: 0.5rem; }
+        .btn:hover { opacity: 0.85; }
+        form { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
+        .full-width { grid-column: span 2; }
+        label { display: block; font-weight: bold; margin-bottom: 0.3rem; font-size: 0.9rem; }
+        input[type="text"], input[type="number"], select { width: 100%; padding: 0.5rem; background: #000; border: 1px solid var(--border); color: #fff; border-radius: 4px; box-sizing: border-box; }
+        input[type="range"] { width: 100%; margin-top: 0.5rem; cursor: pointer; }
+        input[type="color"] { width: 100%; height: 40px; padding: 2px; background: #000; border: 1px solid var(--border); border-radius: 4px; cursor: pointer; box-sizing: border-box; }
+        
+        .slider-group { background: #111; padding: 1rem; border: 1px solid var(--border); border-radius: 6px; display: grid; grid-template-columns: 1fr; gap: 1rem; margin-top: 0.5rem; margin-bottom: 0.5rem; }
+        .effect-group { padding: 1rem; border: 1px solid var(--border); border-radius: 6px; display: grid; grid-template-columns: 1fr; gap: 0.5rem; }
+        .effect-group h3 { margin: 0 0 0.5rem 0; font-size: 1rem; color: #fff; }
+
+        #status { margin-top: 1rem; font-weight: bold; }
+
+        .preview-wrapper { margin-top: 0.5rem; border: 1px dashed var(--border); border-radius: 4px; overflow: hidden; transition: 0.3s; background: #000; white-space: nowrap; }
+        
+        #fontPreview { 
+            font-size: 2rem; line-height: 1.2; padding: 1rem; text-align: center; transition: 0.3s;
+        }
+
+        @keyframes marquee {
+            0% { transform: translate(0, 0); }
+            100% { transform: translate(-100%, 0); }
+        }
+        
+        .val-display { color: var(--accent); font-family: monospace; font-weight: normal; margin-left: 0.5rem; }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <h1>WLED Billboard Daemon Controller</h1>
+        
+        <div class="card">
+            <h2>Interactive Web Control Panel</h2>
+            <form id="apiForm" onsubmit="sendPayload(event)">
+                <div class="full-width">
+                    <label>Display Mode</label>
+                    <select id="mode">
+                        <option value="scroll">Scroll Text Banner</option>
+                        <option value="clock_digital">Digital Clock</option>
+                        <option value="clock_analog">Analog Clock</option>
+                    </select>
+                </div>
+
+                <div class="full-width effect-group clock-only" style="display: none;">
+                    <h3>Digital Clock Layout</h3>
+                    <div style="display: flex; gap: 1rem; align-items: center; justify-content: flex-start;">
+                        <label style="margin:0; display:flex; align-items:center; gap:0.4rem;">
+                            <input type="checkbox" id="clock_date" checked style="width:auto;"> Show Date
+                        </label>
+                        <label style="margin:0; display:flex; align-items:center; gap:0.4rem;">
+                            <input type="checkbox" id="clock_seconds" checked style="width:auto;"> Show Seconds
+                        </label>
+                        <label style="margin:0; display:flex; align-items:center; gap:0.4rem;">
+                            <input type="checkbox" id="clock_blink" checked style="width:auto;"> Blink Colon
+                        </label>
+                    </div>
+                </div>
+                
+                <div class="full-width slider-group">
+                    <div class="scroll-only">
+                        <label>Scroll Speed (Sec Delay): <span id="speed_val" class="val-display">0.05</span>s</label>
+                        <input type="range" id="speed" value="0.05" step="0.001" min="0.001" max="0.1">
+                    </div>
+                    
+                    <div class="scroll-only">
+                        <label>Blank Sweep Pause: <span id="pause_val" class="val-display">2.0</span>s</label>
+                        <input type="range" id="sweep_pause" value="2.0" step="0.5" min="0.0" max="10.0">
+                    </div>
+
+                    <div>
+                        <label>Brightness (0-255): <span id="bright_val" class="val-display">255</span></label>
+                        <input type="range" id="brightness" value="255" min="0" max="255" step="1">
+                    </div>
+                </div>
+
+                <div class="full-width scroll-only">
+                    <label>Text Payload</label>
+                    <input type="text" id="text" value="WOMAN CAVE">
+                </div>
+                
+                <div class="full-width needs-font">
+                    <label>System Font</label>
+                    <select id="font"></select>
+                    
+                    <div class="preview-wrapper" id="previewWrapper">
+                        <div id="fontPreview">WOMAN CAVE</div>
+                    </div>
+                </div>
+
+                <div class="effect-group">
+                    <h3>Foreground / Text Style</h3>
+                    <div>
+                        <label>Effect Engine</label>
+                        <select id="effect">
+                            <option value="solid">Solid</option>
+                            <option value="rainbow">Rainbow</option>
+                            <option value="gradient-h">Gradient (Horizontal)</option>
+                            <option value="gradient-v">Gradient (Vertical)</option>
+                            <option value="pulse">Pulse</option>
+                        </select>
+                    </div>
+                    <div>
+                        <label>Primary Color</label>
+                        <input type="color" id="color" value="#ff00ff">
+                    </div>
+                    <div id="fg_color2_container" style="display: none;">
+                        <label>Secondary Color (Gradient)</label>
+                        <input type="color" id="color2" value="#00ffff">
+                    </div>
+                </div>
+
+                <div class="effect-group">
+                    <h3>Background Canvas Style</h3>
+                    <div>
+                        <label>Background Engine</label>
+                        <select id="bg_effect">
+                            <option value="solid">Solid</option>
+                            <option value="rainbow">Rainbow</option>
+                            <option value="gradient-h">Gradient (Horizontal)</option>
+                            <option value="gradient-v">Gradient (Vertical)</option>
+                            <option value="pulse">Pulse</option>
+                        </select>
+                    </div>
+                    <div>
+                        <label>Primary Background Color</label>
+                        <input type="color" id="bg_color" value="#000000">
+                    </div>
+                    <div id="bg_color2_container" style="display: none;">
+                        <label>Secondary Background Color</label>
+                        <input type="color" id="bg_color2" value="#000080">
+                    </div>
+                </div>
+
+                <div class="full-width" style="margin-top: 1rem;">
+                    <button type="submit" class="btn btn-primary">Push To Matrix</button>
+                    <button type="button" onclick="clearMatrix()" class="btn btn-danger">Clear / Stop Matrix</button>
+                </div>
+            </form>
+            <div id="status"></div>
+        </div>
+
+        <div class="card">
+            <h2>Dynamic REST API Payload (cURL)</h2>
+            <pre id="curlOutput"></pre>
+        </div>
+    </div>
+
+    <script>
+        const allFonts = __ALL_FONTS_JSON__;
+        const monoFonts = __MONO_FONTS_JSON__;
+
+        document.getElementById('speed').addEventListener('input', function() {
+            document.getElementById('speed_val').innerText = this.value;
+        });
+        document.getElementById('sweep_pause').addEventListener('input', function() {
+            document.getElementById('pause_val').innerText = this.value;
+        });
+        document.getElementById('brightness').addEventListener('input', function() {
+            document.getElementById('bright_val').innerText = this.value;
+        });
+
+        async function updateFontPreview() {
+            const mode = document.getElementById("mode").value;
+            const fontKey = document.getElementById("font").value;
+            const wrapperDiv = document.getElementById("previewWrapper");
+            const previewDiv = document.getElementById("fontPreview");
+            
+            let textToPreview = document.getElementById("text").value || "WOMAN CAVE";
+            if (mode === "clock_digital") {
+                const showSec = document.getElementById("clock_seconds").checked;
+                textToPreview = showSec ? "12:34:56" : "12:34";
+            } else if (mode === "clock_analog") {
+                textToPreview = "🕐 [Analog Clock]";
+            }
+            
+            previewDiv.innerText = textToPreview;
+            const fontName = "custom_" + fontKey;
+            
+            let isLoaded = false;
+            document.fonts.forEach(f => {
+                if (f.family === fontName) isLoaded = true;
+            });
+
+            if (!isLoaded && fontKey) {
+                try {
+                    const fontUrl = "/font/" + fontKey;
+                    const font = new FontFace(fontName, `url('${fontUrl}')`);
+                    await font.load();
+                    document.fonts.add(font);
+                } catch (err) {}
+            }
+            previewDiv.style.fontFamily = `"${fontName}", monospace`;
+            
+            const bgEffect = document.getElementById("bg_effect").value;
+            const bgColor = document.getElementById("bg_color").value;
+            const bgColor2 = document.getElementById("bg_color2").value;
+
+            if (bgEffect === "solid" || bgEffect === "pulse") {
+                wrapperDiv.style.background = bgColor;
+            } else if (bgEffect === "gradient-h") {
+                wrapperDiv.style.background = `linear-gradient(90deg, ${bgColor}, ${bgColor2})`;
+            } else if (bgEffect === "gradient-v") {
+                wrapperDiv.style.background = `linear-gradient(180deg, ${bgColor}, ${bgColor2})`;
+            } else if (bgEffect === "rainbow") {
+                wrapperDiv.style.background = `linear-gradient(90deg, #FF0000, #FF7F00, #FFFF00, #00FF00, #0000FF, #4B0082, #9400D3)`;
+            }
+
+            const fgEffect = document.getElementById("effect").value;
+            const fgColor = document.getElementById("color").value;
+            const fgColor2 = document.getElementById("color2").value;
+
+            if (fgEffect === "solid" || fgEffect === "pulse") {
+                previewDiv.style.background = "none";
+                previewDiv.style.color = fgColor;
+                previewDiv.style.webkitBackgroundClip = "initial";
+                previewDiv.style.webkitTextFillColor = "initial";
+            } else {
+                if (fgEffect === "gradient-h") {
+                    previewDiv.style.background = `linear-gradient(90deg, ${fgColor}, ${fgColor2})`;
+                } else if (fgEffect === "gradient-v") {
+                    previewDiv.style.background = `linear-gradient(180deg, ${fgColor}, ${fgColor2})`;
+                } else if (fgEffect === "rainbow") {
+                    previewDiv.style.background = `linear-gradient(90deg, #FF0000, #FF7F00, #FFFF00, #00FF00, #0000FF, #4B0082, #9400D3)`;
+                }
+                previewDiv.style.webkitBackgroundClip = "text";
+                previewDiv.style.webkitTextFillColor = "transparent";
+                previewDiv.style.color = "transparent";
+            }
+            
+            if (mode === "scroll") {
+                previewDiv.style.paddingLeft = "100%";
+                previewDiv.style.display = "inline-block";
+                previewDiv.style.animation = "marquee 5s linear infinite";
+                previewDiv.style.textOverflow = "clip";
+            } else {
+                previewDiv.style.paddingLeft = "0";
+                previewDiv.style.display = "block";
+                previewDiv.style.animation = "none";
+                previewDiv.style.textOverflow = "ellipsis";
+            }
+        }
+
+        function handleModeToggle() {
+            const mode = document.getElementById("mode").value;
+            document.querySelectorAll(".scroll-only").forEach(el => {
+                el.style.display = mode === "scroll" ? "block" : "none";
+            });
+            document.querySelectorAll(".needs-font").forEach(el => {
+                el.style.display = (mode === "scroll" || mode === "clock_digital") ? "block" : "none";
+            });
+            document.querySelectorAll(".clock-only").forEach(el => {
+                el.style.display = (mode === "clock_digital") ? "grid" : "none";
+            });
+            
+            // Populate font dropdown strictly based on mode (monospace for digital clock)
+            const fontSelect = document.getElementById("font");
+            const currentFont = fontSelect.value;
+            fontSelect.innerHTML = "";
+            const fontMap = (mode === "clock_digital") ? monoFonts : allFonts;
+            for (const [key, path] of Object.entries(fontMap)) {
+                const opt = document.createElement("option");
+                opt.value = key;
+                opt.innerText = path.split("/").pop() + " (" + key + ")";
+                fontSelect.appendChild(opt);
+            }
+            if (fontMap[currentFont]) {
+                fontSelect.value = currentFont;
+            }
+
+            const effect = document.getElementById("effect").value;
+            const bgEffect = document.getElementById("bg_effect").value;
+            
+            document.getElementById("fg_color2_container").style.display = (effect.includes("gradient")) ? "block" : "none";
+            document.getElementById("bg_color2_container").style.display = (bgEffect.includes("gradient")) ? "block" : "none";
+
+            updateCurlPreview();
+            updateFontPreview();
+        }
+
+        function updateCurlPreview() {
+            const mode = document.getElementById("mode").value;
+            const effect = document.getElementById("effect").value;
+            const bgEffect = document.getElementById("bg_effect").value;
+            
+            let payload = {
+                mode: mode,
+                effect: effect,
+                color: document.getElementById("color").value,
+                bg_effect: bgEffect,
+                bg_color: document.getElementById("bg_color").value,
+                brightness: parseInt(document.getElementById("brightness").value)
+            };
+
+            if (effect.includes("gradient")) payload.color2 = document.getElementById("color2").value;
+            if (bgEffect.includes("gradient")) payload.bg_color2 = document.getElementById("bg_color2").value;
+
+            if (mode === "scroll" || mode === "clock_digital") {
+                payload.font = document.getElementById("font").value;
+            }
+
+            if (mode === "scroll") {
+                payload.text = document.getElementById("text").value;
+                payload.speed = parseFloat(document.getElementById("speed").value);
+                const pause = parseFloat(document.getElementById("sweep_pause").value);
+                if (pause > 0) payload.sweep_pause = pause;
+            }
+            
+            if (mode === "clock_digital") {
+                payload.clock_date = document.getElementById("clock_date").checked;
+                payload.clock_seconds = document.getElementById("clock_seconds").checked;
+                payload.clock_blink = document.getElementById("clock_blink").checked;
+            }
+
+            const jsonString = JSON.stringify(payload, null, 2).replace(/'/g, "'\\\\''");
+            const host = window.location.host || "localhost:8080";
+            
+            const curlCode = `curl -X POST "http://${host}/" \\\\
+     -H "Content-Type: application/json" \\\\
+     -d '${jsonString}'`;
+            
+            document.getElementById("curlOutput").innerText = curlCode;
+        }
+
+        document.getElementById("mode").addEventListener("change", handleModeToggle);
+        document.querySelectorAll("input, select").forEach(el => {
+            el.addEventListener("change", handleDisplayToggles);
+            el.addEventListener("input", () => {
+                updateCurlPreview();
+                updateFontPreview();
+            });
+        });
+
+        window.onload = () => {
+            handleModeToggle();
+        };
+
+        async function sendPayload(e) {
+            e.preventDefault();
+            const status = document.getElementById("status");
+            status.style.color = "#00ffff";
+            status.innerText = "Pushed request...";
+
+            const mode = document.getElementById("mode").value;
+            const effect = document.getElementById("effect").value;
+            const bgEffect = document.getElementById("bg_effect").value;
+            
+            let payload = {
+                mode: mode,
+                effect: effect,
+                color: document.getElementById("color").value,
+                bg_effect: bgEffect,
+                bg_color: document.getElementById("bg_color").value,
+                brightness: parseInt(document.getElementById("brightness").value)
+            };
+            
+            if (effect.includes("gradient")) payload.color2 = document.getElementById("color2").value;
+            if (bgEffect.includes("gradient")) payload.bg_color2 = document.getElementById("bg_color2").value;
+            
+            if (mode === "scroll" || mode === "clock_digital") {
+                payload.font = document.getElementById("font").value;
+            }
+
+            if (mode === "scroll") {
+                payload.text = document.getElementById("text").value;
+                payload.speed = parseFloat(document.getElementById("speed").value);
+                payload.sweep_pause = parseFloat(document.getElementById("sweep_pause").value) || 0.0;
+            }
+            
+            if (mode === "clock_digital") {
+                payload.clock_date = document.getElementById("clock_date").checked;
+                payload.clock_seconds = document.getElementById("clock_seconds").checked;
+                payload.clock_blink = document.getElementById("clock_blink").checked;
+            }
+
+            try {
+                const res = await fetch("/", {
+                    method: "POST",
+                    headers: { "Content-Type": "application/json" },
+                    body: JSON.stringify(payload)
+                });
+                const data = await res.json();
+                if (res.ok) {
+                    status.style.color = "#00ff66";
+                    status.innerText = "Success: " + data.message;
+                } else {
+                    status.style.color = "#ff4444";
+                    status.innerText = "Error: " + data.error_detail;
+                }
+            } catch (err) {
+                status.style.color = "#ff4444";
+                status.innerText = "Network Error: " + err;
+            }
+        }
+
+        async function clearMatrix() {
+            const status = document.getElementById("status");
+            status.style.color = "#ff4444";
+            status.innerText = "Clearing matrix...";
+            
+            const host = window.location.host || "localhost:8080";
+            const clearCurl = `curl -X POST "http://${host}/" \\\\
+     -H "Content-Type: application/json" \\\\
+     -d '{"action": "clear"}'`;
+            document.getElementById("curlOutput").innerText = clearCurl;
+
+            try {
+                const res = await fetch("/", {
+                    method: "POST",
+                    headers: { "Content-Type": "application/json" },
+                    body: JSON.stringify({ action: "clear" })
+                });
+                const data = await res.json();
+                status.innerText = data.message;
+            } catch (err) {
+                status.innerText = "Network Error: " + err;
+            }
+        }
+    </script>
+</body>
+</html>"""
+
+# --- HTTP Daemon Request Handler ---
+class DaemonHTTPHandler(BaseHTTPRequestHandler):
+    daemon_instance = None
+
+    def log_message(self, format, *args):
+        return
+
+    def do_GET(self):
+        if self.path.startswith("/font/"):
+            font_key = self.path.split("/")[-1]
+            if font_key in ALL_FONT_MAP:
+                try:
+                    with open(ALL_FONT_MAP[font_key], "rb") as f:
+                        font_data = f.read()
+                    self.send_response(200)
+                    self.send_header("Content-Type", "font/ttf")
+                    self.end_headers()
+                    self.wfile.write(font_data)
+                    return
+                except Exception:
+                    pass
+            self.send_error(404, "Font not found")
+            return
+
+        html_page = HTML_DOCS_TEMPLATE.replace("__ALL_FONTS_JSON__", json.dumps(ALL_FONT_MAP))
+        html_page = html_page.replace("__MONO_FONTS_JSON__", json.dumps(MONO_FONT_MAP))
+
+        self.send_response(200)
+        self.send_header("Content-Type", "text/html; charset=utf-8")
+        self.end_headers()
+        self.wfile.write(html_page.encode("utf-8"))
+
+    def do_POST(self):
+        content_length = int(self.headers.get("Content-Length", 0))
+        post_data = self.rfile.read(content_length)
+
+        try:
+            payload = json.loads(post_data.decode("utf-8"))
+        except Exception as e:
+            return self._send_error_response(f"Invalid JSON body: {str(e)}")
+
+        parsed_params = {}
+
+        if payload.get("action") in ["clear", "stop"]:
+            self.daemon_instance.stop_animation()
+            return self._send_success_response("Matrix cleared and animation stopped.")
+
+        mode = str(payload.get("mode", "scroll")).lower()
+        if mode not in ["scroll", "clock_digital", "clock_analog"]:
+            mode = "scroll"
+        parsed_params["mode"] = mode
+
+        if mode == "scroll":
+            if "text" not in payload or not str(payload.get("text", "")).strip():
+                return self._send_error_response("Missing or empty required field: 'text'")
+            parsed_params["text"] = str(payload["text"])
+
+            try:
+                parsed_params["sweep_pause"] = float(payload.get("sweep_pause", 2.0))
+            except ValueError:
+                return self._send_error_response("Field 'sweep_pause' must be a float in seconds.")
+        
+        if mode == "clock_digital":
+            parsed_params["clock_date"] = bool(payload.get("clock_date", True))
+            parsed_params["clock_seconds"] = bool(payload.get("clock_seconds", True))
+            parsed_params["clock_blink"] = bool(payload.get("clock_blink", True))
+
+        effect = payload.get("effect", "solid").lower()
+        if effect not in ["solid", "rainbow", "gradient-h", "gradient-v", "pulse"]:
+            return self._send_error_response(f"Invalid effect '{effect}'.")
+        parsed_params["effect"] = effect
+        
+        bg_effect = payload.get("bg_effect", "solid").lower()
+        if bg_effect not in ["solid", "rainbow", "gradient-h", "gradient-v", "pulse"]:
+            return self._send_error_response(f"Invalid bg_effect '{bg_effect}'.")
+        parsed_params["bg_effect"] = bg_effect
+
+        font_val = str(payload.get("font", "")).strip()
+        if mode == "clock_digital":
+            if font_val in MONO_FONT_MAP:
+                parsed_params["font"] = MONO_FONT_MAP[font_val]
+            else:
+                parsed_params["font"] = next(iter(MONO_FONT_MAP.values())) if MONO_FONT_MAP else next(iter(ALL_FONT_MAP.values()))
+        else:
+            if font_val in ALL_FONT_MAP:
+                parsed_params["font"] = ALL_FONT_MAP[font_val]
+            elif os.path.exists(font_val):
+                parsed_params["font"] = font_val
+            else:
+                parsed_params["font"] = self.daemon_instance.default_font
+
+        try:
+            parsed_params["color"] = parse_rgb(payload.get("color", "magenta"))
+            parsed_params["color2"] = parse_rgb(payload.get("color2", "cyan"))
+            parsed_params["bg_color"] = parse_rgb(payload.get("bg_color", "black"))
+            parsed_params["bg_color2"] = parse_rgb(payload.get("bg_color2", "navy"))
+        except ValueError as ve:
+            return self._send_error_response(str(ve))
+
+        try:
+            parsed_params["brightness"] = int(payload.get("brightness", 255))
+            if not (0 <= parsed_params["brightness"] <= 255):
+                raise ValueError()
+        except ValueError:
+            return self._send_error_response("Field 'brightness' must be an integer between 0 and 255.")
+
+        try:
+            parsed_params["speed"] = float(payload.get("speed", 0.05))
+            if parsed_params["speed"] < 0.001:
+                raise ValueError()
+        except ValueError:
+            return self._send_error_response("Field 'speed' must be a positive float (delay in seconds).")
+
+        try:
+            parsed_params["loops"] = int(payload.get("loops", 0))
+            if parsed_params["loops"] < 0:
+                raise ValueError()
+        except ValueError:
+            return self._send_error_response("Field 'loops' must be an integer >= 0.")
+
+        if "ip" in payload: parsed_params["ip"] = str(payload["ip"])
+        if "port" in payload: parsed_params["port"] = int(payload["port"])
+
+        self.daemon_instance.start_animation(parsed_params)
+        return self._send_success_response("Pushed payload to matrix.")
+
+    def _send_success_response(self, message):
+        self.send_response(200)
+        self.send_header("Content-Type", "application/json")
+        self.end_headers()
+        res = {"status": "success", "message": message}
+        self.wfile.write(json.dumps(res).encode("utf-8"))
+
+    def _send_error_response(self, error_msg):
+        self.send_response(400)
+        self.send_header("Content-Type", "application/json")
+        self.end_headers()
+        usage_instructions = {
+            "status": "error",
+            "error_detail": error_msg
+        }
+        self.wfile.write(json.dumps(usage_instructions, indent=2).encode("utf-8"))
+
+# --- CLI & Daemon Entrypoint ---
+def main():
+    parser = argparse.ArgumentParser(description="WLED DDP Matrix Billboard & Daemon")
+    
+    parser.add_argument("-d", "--daemon", action="store_true", help="Run as background HTTP daemon server")
+    parser.add_argument("--listen-ip", type=str, default="0.0.0.0", help="HTTP daemon bind IP")
+    parser.add_argument("--listen-port", type=int, default=8080, help="HTTP daemon listening port")
+    parser.add_argument("-m", "--mode", type=str, choices=["scroll", "clock_digital", "clock_analog"], default="scroll", help="Display mode")
+    parser.add_argument("-t", "--text", type=str, default="WOMAN CAVE", help="Text to scroll")
+    parser.add_argument("--ip", type=str, default="192.168.195.216", help="WLED IP address")
+    parser.add_argument("--port", type=int, default=4048, help="DDP UDP Port")
+    parser.add_argument("-b", "--brightness", type=int, default=255, help="Global brightness scale (0-255)")
+    
+    parser.add_argument("--no-date", action="store_true", help="Hide date in digital clock mode")
+    parser.add_argument("--no-seconds", action="store_true", help="Hide seconds in digital clock mode")
+    parser.add_argument("--no-blink", action="store_true", help="Disable blinking colon in digital clock mode")
+    
+    parser.add_argument("-e", "--effect", type=str, choices=["solid", "rainbow", "gradient-h", "gradient-v", "pulse"], default="solid", help="Text effect")
+    parser.add_argument("-c", "--color", type=str, default="magenta", help="Primary color")
+    parser.add_argument("--color2", type=str, default="cyan", help="Secondary color for gradient effect")
+    
+    parser.add_argument("--bg-effect", type=str, choices=["solid", "rainbow", "gradient-h", "gradient-v", "pulse"], default="solid", help="Background canvas effect")
+    parser.add_argument("--bg-color", type=str, default="black", help="Background primary color")
+    parser.add_argument("--bg-color2", type=str, default="navy", help="Background secondary color for gradient effect")
+    
+    parser.add_argument("-s", "--speed", type=float, default=0.05, help="Scroll frame delay in seconds")
+    parser.add_argument("--sweep-pause", type=float, default=2.0, help="Seconds to pause on blank screen after sweep")
+    parser.add_argument("--font", type=str, default="default", help="Path or preset key for TTF font")
+    parser.add_argument("--width", type=int, default=48, help="Matrix width in pixels")
+    parser.add_argument("--height", type=int, default=32, help="Matrix height in pixels")
+    parser.add_argument("-l", "--loops", type=int, default=0, help="Number of scroll loops (0 = infinite)")
+
+    args = parser.parse_args()
+
+    font_path = ALL_FONT_MAP.get(args.font, args.font)
+    if not os.path.exists(font_path):
+        font_path = next(iter(ALL_FONT_MAP.values())) if ALL_FONT_MAP else ""
+
+    daemon_mgr = BillboardDaemon(
+        default_ip=args.ip,
+        default_port=args.port,
+        default_width=args.width,
+        default_height=args.height,
+        default_font=font_path
+    )
+
+    if args.daemon:
+        DaemonHTTPHandler.daemon_instance = daemon_mgr
+        server = HTTPServer((args.listen_ip, args.listen_port), DaemonHTTPHandler)
+        print(f"Billboard Daemon active on http://{args.listen_ip}:{args.listen_port}")
+        print(f"Found {len(ALL_FONT_MAP)} total fonts ({len(MONO_FONT_MAP)} monospace).")
+        print(f"Targeting WLED at {args.ip}:{args.port} | Press Ctrl+C to stop.")
+        
+        try:
+            server.serve_forever()
+        except KeyboardInterrupt:
+            print("\nShutting down daemon...")
+            daemon_mgr.stop_animation()
+            server.server_close()
+            print("Daemon stopped cleanly.")
+    else:
+        try:
+            params = {
+                "mode": args.mode,
+                "text": args.text,
+                "ip": args.ip,
+                "port": args.port,
+                "width": args.width,
+                "height": args.height,
+                "font": font_path,
+                "effect": args.effect,
+                "color": parse_rgb(args.color),
+                "color2": parse_rgb(args.color2),
+                "bg_effect": args.bg_effect,
+                "bg_color": parse_rgb(args.bg_color),
+                "bg_color2": parse_rgb(args.bg_color2),
+                "brightness": args.brightness,
+                "speed": args.speed,
+                "sweep_pause": args.sweep_pause,
+                "loops": args.loops,
+                "clock_date": not args.no_date,
+                "clock_seconds": not args.no_seconds,
+                "clock_blink": not args.no_blink
+            }
+        except ValueError as ve:
+            print(f"Error: {ve}")
+            sys.exit(1)
+
+        print(f"Streaming CLI payload to {args.ip}:{args.port} | Press Ctrl+C to stop.")
+        daemon_mgr.start_animation(params)
+        
+        try:
+            while daemon_mgr.worker_thread and daemon_mgr.worker_thread.is_alive():
+                time.sleep(0.5)
+        except KeyboardInterrupt:
+            print("\nStopping CLI stream...")
+            daemon_mgr.stop_animation()
+
+if __name__ == "__main__":
+    main()