matrix_billboard.py 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066
  1. #!/usr/bin/env python3
  2. import argparse
  3. import colorsys
  4. import datetime
  5. import json
  6. import math
  7. import os
  8. import socket
  9. import sys
  10. import threading
  11. import time
  12. from http.server import BaseHTTPRequestHandler, HTTPServer
  13. from PIL import Image, ImageDraw, ImageEnhance, ImageFont
  14. # --- DDP Network Engine ---
  15. CHUNK_SIZE = 1440
  16. COLOR_MAP = {
  17. "red": "#FF0000", "green": "#00FF00", "blue": "#0000FF",
  18. "cyan": "#00FFFF", "magenta": "#FF00FF", "yellow": "#FFFF00",
  19. "white": "#FFFFFF", "black": "#000000", "orange": "#FF7F00",
  20. "purple": "#800080", "pink": "#FFC0CB", "hotpink": "#FF1493",
  21. "lime": "#00FF00", "teal": "#008080", "navy": "#000080",
  22. "amber": "#FFBF00", "violet": "#8A2BE2", "turquoise": "#40E0D0"
  23. }
  24. def is_monospace_font(path):
  25. try:
  26. font = ImageFont.truetype(path, 20)
  27. w_i = font.getlength('i')
  28. w_w = font.getlength('W')
  29. w_m = font.getlength('m')
  30. return abs(w_i - w_w) < 0.1 and abs(w_i - w_m) < 0.1
  31. except Exception:
  32. name = os.path.basename(path).lower()
  33. return any(k in name for k in ["mono", "code", "term", "courier", "consolas", "fixed"])
  34. def scan_system_fonts():
  35. font_dirs = [
  36. "/usr/share/fonts/truetype",
  37. "/usr/local/share/fonts",
  38. os.path.expanduser("~/.local/share/fonts"),
  39. os.path.expanduser("~/.fonts")
  40. ]
  41. all_fonts = {}
  42. mono_fonts = {}
  43. for directory in font_dirs:
  44. if os.path.exists(directory):
  45. for root, _, files in os.walk(directory):
  46. for file in files:
  47. if file.lower().endswith(".ttf"):
  48. path = os.path.join(root, file)
  49. key = file.lower().replace(".ttf", "")
  50. all_fonts[key] = path
  51. if is_monospace_font(path):
  52. mono_fonts[key] = path
  53. if not all_fonts:
  54. default_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
  55. all_fonts["default"] = default_path
  56. mono_fonts["default"] = default_path
  57. return dict(sorted(all_fonts.items())), dict(sorted(mono_fonts.items()))
  58. ALL_FONT_MAP, MONO_FONT_MAP = scan_system_fonts()
  59. def parse_rgb(rgb_val):
  60. if isinstance(rgb_val, (list, tuple)):
  61. if len(rgb_val) == 3 and all(isinstance(x, int) and 0 <= x <= 255 for x in rgb_val):
  62. return tuple(rgb_val)
  63. raise ValueError("RGB tuple/list must contain 3 integers between 0 and 255.")
  64. val = str(rgb_val).strip().lower()
  65. if val in COLOR_MAP:
  66. val = COLOR_MAP[val]
  67. try:
  68. if val.startswith("#"):
  69. val = val.lstrip("#")
  70. if len(val) == 6:
  71. return tuple(int(val[i:i+2], 16) for i in (0, 2, 4))
  72. parts = [int(x.strip()) for x in str(rgb_val).split(",")]
  73. if len(parts) == 3:
  74. return tuple(parts)
  75. except Exception:
  76. pass
  77. raise ValueError(f"Invalid color '{rgb_val}'. Use a valid name, hex '#RRGGBB', or 'R,G,B'.")
  78. def send_ddp_frame(sock, raw_rgb, ip, port, seq):
  79. total_bytes = len(raw_rgb)
  80. offset = 0
  81. while offset < total_bytes:
  82. chunk = raw_rgb[offset : offset + CHUNK_SIZE]
  83. is_last = (offset + len(chunk)) >= total_bytes
  84. flags = 0x41 if is_last else 0x40
  85. header = bytearray([
  86. flags, seq & 0x0F, 0x01, 0x01,
  87. (offset >> 24) & 0xFF, (offset >> 16) & 0xFF,
  88. (offset >> 8) & 0xFF, offset & 0xFF,
  89. (len(chunk) >> 8) & 0xFF, len(chunk) & 0xFF
  90. ])
  91. sock.sendto(header + chunk, (ip, port))
  92. offset += len(chunk)
  93. def clear_matrix(sock, ip, port, width, height):
  94. send_ddp_frame(sock, bytes([0, 0, 0] * (width * height)), ip, port, 0)
  95. # --- Graphics & Render Engine ---
  96. def generate_effect_layer(w, h, effect, color1, color2, frame_idx):
  97. layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
  98. draw = ImageDraw.Draw(layer)
  99. if effect == "solid":
  100. draw.rectangle([(0, 0), (w, h)], fill=color1 + (255,))
  101. elif effect == "rainbow":
  102. for x in range(w):
  103. hue = ((x / float(max(1, w))) + (frame_idx * 0.02)) % 1.0
  104. r, g, b = [int(c * 255) for c in colorsys.hsv_to_rgb(hue, 1.0, 1.0)]
  105. draw.line([(x, 0), (x, h)], fill=(r, g, b, 255))
  106. elif effect == "gradient-h":
  107. for x in range(w):
  108. t = x / float(max(1, w - 1))
  109. r = int(color1[0] + (color2[0] - color1[0]) * t)
  110. g = int(color1[1] + (color2[1] - color1[1]) * t)
  111. b = int(color1[2] + (color2[2] - color1[2]) * t)
  112. draw.line([(x, 0), (x, h)], fill=(r, g, b, 255))
  113. elif effect == "gradient-v":
  114. for y in range(h):
  115. t = y / float(max(1, h - 1))
  116. r = int(color1[0] + (color2[0] - color1[0]) * t)
  117. g = int(color1[1] + (color2[1] - color1[1]) * t)
  118. b = int(color1[2] + (color2[2] - color1[2]) * t)
  119. draw.line([(0, y), (w, y)], fill=(r, g, b, 255))
  120. elif effect == "pulse":
  121. factor = (math.sin(frame_idx * 0.15) + 1.0) / 2.0
  122. r = int(color1[0] * (0.15 + 0.85 * factor))
  123. g = int(color1[1] * (0.15 + 0.85 * factor))
  124. b = int(color1[2] * (0.15 + 0.85 * factor))
  125. draw.rectangle([(0, 0), (w, h)], fill=(r, g, b, 255))
  126. return layer
  127. def apply_text_effect(base_banner, effect, color1, color2, frame_idx):
  128. w, h = base_banner.size
  129. alpha_mask = base_banner.split()[3]
  130. overlay = generate_effect_layer(w, h, effect, color1, color2, frame_idx)
  131. overlay.putalpha(alpha_mask)
  132. if getattr(base_banner, "is_analog_clock", False):
  133. final = Image.alpha_composite(overlay, base_banner)
  134. final.putalpha(alpha_mask)
  135. return final
  136. return overlay
  137. def render_base_text(text, font_path, target_height):
  138. try:
  139. font = ImageFont.truetype(font_path, 80)
  140. except IOError:
  141. font = ImageFont.load_default()
  142. temp_img = Image.new("RGBA", (3000, 250), (0, 0, 0, 0))
  143. temp_draw = ImageDraw.Draw(temp_img)
  144. temp_draw.text((10, 10), text, font=font, fill=(255, 255, 255, 255))
  145. bbox = temp_img.getbbox()
  146. if not bbox:
  147. return Image.new("RGBA", (1, target_height), (0, 0, 0, 0))
  148. cropped = temp_img.crop(bbox)
  149. scale_factor = target_height / float(cropped.size[1])
  150. new_w = max(1, int(cropped.size[0] * scale_factor))
  151. return cropped.resize((new_w, target_height), Image.Resampling.LANCZOS)
  152. def render_fitted_text(text, font_path, max_w, max_h):
  153. try:
  154. font = ImageFont.truetype(font_path, 80)
  155. except IOError:
  156. font = ImageFont.load_default()
  157. temp_img = Image.new("RGBA", (2000, 300), (0, 0, 0, 0))
  158. temp_draw = ImageDraw.Draw(temp_img)
  159. temp_draw.text((10, 10), text, font=font, fill=(255, 255, 255, 255))
  160. bbox = temp_img.getbbox()
  161. if not bbox:
  162. return Image.new("RGBA", (1, 1), (0, 0, 0, 0))
  163. cropped = temp_img.crop(bbox)
  164. scale = min(max_w / float(cropped.size[0]), max_h / float(cropped.size[1]))
  165. new_w = max(1, int(cropped.size[0] * scale))
  166. new_h = max(1, int(cropped.size[1] * scale))
  167. return cropped.resize((new_w, new_h), Image.Resampling.LANCZOS)
  168. def render_analog_clock(width, height):
  169. now = datetime.datetime.now()
  170. img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
  171. draw = ImageDraw.Draw(img)
  172. center_x, center_y = width // 2, height // 2
  173. radius = min(center_x, center_y) - 1
  174. draw.ellipse([center_x - radius, center_y - radius, center_x + radius, center_y + radius], outline=(255, 255, 255, 255), width=1)
  175. sec_angle = math.radians(now.second * 6 - 90)
  176. min_angle = math.radians(now.minute * 6 - 90)
  177. hr_angle = math.radians((now.hour % 12) * 30 + (now.minute / 2) - 90)
  178. 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)
  179. 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)
  180. 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)
  181. img.is_analog_clock = True
  182. return img
  183. def render_digital_clock(width, height, font_path, show_date=True, show_seconds=True, blink=True):
  184. now = datetime.datetime.now()
  185. img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
  186. fmt = "%H:%M:%S" if show_seconds else "%H:%M"
  187. time_str = now.strftime(fmt)
  188. if blink and now.second % 2 == 0:
  189. time_str = time_str.replace(":", " ")
  190. max_w = width - 2
  191. max_h = (height // 2) - 2 if show_date else height - 2
  192. time_img = render_fitted_text(time_str, font_path, max_w, max_h)
  193. t_x = (width - time_img.size[0]) // 2
  194. t_y = ((height // 2) - time_img.size[1]) // 2 if show_date else (height - time_img.size[1]) // 2
  195. img.paste(time_img, (t_x, t_y), time_img)
  196. if show_date:
  197. date_str = now.strftime("%a %d %b")
  198. date_img = render_fitted_text(date_str, font_path, max_w, max_h)
  199. d_x = (width - date_img.size[0]) // 2
  200. d_y = (height // 2) + (((height // 2) - date_img.size[1]) // 2)
  201. img.paste(date_img, (d_x, d_y), date_img)
  202. return img
  203. # --- Thread-Safe Billboard Animation Manager ---
  204. class BillboardDaemon:
  205. def __init__(self, default_ip, default_port, default_width, default_height, default_font):
  206. self.default_ip = default_ip
  207. self.default_port = default_port
  208. self.default_width = default_width
  209. self.default_height = default_height
  210. self.default_font = default_font
  211. self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  212. self.stop_event = threading.Event()
  213. self.worker_thread = None
  214. self.lock = threading.Lock()
  215. def start_animation(self, params):
  216. with self.lock:
  217. if self.worker_thread and self.worker_thread.is_alive():
  218. self.stop_event.set()
  219. self.worker_thread.join()
  220. self.stop_event.clear()
  221. self.worker_thread = threading.Thread(
  222. target=self._render_loop,
  223. args=(params,),
  224. daemon=True
  225. )
  226. self.worker_thread.start()
  227. def stop_animation(self):
  228. with self.lock:
  229. if self.worker_thread and self.worker_thread.is_alive():
  230. self.stop_event.set()
  231. self.worker_thread.join()
  232. clear_matrix(self.sock, self.default_ip, self.default_port, self.default_width, self.default_height)
  233. def _render_loop(self, params):
  234. mode = params.get("mode", "scroll")
  235. ip = params.get("ip", self.default_ip)
  236. port = params.get("port", self.default_port)
  237. width = params.get("width", self.default_width)
  238. height = params.get("height", self.default_height)
  239. font_path = params.get("font", self.default_font)
  240. effect = params.get("effect", "rainbow")
  241. color1 = params.get("color", (255, 0, 255))
  242. color2 = params.get("color2", (0, 255, 255))
  243. bg_effect = params.get("bg_effect", "solid")
  244. bg_color = params.get("bg_color", (0, 0, 0))
  245. bg_color2 = params.get("bg_color2", (0, 0, 128))
  246. brightness = max(0, min(255, params.get("brightness", 255))) / 255.0
  247. seq = 0
  248. frame_idx = 0
  249. if mode in ["clock_digital", "clock_analog"]:
  250. show_date = params.get("clock_date", True)
  251. show_seconds = params.get("clock_seconds", True)
  252. blink = params.get("clock_blink", True)
  253. while not self.stop_event.is_set():
  254. if mode == "clock_analog":
  255. base_mask = render_analog_clock(width, height)
  256. else:
  257. base_mask = render_digital_clock(width, height, font_path, show_date, show_seconds, blink)
  258. styled_mask = apply_text_effect(base_mask, effect, color1, color2, frame_idx)
  259. bg_layer = generate_effect_layer(width, height, bg_effect, bg_color, bg_color2, frame_idx).convert("RGB")
  260. bg_layer.paste(styled_mask, (0, 0), styled_mask)
  261. if brightness < 1.0:
  262. bg_layer = ImageEnhance.Brightness(bg_layer).enhance(brightness)
  263. send_ddp_frame(self.sock, bg_layer.tobytes(), ip, port, seq)
  264. seq = (seq + 1) % 16
  265. frame_idx += 1
  266. if self.stop_event.wait(0.1):
  267. break
  268. clear_matrix(self.sock, ip, port, width, height)
  269. return
  270. text = params.get("text", "WOMAN CAVE")
  271. speed = params.get("speed", 0.05)
  272. loops = params.get("loops", 0)
  273. sweep_pause = params.get("sweep_pause", 2.0)
  274. base_banner = render_base_text(text, font_path, height)
  275. banner_width = base_banner.size[0]
  276. loop_count = 0
  277. while not self.stop_event.is_set():
  278. for x_pos in range(width, -banner_width - 1, -1):
  279. if self.stop_event.is_set():
  280. break
  281. styled_banner = apply_text_effect(base_banner, effect, color1, color2, frame_idx)
  282. bg_layer = generate_effect_layer(width, height, bg_effect, bg_color, bg_color2, frame_idx).convert("RGB")
  283. bg_layer.paste(styled_banner, (x_pos, 0), styled_banner)
  284. if brightness < 1.0:
  285. bg_layer = ImageEnhance.Brightness(bg_layer).enhance(brightness)
  286. send_ddp_frame(self.sock, bg_layer.tobytes(), ip, port, seq)
  287. seq = (seq + 1) % 16
  288. frame_idx += 1
  289. if self.stop_event.wait(speed):
  290. break
  291. if sweep_pause > 0 and not self.stop_event.is_set():
  292. pause_end = time.time() + sweep_pause
  293. while time.time() < pause_end and not self.stop_event.is_set():
  294. bg_layer = generate_effect_layer(width, height, bg_effect, bg_color, bg_color2, frame_idx).convert("RGB")
  295. if brightness < 1.0:
  296. bg_layer = ImageEnhance.Brightness(bg_layer).enhance(brightness)
  297. send_ddp_frame(self.sock, bg_layer.tobytes(), ip, port, seq)
  298. seq = (seq + 1) % 16
  299. frame_idx += 1
  300. if self.stop_event.wait(0.1):
  301. break
  302. loop_count += 1
  303. if loops > 0 and loop_count >= loops:
  304. break
  305. clear_matrix(self.sock, ip, port, width, height)
  306. # --- HTML Documentation Template ---
  307. HTML_DOCS_TEMPLATE = """<!DOCTYPE html>
  308. <html lang="en">
  309. <head>
  310. <meta charset="UTF-8">
  311. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  312. <title>WLED Billboard Daemon API</title>
  313. <style>
  314. :root { --bg: #0e1117; --card: #161b22; --border: #30363d; --text: #c9d1d9; --accent: #00ffff; --accent2: #ff00ff; --danger: #ff4444; }
  315. body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 2rem; line-height: 1.5; }
  316. .container { max-width: 950px; margin: 0 auto; }
  317. h1 { color: #fff; font-size: 2.2rem; border-bottom: 2px solid var(--border); padding-bottom: 0.5rem; margin-top: 0; }
  318. h2 { color: var(--accent); margin-top: 1.5rem; }
  319. .card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem; }
  320. code, pre { background: #000; font-family: monospace; border-radius: 4px; }
  321. code { color: var(--accent); padding: 0.2rem 0.4rem; }
  322. pre { padding: 1rem; overflow-x: auto; border: 1px solid var(--border); color: #00ff66; white-space: pre-wrap; word-break: break-all; }
  323. table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
  324. th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--border); }
  325. th { color: var(--accent2); }
  326. .btn { padding: 0.6rem 1.2rem; border: none; border-radius: 4px; font-weight: bold; cursor: pointer; transition: 0.2s; }
  327. .btn-primary { background: var(--accent); color: #000; }
  328. .btn-danger { background: var(--danger); color: #fff; margin-left: 0.5rem; }
  329. .btn:hover { opacity: 0.85; }
  330. form { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
  331. .full-width { grid-column: span 2; }
  332. label { display: block; font-weight: bold; margin-bottom: 0.3rem; font-size: 0.9rem; }
  333. 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; }
  334. input[type="range"] { width: 100%; margin-top: 0.5rem; cursor: pointer; }
  335. 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; }
  336. .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; }
  337. .effect-group { padding: 1rem; border: 1px solid var(--border); border-radius: 6px; display: grid; grid-template-columns: 1fr; gap: 0.5rem; }
  338. .effect-group h3 { margin: 0 0 0.5rem 0; font-size: 1rem; color: #fff; }
  339. #status { margin-top: 1rem; font-weight: bold; }
  340. .preview-wrapper { margin-top: 0.5rem; border: 1px dashed var(--border); border-radius: 4px; overflow: hidden; transition: 0.3s; background: #000; white-space: nowrap; }
  341. #fontPreview {
  342. font-size: 2rem; line-height: 1.2; padding: 1rem; text-align: center; transition: 0.3s;
  343. }
  344. @keyframes marquee {
  345. 0% { transform: translate(0, 0); }
  346. 100% { transform: translate(-100%, 0); }
  347. }
  348. .val-display { color: var(--accent); font-family: monospace; font-weight: normal; margin-left: 0.5rem; }
  349. </style>
  350. </head>
  351. <body>
  352. <div class="container">
  353. <h1>WLED Billboard Daemon Controller</h1>
  354. <div class="card">
  355. <h2>Interactive Web Control Panel</h2>
  356. <form id="apiForm" onsubmit="sendPayload(event)">
  357. <div class="full-width">
  358. <label>Display Mode</label>
  359. <select id="mode">
  360. <option value="scroll">Scroll Text Banner</option>
  361. <option value="clock_digital">Digital Clock</option>
  362. <option value="clock_analog">Analog Clock</option>
  363. </select>
  364. </div>
  365. <div class="full-width effect-group clock-only" style="display: none;">
  366. <h3>Digital Clock Layout</h3>
  367. <div style="display: flex; gap: 1rem; align-items: center; justify-content: flex-start;">
  368. <label style="margin:0; display:flex; align-items:center; gap:0.4rem;">
  369. <input type="checkbox" id="clock_date" checked style="width:auto;"> Show Date
  370. </label>
  371. <label style="margin:0; display:flex; align-items:center; gap:0.4rem;">
  372. <input type="checkbox" id="clock_seconds" checked style="width:auto;"> Show Seconds
  373. </label>
  374. <label style="margin:0; display:flex; align-items:center; gap:0.4rem;">
  375. <input type="checkbox" id="clock_blink" checked style="width:auto;"> Blink Colon
  376. </label>
  377. </div>
  378. </div>
  379. <div class="full-width slider-group">
  380. <div class="scroll-only">
  381. <label>Scroll Speed (Sec Delay): <span id="speed_val" class="val-display">0.05</span>s</label>
  382. <input type="range" id="speed" value="0.05" step="0.001" min="0.001" max="0.1">
  383. </div>
  384. <div class="scroll-only">
  385. <label>Blank Sweep Pause: <span id="pause_val" class="val-display">2.0</span>s</label>
  386. <input type="range" id="sweep_pause" value="2.0" step="0.5" min="0.0" max="10.0">
  387. </div>
  388. <div>
  389. <label>Brightness (0-255): <span id="bright_val" class="val-display">255</span></label>
  390. <input type="range" id="brightness" value="255" min="0" max="255" step="1">
  391. </div>
  392. </div>
  393. <div class="full-width scroll-only">
  394. <label>Text Payload</label>
  395. <input type="text" id="text" value="WOMAN CAVE">
  396. </div>
  397. <div class="full-width needs-font">
  398. <label>System Font</label>
  399. <select id="font"></select>
  400. <div class="preview-wrapper" id="previewWrapper">
  401. <div id="fontPreview">WOMAN CAVE</div>
  402. </div>
  403. </div>
  404. <div class="effect-group">
  405. <h3>Foreground / Text Style</h3>
  406. <div>
  407. <label>Effect Engine</label>
  408. <select id="effect">
  409. <option value="solid">Solid</option>
  410. <option value="rainbow">Rainbow</option>
  411. <option value="gradient-h">Gradient (Horizontal)</option>
  412. <option value="gradient-v">Gradient (Vertical)</option>
  413. <option value="pulse">Pulse</option>
  414. </select>
  415. </div>
  416. <div>
  417. <label>Primary Color</label>
  418. <input type="color" id="color" value="#ff00ff">
  419. </div>
  420. <div id="fg_color2_container" style="display: none;">
  421. <label>Secondary Color (Gradient)</label>
  422. <input type="color" id="color2" value="#00ffff">
  423. </div>
  424. </div>
  425. <div class="effect-group">
  426. <h3>Background Canvas Style</h3>
  427. <div>
  428. <label>Background Engine</label>
  429. <select id="bg_effect">
  430. <option value="solid">Solid</option>
  431. <option value="rainbow">Rainbow</option>
  432. <option value="gradient-h">Gradient (Horizontal)</option>
  433. <option value="gradient-v">Gradient (Vertical)</option>
  434. <option value="pulse">Pulse</option>
  435. </select>
  436. </div>
  437. <div>
  438. <label>Primary Background Color</label>
  439. <input type="color" id="bg_color" value="#000000">
  440. </div>
  441. <div id="bg_color2_container" style="display: none;">
  442. <label>Secondary Background Color</label>
  443. <input type="color" id="bg_color2" value="#000080">
  444. </div>
  445. </div>
  446. <div class="full-width" style="margin-top: 1rem;">
  447. <button type="submit" class="btn btn-primary">Push To Matrix</button>
  448. <button type="button" onclick="clearMatrix()" class="btn btn-danger">Clear / Stop Matrix</button>
  449. </div>
  450. </form>
  451. <div id="status"></div>
  452. </div>
  453. <div class="card">
  454. <h2>Dynamic REST API Payload (cURL)</h2>
  455. <pre id="curlOutput"></pre>
  456. </div>
  457. </div>
  458. <script>
  459. const allFonts = __ALL_FONTS_JSON__;
  460. const monoFonts = __MONO_FONTS_JSON__;
  461. document.getElementById('speed').addEventListener('input', function() {
  462. document.getElementById('speed_val').innerText = this.value;
  463. });
  464. document.getElementById('sweep_pause').addEventListener('input', function() {
  465. document.getElementById('pause_val').innerText = this.value;
  466. });
  467. document.getElementById('brightness').addEventListener('input', function() {
  468. document.getElementById('bright_val').innerText = this.value;
  469. });
  470. async function updateFontPreview() {
  471. const mode = document.getElementById("mode").value;
  472. const fontKey = document.getElementById("font").value;
  473. const wrapperDiv = document.getElementById("previewWrapper");
  474. const previewDiv = document.getElementById("fontPreview");
  475. let textToPreview = document.getElementById("text").value || "WOMAN CAVE";
  476. if (mode === "clock_digital") {
  477. const showSec = document.getElementById("clock_seconds").checked;
  478. textToPreview = showSec ? "12:34:56" : "12:34";
  479. } else if (mode === "clock_analog") {
  480. textToPreview = "🕐 [Analog Clock]";
  481. }
  482. previewDiv.innerText = textToPreview;
  483. const fontName = "custom_" + fontKey;
  484. let isLoaded = false;
  485. document.fonts.forEach(f => {
  486. if (f.family === fontName) isLoaded = true;
  487. });
  488. if (!isLoaded && fontKey) {
  489. try {
  490. const fontUrl = "/font/" + fontKey;
  491. const font = new FontFace(fontName, `url('${fontUrl}')`);
  492. await font.load();
  493. document.fonts.add(font);
  494. } catch (err) {}
  495. }
  496. previewDiv.style.fontFamily = `"${fontName}", monospace`;
  497. const bgEffect = document.getElementById("bg_effect").value;
  498. const bgColor = document.getElementById("bg_color").value;
  499. const bgColor2 = document.getElementById("bg_color2").value;
  500. if (bgEffect === "solid" || bgEffect === "pulse") {
  501. wrapperDiv.style.background = bgColor;
  502. } else if (bgEffect === "gradient-h") {
  503. wrapperDiv.style.background = `linear-gradient(90deg, ${bgColor}, ${bgColor2})`;
  504. } else if (bgEffect === "gradient-v") {
  505. wrapperDiv.style.background = `linear-gradient(180deg, ${bgColor}, ${bgColor2})`;
  506. } else if (bgEffect === "rainbow") {
  507. wrapperDiv.style.background = `linear-gradient(90deg, #FF0000, #FF7F00, #FFFF00, #00FF00, #0000FF, #4B0082, #9400D3)`;
  508. }
  509. const fgEffect = document.getElementById("effect").value;
  510. const fgColor = document.getElementById("color").value;
  511. const fgColor2 = document.getElementById("color2").value;
  512. if (fgEffect === "solid" || fgEffect === "pulse") {
  513. previewDiv.style.background = "none";
  514. previewDiv.style.color = fgColor;
  515. previewDiv.style.webkitBackgroundClip = "initial";
  516. previewDiv.style.webkitTextFillColor = "initial";
  517. } else {
  518. if (fgEffect === "gradient-h") {
  519. previewDiv.style.background = `linear-gradient(90deg, ${fgColor}, ${fgColor2})`;
  520. } else if (fgEffect === "gradient-v") {
  521. previewDiv.style.background = `linear-gradient(180deg, ${fgColor}, ${fgColor2})`;
  522. } else if (fgEffect === "rainbow") {
  523. previewDiv.style.background = `linear-gradient(90deg, #FF0000, #FF7F00, #FFFF00, #00FF00, #0000FF, #4B0082, #9400D3)`;
  524. }
  525. previewDiv.style.webkitBackgroundClip = "text";
  526. previewDiv.style.webkitTextFillColor = "transparent";
  527. previewDiv.style.color = "transparent";
  528. }
  529. if (mode === "scroll") {
  530. previewDiv.style.paddingLeft = "100%";
  531. previewDiv.style.display = "inline-block";
  532. previewDiv.style.animation = "marquee 5s linear infinite";
  533. previewDiv.style.textOverflow = "clip";
  534. } else {
  535. previewDiv.style.paddingLeft = "0";
  536. previewDiv.style.display = "block";
  537. previewDiv.style.animation = "none";
  538. previewDiv.style.textOverflow = "ellipsis";
  539. }
  540. }
  541. function handleModeToggle() {
  542. const mode = document.getElementById("mode").value;
  543. document.querySelectorAll(".scroll-only").forEach(el => {
  544. el.style.display = mode === "scroll" ? "block" : "none";
  545. });
  546. document.querySelectorAll(".needs-font").forEach(el => {
  547. el.style.display = (mode === "scroll" || mode === "clock_digital") ? "block" : "none";
  548. });
  549. document.querySelectorAll(".clock-only").forEach(el => {
  550. el.style.display = (mode === "clock_digital") ? "grid" : "none";
  551. });
  552. // Populate font dropdown strictly based on mode (monospace for digital clock)
  553. const fontSelect = document.getElementById("font");
  554. const currentFont = fontSelect.value;
  555. fontSelect.innerHTML = "";
  556. const fontMap = (mode === "clock_digital") ? monoFonts : allFonts;
  557. for (const [key, path] of Object.entries(fontMap)) {
  558. const opt = document.createElement("option");
  559. opt.value = key;
  560. opt.innerText = path.split("/").pop() + " (" + key + ")";
  561. fontSelect.appendChild(opt);
  562. }
  563. if (fontMap[currentFont]) {
  564. fontSelect.value = currentFont;
  565. }
  566. const effect = document.getElementById("effect").value;
  567. const bgEffect = document.getElementById("bg_effect").value;
  568. document.getElementById("fg_color2_container").style.display = (effect.includes("gradient")) ? "block" : "none";
  569. document.getElementById("bg_color2_container").style.display = (bgEffect.includes("gradient")) ? "block" : "none";
  570. updateCurlPreview();
  571. updateFontPreview();
  572. }
  573. function updateCurlPreview() {
  574. const mode = document.getElementById("mode").value;
  575. const effect = document.getElementById("effect").value;
  576. const bgEffect = document.getElementById("bg_effect").value;
  577. let payload = {
  578. mode: mode,
  579. effect: effect,
  580. color: document.getElementById("color").value,
  581. bg_effect: bgEffect,
  582. bg_color: document.getElementById("bg_color").value,
  583. brightness: parseInt(document.getElementById("brightness").value)
  584. };
  585. if (effect.includes("gradient")) payload.color2 = document.getElementById("color2").value;
  586. if (bgEffect.includes("gradient")) payload.bg_color2 = document.getElementById("bg_color2").value;
  587. if (mode === "scroll" || mode === "clock_digital") {
  588. payload.font = document.getElementById("font").value;
  589. }
  590. if (mode === "scroll") {
  591. payload.text = document.getElementById("text").value;
  592. payload.speed = parseFloat(document.getElementById("speed").value);
  593. const pause = parseFloat(document.getElementById("sweep_pause").value);
  594. if (pause > 0) payload.sweep_pause = pause;
  595. }
  596. if (mode === "clock_digital") {
  597. payload.clock_date = document.getElementById("clock_date").checked;
  598. payload.clock_seconds = document.getElementById("clock_seconds").checked;
  599. payload.clock_blink = document.getElementById("clock_blink").checked;
  600. }
  601. const jsonString = JSON.stringify(payload, null, 2).replace(/'/g, "'\\\\''");
  602. const host = window.location.host || "localhost:8080";
  603. const curlCode = `curl -X POST "http://${host}/" \\\\
  604. -H "Content-Type: application/json" \\\\
  605. -d '${jsonString}'`;
  606. document.getElementById("curlOutput").innerText = curlCode;
  607. }
  608. document.getElementById("mode").addEventListener("change", handleModeToggle);
  609. document.querySelectorAll("input, select").forEach(el => {
  610. el.addEventListener("change", handleDisplayToggles);
  611. el.addEventListener("input", () => {
  612. updateCurlPreview();
  613. updateFontPreview();
  614. });
  615. });
  616. window.onload = () => {
  617. handleModeToggle();
  618. };
  619. async function sendPayload(e) {
  620. e.preventDefault();
  621. const status = document.getElementById("status");
  622. status.style.color = "#00ffff";
  623. status.innerText = "Pushed request...";
  624. const mode = document.getElementById("mode").value;
  625. const effect = document.getElementById("effect").value;
  626. const bgEffect = document.getElementById("bg_effect").value;
  627. let payload = {
  628. mode: mode,
  629. effect: effect,
  630. color: document.getElementById("color").value,
  631. bg_effect: bgEffect,
  632. bg_color: document.getElementById("bg_color").value,
  633. brightness: parseInt(document.getElementById("brightness").value)
  634. };
  635. if (effect.includes("gradient")) payload.color2 = document.getElementById("color2").value;
  636. if (bgEffect.includes("gradient")) payload.bg_color2 = document.getElementById("bg_color2").value;
  637. if (mode === "scroll" || mode === "clock_digital") {
  638. payload.font = document.getElementById("font").value;
  639. }
  640. if (mode === "scroll") {
  641. payload.text = document.getElementById("text").value;
  642. payload.speed = parseFloat(document.getElementById("speed").value);
  643. payload.sweep_pause = parseFloat(document.getElementById("sweep_pause").value) || 0.0;
  644. }
  645. if (mode === "clock_digital") {
  646. payload.clock_date = document.getElementById("clock_date").checked;
  647. payload.clock_seconds = document.getElementById("clock_seconds").checked;
  648. payload.clock_blink = document.getElementById("clock_blink").checked;
  649. }
  650. try {
  651. const res = await fetch("/", {
  652. method: "POST",
  653. headers: { "Content-Type": "application/json" },
  654. body: JSON.stringify(payload)
  655. });
  656. const data = await res.json();
  657. if (res.ok) {
  658. status.style.color = "#00ff66";
  659. status.innerText = "Success: " + data.message;
  660. } else {
  661. status.style.color = "#ff4444";
  662. status.innerText = "Error: " + data.error_detail;
  663. }
  664. } catch (err) {
  665. status.style.color = "#ff4444";
  666. status.innerText = "Network Error: " + err;
  667. }
  668. }
  669. async function clearMatrix() {
  670. const status = document.getElementById("status");
  671. status.style.color = "#ff4444";
  672. status.innerText = "Clearing matrix...";
  673. const host = window.location.host || "localhost:8080";
  674. const clearCurl = `curl -X POST "http://${host}/" \\\\
  675. -H "Content-Type: application/json" \\\\
  676. -d '{"action": "clear"}'`;
  677. document.getElementById("curlOutput").innerText = clearCurl;
  678. try {
  679. const res = await fetch("/", {
  680. method: "POST",
  681. headers: { "Content-Type": "application/json" },
  682. body: JSON.stringify({ action: "clear" })
  683. });
  684. const data = await res.json();
  685. status.innerText = data.message;
  686. } catch (err) {
  687. status.innerText = "Network Error: " + err;
  688. }
  689. }
  690. </script>
  691. </body>
  692. </html>"""
  693. # --- HTTP Daemon Request Handler ---
  694. class DaemonHTTPHandler(BaseHTTPRequestHandler):
  695. daemon_instance = None
  696. def log_message(self, format, *args):
  697. return
  698. def do_GET(self):
  699. if self.path.startswith("/font/"):
  700. font_key = self.path.split("/")[-1]
  701. if font_key in ALL_FONT_MAP:
  702. try:
  703. with open(ALL_FONT_MAP[font_key], "rb") as f:
  704. font_data = f.read()
  705. self.send_response(200)
  706. self.send_header("Content-Type", "font/ttf")
  707. self.end_headers()
  708. self.wfile.write(font_data)
  709. return
  710. except Exception:
  711. pass
  712. self.send_error(404, "Font not found")
  713. return
  714. html_page = HTML_DOCS_TEMPLATE.replace("__ALL_FONTS_JSON__", json.dumps(ALL_FONT_MAP))
  715. html_page = html_page.replace("__MONO_FONTS_JSON__", json.dumps(MONO_FONT_MAP))
  716. self.send_response(200)
  717. self.send_header("Content-Type", "text/html; charset=utf-8")
  718. self.end_headers()
  719. self.wfile.write(html_page.encode("utf-8"))
  720. def do_POST(self):
  721. content_length = int(self.headers.get("Content-Length", 0))
  722. post_data = self.rfile.read(content_length)
  723. try:
  724. payload = json.loads(post_data.decode("utf-8"))
  725. except Exception as e:
  726. return self._send_error_response(f"Invalid JSON body: {str(e)}")
  727. parsed_params = {}
  728. if payload.get("action") in ["clear", "stop"]:
  729. self.daemon_instance.stop_animation()
  730. return self._send_success_response("Matrix cleared and animation stopped.")
  731. mode = str(payload.get("mode", "scroll")).lower()
  732. if mode not in ["scroll", "clock_digital", "clock_analog"]:
  733. mode = "scroll"
  734. parsed_params["mode"] = mode
  735. if mode == "scroll":
  736. if "text" not in payload or not str(payload.get("text", "")).strip():
  737. return self._send_error_response("Missing or empty required field: 'text'")
  738. parsed_params["text"] = str(payload["text"])
  739. try:
  740. parsed_params["sweep_pause"] = float(payload.get("sweep_pause", 2.0))
  741. except ValueError:
  742. return self._send_error_response("Field 'sweep_pause' must be a float in seconds.")
  743. if mode == "clock_digital":
  744. parsed_params["clock_date"] = bool(payload.get("clock_date", True))
  745. parsed_params["clock_seconds"] = bool(payload.get("clock_seconds", True))
  746. parsed_params["clock_blink"] = bool(payload.get("clock_blink", True))
  747. effect = payload.get("effect", "solid").lower()
  748. if effect not in ["solid", "rainbow", "gradient-h", "gradient-v", "pulse"]:
  749. return self._send_error_response(f"Invalid effect '{effect}'.")
  750. parsed_params["effect"] = effect
  751. bg_effect = payload.get("bg_effect", "solid").lower()
  752. if bg_effect not in ["solid", "rainbow", "gradient-h", "gradient-v", "pulse"]:
  753. return self._send_error_response(f"Invalid bg_effect '{bg_effect}'.")
  754. parsed_params["bg_effect"] = bg_effect
  755. font_val = str(payload.get("font", "")).strip()
  756. if mode == "clock_digital":
  757. if font_val in MONO_FONT_MAP:
  758. parsed_params["font"] = MONO_FONT_MAP[font_val]
  759. else:
  760. parsed_params["font"] = next(iter(MONO_FONT_MAP.values())) if MONO_FONT_MAP else next(iter(ALL_FONT_MAP.values()))
  761. else:
  762. if font_val in ALL_FONT_MAP:
  763. parsed_params["font"] = ALL_FONT_MAP[font_val]
  764. elif os.path.exists(font_val):
  765. parsed_params["font"] = font_val
  766. else:
  767. parsed_params["font"] = self.daemon_instance.default_font
  768. try:
  769. parsed_params["color"] = parse_rgb(payload.get("color", "magenta"))
  770. parsed_params["color2"] = parse_rgb(payload.get("color2", "cyan"))
  771. parsed_params["bg_color"] = parse_rgb(payload.get("bg_color", "black"))
  772. parsed_params["bg_color2"] = parse_rgb(payload.get("bg_color2", "navy"))
  773. except ValueError as ve:
  774. return self._send_error_response(str(ve))
  775. try:
  776. parsed_params["brightness"] = int(payload.get("brightness", 255))
  777. if not (0 <= parsed_params["brightness"] <= 255):
  778. raise ValueError()
  779. except ValueError:
  780. return self._send_error_response("Field 'brightness' must be an integer between 0 and 255.")
  781. try:
  782. parsed_params["speed"] = float(payload.get("speed", 0.05))
  783. if parsed_params["speed"] < 0.001:
  784. raise ValueError()
  785. except ValueError:
  786. return self._send_error_response("Field 'speed' must be a positive float (delay in seconds).")
  787. try:
  788. parsed_params["loops"] = int(payload.get("loops", 0))
  789. if parsed_params["loops"] < 0:
  790. raise ValueError()
  791. except ValueError:
  792. return self._send_error_response("Field 'loops' must be an integer >= 0.")
  793. if "ip" in payload: parsed_params["ip"] = str(payload["ip"])
  794. if "port" in payload: parsed_params["port"] = int(payload["port"])
  795. self.daemon_instance.start_animation(parsed_params)
  796. return self._send_success_response("Pushed payload to matrix.")
  797. def _send_success_response(self, message):
  798. self.send_response(200)
  799. self.send_header("Content-Type", "application/json")
  800. self.end_headers()
  801. res = {"status": "success", "message": message}
  802. self.wfile.write(json.dumps(res).encode("utf-8"))
  803. def _send_error_response(self, error_msg):
  804. self.send_response(400)
  805. self.send_header("Content-Type", "application/json")
  806. self.end_headers()
  807. usage_instructions = {
  808. "status": "error",
  809. "error_detail": error_msg
  810. }
  811. self.wfile.write(json.dumps(usage_instructions, indent=2).encode("utf-8"))
  812. # --- CLI & Daemon Entrypoint ---
  813. def main():
  814. parser = argparse.ArgumentParser(description="WLED DDP Matrix Billboard & Daemon")
  815. parser.add_argument("-d", "--daemon", action="store_true", help="Run as background HTTP daemon server")
  816. parser.add_argument("--listen-ip", type=str, default="0.0.0.0", help="HTTP daemon bind IP")
  817. parser.add_argument("--listen-port", type=int, default=8080, help="HTTP daemon listening port")
  818. parser.add_argument("-m", "--mode", type=str, choices=["scroll", "clock_digital", "clock_analog"], default="scroll", help="Display mode")
  819. parser.add_argument("-t", "--text", type=str, default="WOMAN CAVE", help="Text to scroll")
  820. parser.add_argument("--ip", type=str, default="192.168.195.216", help="WLED IP address")
  821. parser.add_argument("--port", type=int, default=4048, help="DDP UDP Port")
  822. parser.add_argument("-b", "--brightness", type=int, default=255, help="Global brightness scale (0-255)")
  823. parser.add_argument("--no-date", action="store_true", help="Hide date in digital clock mode")
  824. parser.add_argument("--no-seconds", action="store_true", help="Hide seconds in digital clock mode")
  825. parser.add_argument("--no-blink", action="store_true", help="Disable blinking colon in digital clock mode")
  826. parser.add_argument("-e", "--effect", type=str, choices=["solid", "rainbow", "gradient-h", "gradient-v", "pulse"], default="solid", help="Text effect")
  827. parser.add_argument("-c", "--color", type=str, default="magenta", help="Primary color")
  828. parser.add_argument("--color2", type=str, default="cyan", help="Secondary color for gradient effect")
  829. parser.add_argument("--bg-effect", type=str, choices=["solid", "rainbow", "gradient-h", "gradient-v", "pulse"], default="solid", help="Background canvas effect")
  830. parser.add_argument("--bg-color", type=str, default="black", help="Background primary color")
  831. parser.add_argument("--bg-color2", type=str, default="navy", help="Background secondary color for gradient effect")
  832. parser.add_argument("-s", "--speed", type=float, default=0.05, help="Scroll frame delay in seconds")
  833. parser.add_argument("--sweep-pause", type=float, default=2.0, help="Seconds to pause on blank screen after sweep")
  834. parser.add_argument("--font", type=str, default="default", help="Path or preset key for TTF font")
  835. parser.add_argument("--width", type=int, default=48, help="Matrix width in pixels")
  836. parser.add_argument("--height", type=int, default=32, help="Matrix height in pixels")
  837. parser.add_argument("-l", "--loops", type=int, default=0, help="Number of scroll loops (0 = infinite)")
  838. args = parser.parse_args()
  839. font_path = ALL_FONT_MAP.get(args.font, args.font)
  840. if not os.path.exists(font_path):
  841. font_path = next(iter(ALL_FONT_MAP.values())) if ALL_FONT_MAP else ""
  842. daemon_mgr = BillboardDaemon(
  843. default_ip=args.ip,
  844. default_port=args.port,
  845. default_width=args.width,
  846. default_height=args.height,
  847. default_font=font_path
  848. )
  849. if args.daemon:
  850. DaemonHTTPHandler.daemon_instance = daemon_mgr
  851. server = HTTPServer((args.listen_ip, args.listen_port), DaemonHTTPHandler)
  852. print(f"Billboard Daemon active on http://{args.listen_ip}:{args.listen_port}")
  853. print(f"Found {len(ALL_FONT_MAP)} total fonts ({len(MONO_FONT_MAP)} monospace).")
  854. print(f"Targeting WLED at {args.ip}:{args.port} | Press Ctrl+C to stop.")
  855. try:
  856. server.serve_forever()
  857. except KeyboardInterrupt:
  858. print("\nShutting down daemon...")
  859. daemon_mgr.stop_animation()
  860. server.server_close()
  861. print("Daemon stopped cleanly.")
  862. else:
  863. try:
  864. params = {
  865. "mode": args.mode,
  866. "text": args.text,
  867. "ip": args.ip,
  868. "port": args.port,
  869. "width": args.width,
  870. "height": args.height,
  871. "font": font_path,
  872. "effect": args.effect,
  873. "color": parse_rgb(args.color),
  874. "color2": parse_rgb(args.color2),
  875. "bg_effect": args.bg_effect,
  876. "bg_color": parse_rgb(args.bg_color),
  877. "bg_color2": parse_rgb(args.bg_color2),
  878. "brightness": args.brightness,
  879. "speed": args.speed,
  880. "sweep_pause": args.sweep_pause,
  881. "loops": args.loops,
  882. "clock_date": not args.no_date,
  883. "clock_seconds": not args.no_seconds,
  884. "clock_blink": not args.no_blink
  885. }
  886. except ValueError as ve:
  887. print(f"Error: {ve}")
  888. sys.exit(1)
  889. print(f"Streaming CLI payload to {args.ip}:{args.port} | Press Ctrl+C to stop.")
  890. daemon_mgr.start_animation(params)
  891. try:
  892. while daemon_mgr.worker_thread and daemon_mgr.worker_thread.is_alive():
  893. time.sleep(0.5)
  894. except KeyboardInterrupt:
  895. print("\nStopping CLI stream...")
  896. daemon_mgr.stop_animation()
  897. if __name__ == "__main__":
  898. main()