WIP: cattura in processo separato + close_stream (NON validata, sospesa)
This commit is contained in:
@@ -10,6 +10,10 @@ CACHE_DIR = os.path.expanduser("~/.cache/ambilight")
|
|||||||
PORTAL = "org.freedesktop.portal.Desktop"
|
PORTAL = "org.freedesktop.portal.Desktop"
|
||||||
PPATH = "/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):
|
def screencast_stream(cache_dir=CACHE_DIR):
|
||||||
"""Negozia la condivisione del monitor; ritorna (node_id, width, height)."""
|
"""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)
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
with open(token_file, "w") as f:
|
with open(token_file, "w") as f:
|
||||||
f.write(str(res["restore_token"]))
|
f.write(str(res["restore_token"]))
|
||||||
|
|
||||||
|
_ACTIVE["bus"] = bus
|
||||||
|
_ACTIVE["session"] = session
|
||||||
return node, size[0], size[1]
|
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:
|
class Capture:
|
||||||
"""Pipeline GStreamer ridotta a (led*sub) x gh pixel RGB, pronta per il calcolo zone."""
|
"""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()
|
return f.read()[-400:].decode("utf-8", "replace").strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
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 ""
|
||||||
|
|||||||
Executable
+89
@@ -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())
|
||||||
@@ -6,10 +6,14 @@ PartOf=graphical-session.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
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
|
ExecStart=/usr/bin/python3 %h/.local/bin/ambilightd.py
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=3
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=graphical-session.target
|
WantedBy=graphical-session.target
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Config: ~/.config/ambilight/config.json
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -193,7 +194,7 @@ def apply_params(zones, cfg):
|
|||||||
|
|
||||||
def capture_loop(state, strip):
|
def capture_loop(state, strip):
|
||||||
"""Ciclo di cattura: sceglie il backend (portal o kmsgrab) e invia i frame."""
|
"""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
|
cap = None
|
||||||
key = None
|
key = None
|
||||||
@@ -206,10 +207,13 @@ def capture_loop(state, strip):
|
|||||||
if cfg["capture"] == "kmsgrab":
|
if cfg["capture"] == "kmsgrab":
|
||||||
c = KmsgrabCapture(cfg["leds"], fps=cfg["fps"], device=cfg["drm_device"]).start()
|
c = KmsgrabCapture(cfg["leds"], fps=cfg["fps"], device=cfg["drm_device"]).start()
|
||||||
return c, f"kmsgrab {c.binary} su {cfg['drm_device']}"
|
return c, f"kmsgrab {c.binary} su {cfg['drm_device']}"
|
||||||
node, w, h = screencast_stream()
|
helper = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ambicatcher.py")
|
||||||
c = Capture(node, cfg["leds"], fps=cfg["fps"])
|
c = ChildCapture([sys.executable, helper,
|
||||||
c.start()
|
"--leds", str(cfg["leds"]), "--rows", "72", "--sub", "4",
|
||||||
return c, f"portal node={node} sorgente {w}x{h}"
|
"--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:
|
while True:
|
||||||
cfg = state.snapshot()
|
cfg = state.snapshot()
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ say "Installo il backend in $BIN"
|
|||||||
install -d "$BIN"
|
install -d "$BIN"
|
||||||
install -m 755 "$HERE/backend/ambilightd.py" "$BIN/ambilightd.py"
|
install -m 755 "$HERE/backend/ambilightd.py" "$BIN/ambilightd.py"
|
||||||
install -m 644 "$HERE/backend/ambicapture.py" "$BIN/ambicapture.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"
|
install -m 755 "$HERE/backend/ambilight-ctl" "$BIN/ambilight-ctl"
|
||||||
|
|
||||||
say "Installo il servizio utente"
|
say "Installo il servizio utente"
|
||||||
|
|||||||
Reference in New Issue
Block a user