From 72c7141ecd4405bb9f01d40b389c90736d3972f8 Mon Sep 17 00:00:00 2001 From: enne2 Date: Sun, 1 Nov 2026 13:35:47 +0100 Subject: [PATCH] WIP: cattura in processo separato + close_stream (NON validata, sospesa) --- backend/ambicapture.py | 119 ++++++++++++++++++++++++++++++++++++++ backend/ambicatcher.py | 89 ++++++++++++++++++++++++++++ backend/ambilight.service | 8 ++- backend/ambilightd.py | 14 +++-- install.sh | 1 + 5 files changed, 224 insertions(+), 7 deletions(-) create mode 100755 backend/ambicatcher.py diff --git a/backend/ambicapture.py b/backend/ambicapture.py index 49162d9..fdbabb9 100644 --- a/backend/ambicapture.py +++ b/backend/ambicapture.py @@ -10,6 +10,10 @@ CACHE_DIR = os.path.expanduser("~/.cache/ambilight") PORTAL = "org.freedesktop.portal.Desktop" PPATH = "/org/freedesktop/portal/desktop" +# Sessione ScreenCast attiva: va CHIUSA esplicitamente, altrimenti il portal continua +# a considerarla viva e lascia nel vassoio l'indicatore "Trasmissione dello schermo". +_ACTIVE = {"bus": None, "session": None} + def screencast_stream(cache_dir=CACHE_DIR): """Negozia la condivisione del monitor; ritorna (node_id, width, height).""" @@ -64,9 +68,30 @@ def screencast_stream(cache_dir=CACHE_DIR): os.makedirs(cache_dir, exist_ok=True) with open(token_file, "w") as f: f.write(str(res["restore_token"])) + + _ACTIVE["bus"] = bus + _ACTIVE["session"] = session return node, size[0], size[1] +def close_stream(): + """Chiude la sessione ScreenCast e la connessione D-Bus (rimuove l'indicatore).""" + session, bus = _ACTIVE.get("session"), _ACTIVE.get("bus") + _ACTIVE["bus"] = _ACTIVE["session"] = None + if not session: + return + try: + import dbus + iface = dbus.Interface(bus.get_object(PORTAL, session), "org.freedesktop.portal.Session") + iface.Close() + except Exception: + pass + try: + bus.close() + except Exception: + pass + + class Capture: """Pipeline GStreamer ridotta a (led*sub) x gh pixel RGB, pronta per il calcolo zone.""" @@ -199,3 +224,97 @@ class KmsgrabCapture: return f.read()[-400:].decode("utf-8", "replace").strip() except Exception: return "" + + +# ------------------------------------------------------------------ processo figlio +class ChildCapture: + """Legge frame RGB grezzi dallo stdout di un processo di cattura separato. + + Il figlio (ambicatcher.py per il portal, ffmpeg per kmsgrab) produce: + prima riga -> JSON di intestazione ({"node":..} oppure {"error":..}) + poi -> frame da (leds*sub) x rows pixel RGB, senza separatori + Tenere la cattura fuori dal demone isola GStreamer/PipeWire e, per il portal, + fa chiudere la sessione ScreenCast insieme al processo (niente indicatori orfani). + """ + + def __init__(self, argv, leds, sub=4, rows=72, expect_header=True): + self.cmd = list(argv) + self.gw = leds * sub + self.gh = rows + self.frame_bytes = self.gw * self.gh * 3 + self.expect_header = expect_header + self.header = {} + self.proc = None + self.stderr_path = os.path.join(CACHE_DIR, "catcher.log") + + def start(self): + import subprocess + os.makedirs(CACHE_DIR, exist_ok=True) + self.err = open(self.stderr_path, "wb") + self.proc = subprocess.Popen(self.cmd, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=self.err, + bufsize=0) + if self.expect_header: + line = self.proc.stdout.readline() + if not line: + err = self.error_text() + raise RuntimeError(f"cattura non avviata: {err or 'nessun output'}") + try: + import json + self.header = json.loads(line.decode()) + except Exception: + self.header = {"raw": line[:100].decode("utf-8", "replace")} + if "error" in self.header: + raise RuntimeError(self.header["error"]) + return self + + def stop(self): + try: + if self.proc is not None: + try: + if self.proc.stdin: + self.proc.stdin.close() # il figlio esce e chiude la sessione + except Exception: + pass + try: + self.proc.wait(timeout=3) + except Exception: + self.proc.terminate() + try: + self.proc.wait(timeout=2) + except Exception: + self.proc.kill() + finally: + self.proc = None + try: + self.err.close() + except Exception: + pass + + def alive(self): + return self.proc is not None and self.proc.poll() is None + + def frame(self, timeout_ms=200): + import select + import numpy as np + if self.proc is None: + return None + fd = self.proc.stdout + buf = bytearray() + deadline = timeout_ms / 1000.0 + while len(buf) < self.frame_bytes: + ready, _, _ = select.select([fd], [], [], deadline) + if not ready: + return None + chunk = fd.read(self.frame_bytes - len(buf)) + if not chunk: + return None + buf += chunk + return np.frombuffer(bytes(buf), dtype=np.uint8).reshape(self.gh, self.gw, 3) + + def error_text(self): + try: + with open(self.stderr_path, "rb") as f: + return f.read()[-400:].decode("utf-8", "replace").strip() + except Exception: + return "" diff --git a/backend/ambicatcher.py b/backend/ambicatcher.py new file mode 100755 index 0000000..cd36c6c --- /dev/null +++ b/backend/ambicatcher.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""ambicatcher — processo di cattura separato: scrive frame RGB grezzi su stdout. + +Vive in un processo a sé per due motivi: + 1) isolare GStreamer/PipeWire e dbus-python dal demone (che resta Python puro: + Gio per il D-Bus, numpy, pyserial); + 2) la sessione ScreenCast appartiene a QUESTO processo: quando termina, il portal + la chiude e l'indicatore "Trasmissione dello schermo" sparisce dal vassoio. + +Uso: + ambicatcher.py --leds 47 --rows 72 --sub 4 --fps 60 +Stampa una riga JSON di intestazione ({"node":..,"w":..,"h":..} oppure {"error":..}) +poi frame da (leds*sub) x rows pixel RGB, senza separatori. +""" +import argparse +import json +import os +import signal +import sys + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--leds", type=int, default=47) + ap.add_argument("--sub", type=int, default=4) + ap.add_argument("--rows", type=int, default=72) + ap.add_argument("--fps", type=int, default=60) + ap.add_argument("--drain", action="store_true", + help="termina quando stdin si chiude (usato dal demone)") + a = ap.parse_args() + + gw, gh = a.leds * a.sub, a.rows + + def bye(*_): + raise SystemExit(0) + + signal.signal(signal.SIGTERM, bye) + signal.signal(signal.SIGINT, bye) + + out = sys.stdout.buffer + try: + from ambicapture import Capture, close_stream, screencast_stream + + node, w, h = screencast_stream() + out.write((json.dumps({"node": node, "w": w, "h": h}) + "\n").encode()) + out.flush() + cap = Capture(node, a.leds, sub=a.sub, rows=a.rows, fps=a.fps) + cap.start() + except Exception as e: + out.write((json.dumps({"error": f"{type(e).__name__}: {e}"}) + "\n").encode()) + out.flush() + return 1 + + if a.drain: + import select + import threading + + def watchdog(): + # stdin chiuso dal padre = termina (e con noi la sessione portal) + try: + while True: + if not sys.stdin.buffer.read(1): + break + except Exception: + pass + os.kill(os.getpid(), signal.SIGTERM) + + threading.Thread(target=watchdog, daemon=True).start() + + try: + while True: + frame = cap.frame(200) + if frame is None: + continue + out.write(frame.tobytes()) + out.flush() + except (BrokenPipeError, KeyboardInterrupt): + pass + finally: + try: + cap.stop() + except Exception: + pass + close_stream() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/ambilight.service b/backend/ambilight.service index b97caa0..97cf187 100644 --- a/backend/ambilight.service +++ b/backend/ambilight.service @@ -6,10 +6,14 @@ PartOf=graphical-session.target [Service] Type=simple +# Rete di sicurezza: un crash non deve mai diventare un loop che apre decine di +# sessioni ScreenCast (e quindi decine di indicatori nel vassoio). +Restart=on-failure +RestartSec=10 +StartLimitIntervalSec=120 +StartLimitBurst=3 ExecStart=/usr/bin/python3 %h/.local/bin/ambilightd.py Environment=PYTHONUNBUFFERED=1 -Restart=on-failure -RestartSec=3 [Install] WantedBy=graphical-session.target diff --git a/backend/ambilightd.py b/backend/ambilightd.py index 8f47ac1..8facae6 100755 --- a/backend/ambilightd.py +++ b/backend/ambilightd.py @@ -19,6 +19,7 @@ Config: ~/.config/ambilight/config.json """ import json import os +import sys import threading import time @@ -193,7 +194,7 @@ def apply_params(zones, cfg): def capture_loop(state, strip): """Ciclo di cattura: sceglie il backend (portal o kmsgrab) e invia i frame.""" - from ambicapture import Capture, KmsgrabCapture, screencast_stream + from ambicapture import ChildCapture, KmsgrabCapture cap = None key = None @@ -206,10 +207,13 @@ def capture_loop(state, strip): if cfg["capture"] == "kmsgrab": c = KmsgrabCapture(cfg["leds"], fps=cfg["fps"], device=cfg["drm_device"]).start() return c, f"kmsgrab {c.binary} su {cfg['drm_device']}" - node, w, h = screencast_stream() - c = Capture(node, cfg["leds"], fps=cfg["fps"]) - c.start() - return c, f"portal node={node} sorgente {w}x{h}" + helper = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ambicatcher.py") + c = ChildCapture([sys.executable, helper, + "--leds", str(cfg["leds"]), "--rows", "72", "--sub", "4", + "--fps", str(int(cfg["fps"])), "--drain"], + cfg["leds"]).start() + return c, f"portal (processo separato) node={c.header.get('node')} " \ + f"sorgente {c.header.get('w')}x{c.header.get('h')}" while True: cfg = state.snapshot() diff --git a/install.sh b/install.sh index 0af7e34..2fd7dba 100755 --- a/install.sh +++ b/install.sh @@ -41,6 +41,7 @@ say "Installo il backend in $BIN" install -d "$BIN" install -m 755 "$HERE/backend/ambilightd.py" "$BIN/ambilightd.py" install -m 644 "$HERE/backend/ambicapture.py" "$BIN/ambicapture.py" +install -m 755 "$HERE/backend/ambicatcher.py" "$BIN/ambicatcher.py" install -m 755 "$HERE/backend/ambilight-ctl" "$BIN/ambilight-ctl" say "Installo il servizio utente"