matrix_billboard.py 55 KB

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