|
@@ -254,9 +254,14 @@ class BillboardDaemon:
|
|
|
self.stop_event = threading.Event()
|
|
self.stop_event = threading.Event()
|
|
|
self.worker_thread = None
|
|
self.worker_thread = None
|
|
|
self.lock = threading.Lock()
|
|
self.lock = threading.Lock()
|
|
|
|
|
+ self.ambient_params = None
|
|
|
|
|
|
|
|
def start_animation(self, params):
|
|
def start_animation(self, params):
|
|
|
with self.lock:
|
|
with self.lock:
|
|
|
|
|
+ # If duration is 0, this request becomes our permanent ambient state
|
|
|
|
|
+ if float(params.get("duration", 0.0)) == 0.0:
|
|
|
|
|
+ self.ambient_params = dict(params)
|
|
|
|
|
+
|
|
|
if self.worker_thread and self.worker_thread.is_alive():
|
|
if self.worker_thread and self.worker_thread.is_alive():
|
|
|
self.stop_event.set()
|
|
self.stop_event.set()
|
|
|
self.worker_thread.join()
|
|
self.worker_thread.join()
|
|
@@ -271,6 +276,7 @@ class BillboardDaemon:
|
|
|
|
|
|
|
|
def stop_animation(self):
|
|
def stop_animation(self):
|
|
|
with self.lock:
|
|
with self.lock:
|
|
|
|
|
+ self.ambient_params = None
|
|
|
if self.worker_thread and self.worker_thread.is_alive():
|
|
if self.worker_thread and self.worker_thread.is_alive():
|
|
|
self.stop_event.set()
|
|
self.stop_event.set()
|
|
|
self.worker_thread.join()
|
|
self.worker_thread.join()
|
|
@@ -293,6 +299,8 @@ class BillboardDaemon:
|
|
|
bg_color2 = params.get("bg_color2", (0, 0, 128))
|
|
bg_color2 = params.get("bg_color2", (0, 0, 128))
|
|
|
|
|
|
|
|
brightness = max(0, min(255, params.get("brightness", 255))) / 255.0
|
|
brightness = max(0, min(255, params.get("brightness", 255))) / 255.0
|
|
|
|
|
+ duration = float(params.get("duration", 0.0))
|
|
|
|
|
+ end_time = (time.time() + duration) if duration > 0 else None
|
|
|
|
|
|
|
|
seq = 0
|
|
seq = 0
|
|
|
frame_idx = 0
|
|
frame_idx = 0
|
|
@@ -303,6 +311,9 @@ class BillboardDaemon:
|
|
|
blink = params.get("clock_blink", True)
|
|
blink = params.get("clock_blink", True)
|
|
|
|
|
|
|
|
while not self.stop_event.is_set():
|
|
while not self.stop_event.is_set():
|
|
|
|
|
+ if end_time and time.time() >= end_time:
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
if mode == "clock_analog":
|
|
if mode == "clock_analog":
|
|
|
base_mask = render_analog_clock(width, height)
|
|
base_mask = render_analog_clock(width, height)
|
|
|
else:
|
|
else:
|
|
@@ -322,58 +333,65 @@ class BillboardDaemon:
|
|
|
|
|
|
|
|
if self.stop_event.wait(0.1):
|
|
if self.stop_event.wait(0.1):
|
|
|
break
|
|
break
|
|
|
-
|
|
|
|
|
- clear_matrix(self.sock, ip, port, width, height)
|
|
|
|
|
- return
|
|
|
|
|
-
|
|
|
|
|
- text = params.get("text", "WOMAN CAVE")
|
|
|
|
|
- speed = params.get("speed", 0.05)
|
|
|
|
|
- loops = params.get("loops", 0)
|
|
|
|
|
- sweep_pause = params.get("sweep_pause", 2.0)
|
|
|
|
|
|
|
+ else:
|
|
|
|
|
+ text = params.get("text", "WOMAN CAVE")
|
|
|
|
|
+ speed = params.get("speed", 0.05)
|
|
|
|
|
+ loops = params.get("loops", 0)
|
|
|
|
|
+ sweep_pause = params.get("sweep_pause", 2.0)
|
|
|
|
|
|
|
|
- base_banner = render_base_text(text, font_path, height)
|
|
|
|
|
- banner_width = base_banner.size[0]
|
|
|
|
|
- loop_count = 0
|
|
|
|
|
|
|
+ base_banner = render_base_text(text, font_path, height)
|
|
|
|
|
+ banner_width = base_banner.size[0]
|
|
|
|
|
+ loop_count = 0
|
|
|
|
|
|
|
|
- while not self.stop_event.is_set():
|
|
|
|
|
- for x_pos in range(width, -banner_width - 1, -1):
|
|
|
|
|
- if self.stop_event.is_set():
|
|
|
|
|
|
|
+ while not self.stop_event.is_set():
|
|
|
|
|
+ if end_time and time.time() >= end_time:
|
|
|
break
|
|
break
|
|
|
|
|
|
|
|
- styled_banner = apply_text_effect(base_banner, effect, color1, color2, frame_idx)
|
|
|
|
|
-
|
|
|
|
|
- bg_layer = generate_effect_layer(width, height, bg_effect, bg_color, bg_color2, frame_idx).convert("RGB")
|
|
|
|
|
- bg_layer.paste(styled_banner, (x_pos, 0), styled_banner)
|
|
|
|
|
-
|
|
|
|
|
- if brightness < 1.0:
|
|
|
|
|
- bg_layer = ImageEnhance.Brightness(bg_layer).enhance(brightness)
|
|
|
|
|
-
|
|
|
|
|
- send_ddp_frame(self.sock, bg_layer.tobytes(), ip, port, seq)
|
|
|
|
|
- seq = (seq + 1) % 16
|
|
|
|
|
- frame_idx += 1
|
|
|
|
|
-
|
|
|
|
|
- if self.stop_event.wait(speed):
|
|
|
|
|
- break
|
|
|
|
|
|
|
+ for x_pos in range(width, -banner_width - 1, -1):
|
|
|
|
|
+ if self.stop_event.is_set() or (end_time and time.time() >= end_time):
|
|
|
|
|
+ break
|
|
|
|
|
|
|
|
- if sweep_pause > 0 and not self.stop_event.is_set():
|
|
|
|
|
- pause_end = time.time() + sweep_pause
|
|
|
|
|
- while time.time() < pause_end and not self.stop_event.is_set():
|
|
|
|
|
|
|
+ styled_banner = apply_text_effect(base_banner, effect, color1, color2, frame_idx)
|
|
|
|
|
+
|
|
|
bg_layer = generate_effect_layer(width, height, bg_effect, bg_color, bg_color2, frame_idx).convert("RGB")
|
|
bg_layer = generate_effect_layer(width, height, bg_effect, bg_color, bg_color2, frame_idx).convert("RGB")
|
|
|
|
|
+ bg_layer.paste(styled_banner, (x_pos, 0), styled_banner)
|
|
|
|
|
+
|
|
|
if brightness < 1.0:
|
|
if brightness < 1.0:
|
|
|
bg_layer = ImageEnhance.Brightness(bg_layer).enhance(brightness)
|
|
bg_layer = ImageEnhance.Brightness(bg_layer).enhance(brightness)
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
send_ddp_frame(self.sock, bg_layer.tobytes(), ip, port, seq)
|
|
send_ddp_frame(self.sock, bg_layer.tobytes(), ip, port, seq)
|
|
|
seq = (seq + 1) % 16
|
|
seq = (seq + 1) % 16
|
|
|
frame_idx += 1
|
|
frame_idx += 1
|
|
|
|
|
|
|
|
- if self.stop_event.wait(0.1):
|
|
|
|
|
|
|
+ if self.stop_event.wait(speed):
|
|
|
break
|
|
break
|
|
|
|
|
|
|
|
- loop_count += 1
|
|
|
|
|
- if loops > 0 and loop_count >= loops:
|
|
|
|
|
- break
|
|
|
|
|
|
|
+ if sweep_pause > 0 and not self.stop_event.is_set() and not (end_time and time.time() >= end_time):
|
|
|
|
|
+ pause_end = time.time() + sweep_pause
|
|
|
|
|
+ while time.time() < pause_end and not self.stop_event.is_set():
|
|
|
|
|
+ if end_time and time.time() >= end_time:
|
|
|
|
|
+ break
|
|
|
|
|
+ bg_layer = generate_effect_layer(width, height, bg_effect, bg_color, bg_color2, frame_idx).convert("RGB")
|
|
|
|
|
+ if brightness < 1.0:
|
|
|
|
|
+ bg_layer = ImageEnhance.Brightness(bg_layer).enhance(brightness)
|
|
|
|
|
+
|
|
|
|
|
+ send_ddp_frame(self.sock, bg_layer.tobytes(), ip, port, seq)
|
|
|
|
|
+ seq = (seq + 1) % 16
|
|
|
|
|
+ frame_idx += 1
|
|
|
|
|
+
|
|
|
|
|
+ if self.stop_event.wait(0.1):
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ loop_count += 1
|
|
|
|
|
+ if loops > 0 and loop_count >= loops:
|
|
|
|
|
+ break
|
|
|
|
|
|
|
|
- clear_matrix(self.sock, ip, port, width, height)
|
|
|
|
|
|
|
+ # State restoration check: if we stopped due to duration timeout, restore ambient mode seamlessly
|
|
|
|
|
+ if not self.stop_event.is_set():
|
|
|
|
|
+ if duration > 0 and self.ambient_params:
|
|
|
|
|
+ return self._render_loop(dict(self.ambient_params))
|
|
|
|
|
+ else:
|
|
|
|
|
+ clear_matrix(self.sock, ip, port, width, height)
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- HTML Documentation Template ---
|
|
# --- HTML Documentation Template ---
|
|
@@ -469,6 +487,11 @@ HTML_DOCS_TEMPLATE = """<!DOCTYPE html>
|
|
|
<input type="range" id="sweep_pause" value="2.0" step="0.5" min="0.0" max="10.0">
|
|
<input type="range" id="sweep_pause" value="2.0" step="0.5" min="0.0" max="10.0">
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label>Alert Duration (0 = Continuous Ambient Mode): <span id="duration_val" class="val-display">0</span>s</label>
|
|
|
|
|
+ <input type="range" id="duration" value="0" min="0" max="60" step="1">
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<div>
|
|
<div>
|
|
|
<label>Brightness (0-255): <span id="bright_val" class="val-display">255</span></label>
|
|
<label>Brightness (0-255): <span id="bright_val" class="val-display">255</span></label>
|
|
|
<input type="range" id="brightness" value="255" min="0" max="255" step="1">
|
|
<input type="range" id="brightness" value="255" min="0" max="255" step="1">
|
|
@@ -557,6 +580,9 @@ HTML_DOCS_TEMPLATE = """<!DOCTYPE html>
|
|
|
document.getElementById('sweep_pause').addEventListener('input', function() {
|
|
document.getElementById('sweep_pause').addEventListener('input', function() {
|
|
|
document.getElementById('pause_val').innerText = this.value;
|
|
document.getElementById('pause_val').innerText = this.value;
|
|
|
});
|
|
});
|
|
|
|
|
+ document.getElementById('duration').addEventListener('input', function() {
|
|
|
|
|
+ document.getElementById('duration_val').innerText = this.value;
|
|
|
|
|
+ });
|
|
|
document.getElementById('brightness').addEventListener('input', function() {
|
|
document.getElementById('brightness').addEventListener('input', function() {
|
|
|
document.getElementById('bright_val').innerText = this.value;
|
|
document.getElementById('bright_val').innerText = this.value;
|
|
|
});
|
|
});
|
|
@@ -654,7 +680,6 @@ HTML_DOCS_TEMPLATE = """<!DOCTYPE html>
|
|
|
el.style.display = (mode === "clock_digital") ? "grid" : "none";
|
|
el.style.display = (mode === "clock_digital") ? "grid" : "none";
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // Populate font dropdown strictly based on mode (monospace for digital clock)
|
|
|
|
|
const fontSelect = document.getElementById("font");
|
|
const fontSelect = document.getElementById("font");
|
|
|
const currentFont = fontSelect.value;
|
|
const currentFont = fontSelect.value;
|
|
|
fontSelect.innerHTML = "";
|
|
fontSelect.innerHTML = "";
|
|
@@ -683,6 +708,7 @@ HTML_DOCS_TEMPLATE = """<!DOCTYPE html>
|
|
|
const mode = document.getElementById("mode").value;
|
|
const mode = document.getElementById("mode").value;
|
|
|
const effect = document.getElementById("effect").value;
|
|
const effect = document.getElementById("effect").value;
|
|
|
const bgEffect = document.getElementById("bg_effect").value;
|
|
const bgEffect = document.getElementById("bg_effect").value;
|
|
|
|
|
+ const duration = parseFloat(document.getElementById("duration").value);
|
|
|
|
|
|
|
|
let payload = {
|
|
let payload = {
|
|
|
mode: mode,
|
|
mode: mode,
|
|
@@ -693,6 +719,7 @@ HTML_DOCS_TEMPLATE = """<!DOCTYPE html>
|
|
|
brightness: parseInt(document.getElementById("brightness").value)
|
|
brightness: parseInt(document.getElementById("brightness").value)
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ if (duration > 0) payload.duration = duration;
|
|
|
if (effect.includes("gradient")) payload.color2 = document.getElementById("color2").value;
|
|
if (effect.includes("gradient")) payload.color2 = document.getElementById("color2").value;
|
|
|
if (bgEffect.includes("gradient")) payload.bg_color2 = document.getElementById("bg_color2").value;
|
|
if (bgEffect.includes("gradient")) payload.bg_color2 = document.getElementById("bg_color2").value;
|
|
|
|
|
|
|
@@ -725,7 +752,7 @@ HTML_DOCS_TEMPLATE = """<!DOCTYPE html>
|
|
|
|
|
|
|
|
document.getElementById("mode").addEventListener("change", handleModeToggle);
|
|
document.getElementById("mode").addEventListener("change", handleModeToggle);
|
|
|
document.querySelectorAll("input, select").forEach(el => {
|
|
document.querySelectorAll("input, select").forEach(el => {
|
|
|
- el.addEventListener("change", handleDisplayToggles);
|
|
|
|
|
|
|
+ el.addEventListener("change", handleModeToggle);
|
|
|
el.addEventListener("input", () => {
|
|
el.addEventListener("input", () => {
|
|
|
updateCurlPreview();
|
|
updateCurlPreview();
|
|
|
updateFontPreview();
|
|
updateFontPreview();
|
|
@@ -745,6 +772,7 @@ HTML_DOCS_TEMPLATE = """<!DOCTYPE html>
|
|
|
const mode = document.getElementById("mode").value;
|
|
const mode = document.getElementById("mode").value;
|
|
|
const effect = document.getElementById("effect").value;
|
|
const effect = document.getElementById("effect").value;
|
|
|
const bgEffect = document.getElementById("bg_effect").value;
|
|
const bgEffect = document.getElementById("bg_effect").value;
|
|
|
|
|
+ const duration = parseFloat(document.getElementById("duration").value);
|
|
|
|
|
|
|
|
let payload = {
|
|
let payload = {
|
|
|
mode: mode,
|
|
mode: mode,
|
|
@@ -755,6 +783,7 @@ HTML_DOCS_TEMPLATE = """<!DOCTYPE html>
|
|
|
brightness: parseInt(document.getElementById("brightness").value)
|
|
brightness: parseInt(document.getElementById("brightness").value)
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ if (duration > 0) payload.duration = duration;
|
|
|
if (effect.includes("gradient")) payload.color2 = document.getElementById("color2").value;
|
|
if (effect.includes("gradient")) payload.color2 = document.getElementById("color2").value;
|
|
|
if (bgEffect.includes("gradient")) payload.bg_color2 = document.getElementById("bg_color2").value;
|
|
if (bgEffect.includes("gradient")) payload.bg_color2 = document.getElementById("bg_color2").value;
|
|
|
|
|
|
|
@@ -866,7 +895,7 @@ class DaemonHTTPHandler(BaseHTTPRequestHandler):
|
|
|
|
|
|
|
|
if payload.get("action") in ["clear", "stop"]:
|
|
if payload.get("action") in ["clear", "stop"]:
|
|
|
self.daemon_instance.stop_animation()
|
|
self.daemon_instance.stop_animation()
|
|
|
- return self._send_success_response("Matrix cleared and animation stopped.")
|
|
|
|
|
|
|
+ return self._send_success_response("Matrix cleared and ambient state wiped.")
|
|
|
|
|
|
|
|
mode = str(payload.get("mode", "scroll")).lower()
|
|
mode = str(payload.get("mode", "scroll")).lower()
|
|
|
if mode not in ["scroll", "clock_digital", "clock_analog"]:
|
|
if mode not in ["scroll", "clock_digital", "clock_analog"]:
|
|
@@ -888,6 +917,13 @@ class DaemonHTTPHandler(BaseHTTPRequestHandler):
|
|
|
parsed_params["clock_seconds"] = bool(payload.get("clock_seconds", True))
|
|
parsed_params["clock_seconds"] = bool(payload.get("clock_seconds", True))
|
|
|
parsed_params["clock_blink"] = bool(payload.get("clock_blink", True))
|
|
parsed_params["clock_blink"] = bool(payload.get("clock_blink", True))
|
|
|
|
|
|
|
|
|
|
+ try:
|
|
|
|
|
+ parsed_params["duration"] = float(payload.get("duration", 0.0))
|
|
|
|
|
+ if parsed_params["duration"] < 0:
|
|
|
|
|
+ raise ValueError()
|
|
|
|
|
+ except ValueError:
|
|
|
|
|
+ return self._send_error_response("Field 'duration' must be a non-negative float (seconds).")
|
|
|
|
|
+
|
|
|
effect = payload.get("effect", "solid").lower()
|
|
effect = payload.get("effect", "solid").lower()
|
|
|
if effect not in ["solid", "rainbow", "gradient-h", "gradient-v", "pulse"]:
|
|
if effect not in ["solid", "rainbow", "gradient-h", "gradient-v", "pulse"]:
|
|
|
return self._send_error_response(f"Invalid effect '{effect}'.")
|
|
return self._send_error_response(f"Invalid effect '{effect}'.")
|
|
@@ -945,7 +981,9 @@ class DaemonHTTPHandler(BaseHTTPRequestHandler):
|
|
|
if "port" in payload: parsed_params["port"] = int(payload["port"])
|
|
if "port" in payload: parsed_params["port"] = int(payload["port"])
|
|
|
|
|
|
|
|
self.daemon_instance.start_animation(parsed_params)
|
|
self.daemon_instance.start_animation(parsed_params)
|
|
|
- return self._send_success_response("Pushed payload to matrix.")
|
|
|
|
|
|
|
+
|
|
|
|
|
+ msg = f"Temporary alert active for {parsed_params['duration']}s." if parsed_params['duration'] > 0 else "Set as permanent ambient state."
|
|
|
|
|
+ return self._send_success_response(msg)
|
|
|
|
|
|
|
|
def _send_success_response(self, message):
|
|
def _send_success_response(self, message):
|
|
|
self.send_response(200)
|
|
self.send_response(200)
|
|
@@ -976,6 +1014,7 @@ def main():
|
|
|
parser.add_argument("--ip", type=str, default="192.168.195.216", help="WLED IP address")
|
|
parser.add_argument("--ip", type=str, default="192.168.195.216", help="WLED IP address")
|
|
|
parser.add_argument("--port", type=int, default=4048, help="DDP UDP Port")
|
|
parser.add_argument("--port", type=int, default=4048, help="DDP UDP Port")
|
|
|
parser.add_argument("-b", "--brightness", type=int, default=255, help="Global brightness scale (0-255)")
|
|
parser.add_argument("-b", "--brightness", type=int, default=255, help="Global brightness scale (0-255)")
|
|
|
|
|
+ parser.add_argument("--duration", type=float, default=0.0, help="Alert duration in seconds (0 = permanent ambient state)")
|
|
|
|
|
|
|
|
parser.add_argument("--no-date", action="store_true", help="Hide date in digital clock mode")
|
|
parser.add_argument("--no-date", action="store_true", help="Hide date in digital clock mode")
|
|
|
parser.add_argument("--no-seconds", action="store_true", help="Hide seconds in digital clock mode")
|
|
parser.add_argument("--no-seconds", action="store_true", help="Hide seconds in digital clock mode")
|
|
@@ -987,7 +1026,7 @@ def main():
|
|
|
|
|
|
|
|
parser.add_argument("--bg-effect", type=str, choices=["solid", "rainbow", "gradient-h", "gradient-v", "pulse"], default="solid", help="Background canvas effect")
|
|
parser.add_argument("--bg-effect", type=str, choices=["solid", "rainbow", "gradient-h", "gradient-v", "pulse"], default="solid", help="Background canvas effect")
|
|
|
parser.add_argument("--bg-color", type=str, default="black", help="Background primary color")
|
|
parser.add_argument("--bg-color", type=str, default="black", help="Background primary color")
|
|
|
- parser.add_argument("--bg-color2", type=str, default="navy", help="Background secondary color for gradient effect")
|
|
|
|
|
|
|
+ parser.bg_color2 = parser.add_argument("--bg-color2", type=str, default="navy", help="Background secondary color for gradient effect")
|
|
|
|
|
|
|
|
parser.add_argument("-s", "--speed", type=float, default=0.05, help="Scroll frame delay in seconds")
|
|
parser.add_argument("-s", "--speed", type=float, default=0.05, help="Scroll frame delay in seconds")
|
|
|
parser.add_argument("--sweep-pause", type=float, default=2.0, help="Seconds to pause on blank screen after sweep")
|
|
parser.add_argument("--sweep-pause", type=float, default=2.0, help="Seconds to pause on blank screen after sweep")
|
|
@@ -1043,6 +1082,7 @@ def main():
|
|
|
"brightness": args.brightness,
|
|
"brightness": args.brightness,
|
|
|
"speed": args.speed,
|
|
"speed": args.speed,
|
|
|
"sweep_pause": args.sweep_pause,
|
|
"sweep_pause": args.sweep_pause,
|
|
|
|
|
+ "duration": args.duration,
|
|
|
"loops": args.loops,
|
|
"loops": args.loops,
|
|
|
"clock_date": not args.no_date,
|
|
"clock_date": not args.no_date,
|
|
|
"clock_seconds": not args.no_seconds,
|
|
"clock_seconds": not args.no_seconds,
|
|
@@ -1063,4 +1103,4 @@ def main():
|
|
|
daemon_mgr.stop_animation()
|
|
daemon_mgr.stop_animation()
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if __name__ == "__main__":
|
|
|
- main()
|
|
|
|
|
|
|
+ main()
|