| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240 |
- #!/usr/bin/env python3
- import argparse
- import colorsys
- import datetime
- import json
- import math
- import os
- import re
- import socket
- import sys
- import threading
- import time
- import urllib.parse
- 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"
- }
- ICON_DIR = "icons"
- def scan_icons():
- icons = []
- if os.path.exists(ICON_DIR):
- for file in os.listdir(ICON_DIR):
- if file.lower().endswith(".png"):
- icons.append(file[:-4])
- return sorted(icons)
- 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, "icon_layer", None):
- overlay.paste(base_banner.icon_layer, (0, 0), base_banner.icon_layer)
-
- 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()
- if "{icon:" not in text:
- 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:
- mask = Image.new("RGBA", (1, target_height), (0, 0, 0, 0))
- mask.icon_layer = None
- return mask
-
- cropped = temp_img.crop(bbox)
- scale_factor = target_height / float(cropped.size[1])
- new_w = max(1, int(cropped.size[0] * scale_factor))
- mask = cropped.resize((new_w, target_height), Image.Resampling.LANCZOS)
- mask.icon_layer = None
- return mask
- parts = re.split(r'(\{icon:[^}]+\})', text)
- elements = []
-
- for part in parts:
- if not part: continue
- if part.startswith("{icon:") and part.endswith("}"):
- icon_name = part[6:-1]
- icon_path = os.path.join(ICON_DIR, f"{icon_name}.png")
- if os.path.exists(icon_path):
- try:
- icon_img = Image.open(icon_path).convert("RGBA")
- scale = target_height / float(icon_img.size[1])
- new_w = max(1, int(icon_img.size[0] * scale))
- resized_icon = icon_img.resize((new_w, target_height), Image.Resampling.NEAREST)
- elements.append(("icon", resized_icon))
- continue
- except Exception:
- pass
-
- temp_img = Image.new("RGBA", (3000, 250), (0, 0, 0, 0))
- temp_draw = ImageDraw.Draw(temp_img)
- temp_draw.text((0, 10), part, font=font, fill=(255, 255, 255, 255))
- bbox = temp_img.getbbox()
- if bbox:
- cropped = temp_img.crop(bbox)
- scale = target_height / float(cropped.size[1])
- new_w = max(1, int(cropped.size[0] * scale))
- resized_text = cropped.resize((new_w, target_height), Image.Resampling.LANCZOS)
- elements.append(("text", resized_text))
-
- total_w = sum(img.size[0] for _, img in elements) + 20
- if total_w <= 20:
- total_w = 21
-
- base_mask = Image.new("RGBA", (total_w, target_height), (0, 0, 0, 0))
- icon_layer = Image.new("RGBA", (total_w, target_height), (0, 0, 0, 0))
-
- current_x = 10
- for etype, img in elements:
- if etype == "text":
- base_mask.paste(img, (current_x, 0), img)
- current_x += img.size[0] + 5
- elif etype == "icon":
- icon_layer.paste(img, (current_x, 0), img)
- current_x += img.size[0] + 5
- base_mask.icon_layer = icon_layer
- return base_mask
- 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))
- mask = cropped.resize((new_w, new_h), Image.Resampling.LANCZOS)
- mask.icon_layer = None
- return mask
- 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
- img.icon_layer = None
- 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)
-
- img.icon_layer = None
- 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()
- self.ambient_params = None
-
- if not os.path.exists(ICON_DIR):
- os.makedirs(ICON_DIR)
- def start_animation(self, params):
- with self.lock:
- if float(params.get("duration", 0.0)) == 0.0:
- self.ambient_params = dict(params)
- 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:
- self.ambient_params = None
- 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
- duration = float(params.get("duration", 0.0))
- end_time = (time.time() + duration) if duration > 0 else None
-
- 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 end_time and time.time() >= end_time:
- break
- 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
- else:
- 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():
- if end_time and time.time() >= end_time:
- break
- for x_pos in range(width, -banner_width - 1, -1):
- if self.stop_event.is_set() or (end_time and time.time() >= end_time):
- 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() and not (end_time and time.time() >= end_time):
- pause_end = time.time() + sweep_pause
- while time.time() < pause_end and not self.stop_event.is_set():
- if end_time and time.time() >= end_time:
- break
- 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
- if not self.stop_event.is_set():
- if duration > 0 and self.ambient_params:
- return self._render_loop(dict(self.ambient_params))
- else:
- 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>Alert Duration (0 = Continuous Ambient Mode): <span id="duration_val" class="val-display">0</span>s</label>
- <input type="range" id="duration" value="0" min="0" max="60" step="1">
- </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 scroll-only">
- <label>Available Status Icons (Click to insert inline)</label>
- <div id="icon-grid" style="display: flex; flex-wrap: wrap; gap: 8px; padding: 10px; background: #000; border: 1px solid var(--border); border-radius: 4px;"></div>
- </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__;
- const allIcons = __ICONS_JSON__;
- const iconGrid = document.getElementById("icon-grid");
- if (allIcons.length === 0) {
- iconGrid.innerHTML = "<span style='color:#666; font-size:0.9rem;'>No .png files found in icons/ directory.</span>";
- } else {
- allIcons.forEach(icon => {
- const img = document.createElement("img");
- img.src = `/icons/${icon}.png`;
- img.style.height = "32px";
- img.style.cursor = "pointer";
- img.style.border = "1px solid #30363d";
- img.style.padding = "2px";
- img.style.background = "#111";
- img.title = `Insert {icon:${icon}}`;
-
- img.onclick = () => {
- const textInput = document.getElementById("text");
- const token = `{icon:${icon}}`;
- const start = textInput.selectionStart;
- const end = textInput.selectionEnd;
- textInput.value = textInput.value.substring(0, start) + token + textInput.value.substring(end);
- textInput.selectionStart = textInput.selectionEnd = start + token.length;
- textInput.focus();
- updateCurlPreview();
- updateFontPreview();
- };
-
- img.onmouseover = () => img.style.borderColor = "#00ffff";
- img.onmouseout = () => img.style.borderColor = "#30363d";
-
- iconGrid.appendChild(img);
- });
- }
- 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('duration').addEventListener('input', function() {
- document.getElementById('duration_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]";
- }
-
- let htmlContent = textToPreview.replace(/\\{icon:([^}]+)\\}/g, '<img src="/icons/$1.png" style="height:1em; vertical-align:text-bottom; padding: 0 4px; mix-blend-mode: normal; -webkit-text-fill-color: initial;">');
- previewDiv.innerHTML = htmlContent;
-
- 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";
- });
-
- 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;
- const duration = parseFloat(document.getElementById("duration").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 (duration > 0) payload.duration = duration;
- 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", handleModeToggle);
- 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;
- const duration = parseFloat(document.getElementById("duration").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 (duration > 0) payload.duration = duration;
- 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 = urllib.parse.unquote(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
-
- if self.path.startswith("/icons/"):
- icon_file = urllib.parse.unquote(self.path.split("/")[-1])
- icon_path = os.path.join(ICON_DIR, icon_file)
- if os.path.exists(icon_path):
- try:
- with open(icon_path, "rb") as f:
- img_data = f.read()
- self.send_response(200)
- self.send_header("Content-Type", "image/png")
- self.end_headers()
- self.wfile.write(img_data)
- return
- except Exception:
- pass
- self.send_error(404, "Icon 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))
- html_page = html_page.replace("__ICONS_JSON__", json.dumps(scan_icons()))
- 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 ambient state wiped.")
- 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))
- try:
- parsed_params["duration"] = float(payload.get("duration", 0.0))
- if parsed_params["duration"] < 0:
- raise ValueError()
- except ValueError:
- return self._send_error_response("Field 'duration' must be a non-negative float (seconds).")
- 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)
-
- msg = f"Temporary alert active for {parsed_params['duration']}s." if parsed_params['duration'] > 0 else "Set as permanent ambient state."
- return self._send_success_response(msg)
- 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. Use {icon:filename} for inline PNGs.")
- 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("--duration", type=float, default=0.0, help="Alert duration in seconds (0 = permanent ambient state)")
-
- 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"Loaded {len(scan_icons())} PNG icons from '{ICON_DIR}/'.")
- 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,
- "duration": args.duration,
- "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()
|