| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106 |
- #!/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()
- self.ambient_params = None
- def start_animation(self, params):
- with self.lock:
- # If duration is 0, this request becomes our permanent ambient state
- 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
- # State restoration check: if we stopped due to duration timeout, restore ambient mode seamlessly
- 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 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('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]";
- }
-
- 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";
- });
-
- 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 = 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 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")
- 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.bg_color2 = 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,
- "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()
|