diff --git a/README.md b/README.md index 1c47186..4a3644d 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,9 @@ La scheda (CH340, `1a86:7523`) **non espone un iSerial**: verificato con | `/dev/serial/by-path/pci-0000:65:00.3-usb-0:2:1.0-port0` | legato alla porta USB fisica | sì | | `/dev/ambilight` (regola udev) | symlink creato da noi | sì | +> **Su questa macchina la regola NON è installata**: scelta esplicita dell'utente di +> lasciare `ttyUSB0` come assegnato dal kernel. I file restano nel repo come opzione. + Installa la regola (richiede sudo, una volta): ```sh @@ -117,17 +120,30 @@ finché la sessione di condivisione è attiva. Verificato su questa macchina: esiste la voce "Configure System Tray → Entries" per nasconderlo: è un indicatore di stato del shell, per scelta di KDE. +**Il pallino è uno StatusNotifierItem pubblicato da `xdg-desktop-portal-kde`** (titolo +"Trasmissione dello schermo"): quindi è un elemento del vassoio con visibilità +configurabile da *Configura il vassoio di sistema → Voci* (impostandolo su "Sempre +nascosto" sparisce dal pannello e resta nel popup espanso). + Le alternative per non averlo: -1. **KWin ScreenShot2** (nessun portal, nessun indicatore): KWin autorizza solo i +1. **KWin ScreenShot2** (nessun portal, nessun indicatore) — **rifiutato su questa + macchina**: testato sia con `.desktop` utente sia con entry system-wide in + `/usr/local/share/applications` (`Exec=/usr/bin/python3`, `X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2`, + `kbuildsycoca6`): la risposta resta `org.kde.KWin.ScreenShot2.Error.NoAuthorized`. KWin autorizza solo i processi con un `.desktop` che dichiara `X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2`. Provato con un `.desktop` utente (`Exec=/usr/bin/python3` e `python3.14`) + `kbuildsycoca6`: risponde ancora `org.kde.KWin.ScreenShot2.Error.NoAuthorized`. Non provato l'entry in `/usr/local/share/applications` (richiede sudo). Se funziona, la cattura è un `CaptureArea` della sola fascia (nessun indicatore, ma costo CPU per frame). -2. **kmsgrab** (`ffmpeg -f kmsgrab`) da un servizio privilegiato con `CAP_SYS_ADMIN`: - nessun portal e nessun indicatore, ma cattura DRM grezza (piano/format legati alla - GPU) e un servizio di sistema: più fragile. +2. **kmsgrab** (`ffmpeg -f kmsgrab`) con `CAP_SYS_ADMIN`: **provato e non praticabile su + questa macchina** (Radeon 780M + KWin). Il piano primario è `plane-3` (id 206), + `crtc-0`, `fb=463`, `format=AR30` (`0x30335241`) con `modifier=0x200000010437b04` + (AMD DCC/tiled): ffmpeg risponde `Framebuffer pixel format 30335241 is not a known + supported format` e fallisce **prima** di applicare `-format`/`-format_modifier` + (provati `bgr0`, `rgb0`, `0rgb`, `bgra`, `x2rgb10le`, `-format_modifier 0`, + `-plane_id 206`). Anche superando il controllo, il layout tiled/DCC richiederebbe + un import GBM/EGL o VAAPI, che kmsgrab non fa. 3. Tenere il portal: costo GPU minimo, ~78 fps, indicatore visibile. ## Note diff --git a/backend/ambicapture.py b/backend/ambicapture.py index f2b92ec..49162d9 100644 --- a/backend/ambicapture.py +++ b/backend/ambicapture.py @@ -113,3 +113,89 @@ class Capture: if len(data) < need: return None return np.frombuffer(data, dtype=np.uint8).reshape(self.gh, self.gw, 3) + + +# ------------------------------------------------------------------ kmsgrab +FFMPEG_CAP = "/usr/local/libexec/ambilight/ffmpeg-kmsgrab" +FFMPEG_SYS = "/usr/bin/ffmpeg" + + +class KmsgrabCapture: + """Cattura DRM diretta (ffmpeg -f kmsgrab) senza portal e senza indicatore. + + Richiede CAP_SYS_ADMIN sul binario ffmpeg di appoggio (FFMPEG_CAP), oppure root. + ffmpeg riduce il frame a (leds*sub) x rows in RGB e lo scrive su stdout come + rawvideo: leggiamo esattamente un frame per volta. + """ + + def __init__(self, leds, sub=4, rows=72, fps=60, device="/dev/dri/card1", + ffmpeg=None): + self.gw = leds * sub + self.gh = rows + self.fps = max(1, int(fps)) + self.device = device + ff = ffmpeg or (FFMPEG_CAP if os.path.exists(FFMPEG_CAP) else FFMPEG_SYS) + self.binary = ff + self.frame_bytes = self.gw * self.gh * 3 + self.proc = None + self.stderr_path = os.path.join(CACHE_DIR, "kmsgrab.log") + # hwdownload porta il frame in RAM; lo scaling avviene poi in ffmpeg. + self.vf = (f"hwdownload,format=bgr0," + f"scale={self.gw}:{self.gh}:flags=bilinear,format=rgb24") + self.cmd = [ff, "-hide_banner", "-loglevel", "error", "-nostdin", + "-f", "kmsgrab", "-device", device, "-i", "-", + "-vf", self.vf, + "-r", str(self.fps), + "-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:1"] + + 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, stdout=subprocess.PIPE, + stderr=self.err, bufsize=self.frame_bytes) + return self + + def stop(self): + try: + if self.proc is not None: + 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): + """Legge un frame completo; None se non arriva entro il timeout.""" + 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/ambilightd.py b/backend/ambilightd.py index ca6fbe7..8f47ac1 100755 --- a/backend/ambilightd.py +++ b/backend/ambilightd.py @@ -42,6 +42,8 @@ DEFAULTS = { "baud": 115200, "fps": 60, "side": "top", # top | bottom + "capture": "portal", # portal (ScreenCast) | kmsgrab (DRM diretto, senza indicatore) + "drm_device": "/dev/dri/card1", "auto_start": True, # streaming attivo all'avvio del demone } PARAM_RANGES = { @@ -190,32 +192,53 @@ def apply_params(zones, cfg): def capture_loop(state, strip): - from ambicapture import Capture, screencast_stream + """Ciclo di cattura: sceglie il backend (portal o kmsgrab) e invia i frame.""" + from ambicapture import Capture, KmsgrabCapture, screencast_stream cap = None - leds_cfg = None + key = None prev = None sent = 0 t_win = time.time() + + def build(cfg): + """Crea e avvia il backend; ritorna (cap, descrizione).""" + 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}" + while True: cfg = state.snapshot() if not cfg["running"]: if cap is not None: cap.stop() cap = None + key = None prev = None time.sleep(0.15) continue - if cap is None or leds_cfg != cfg["leds"]: + want = (cfg["capture"], cfg["leds"], cfg["drm_device"], cfg["fps"]) + dead = cap is not None and hasattr(cap, "alive") and not cap.alive() + if cap is None or want != key or dead: if cap is not None: + err = cap.error_text() if hasattr(cap, "error_text") else "" cap.stop() + cap = None + if dead: + with state.lock: + state.stream_error = f"kmsgrab terminato: {err}"[:300] + log("kmsgrab terminato:", err) + time.sleep(3) + continue try: - node, w, h = screencast_stream() - cap = Capture(node, cfg["leds"], fps=cfg["fps"]) - cap.start() - leds_cfg = cfg["leds"] - log(f"cattura avviata: node={node} sorgente {w}x{h} led={cfg['leds']}") + cap, desc = build(cfg) + key = want + log(f"cattura avviata ({cfg['capture']}): {desc} led={cfg['leds']}") with state.lock: state.stream_error = "" state.last_error = "" @@ -231,9 +254,8 @@ def capture_loop(state, strip): time.sleep(0.005) continue - rows = max(1, int(round(cap.gh * cfg["band"]))) gh, gw = frame.shape[0], frame.shape[1] - rows = min(rows, gh) + rows = min(max(1, int(round(gh * cfg["band"]))), gh) band = frame[:rows] if cfg["side"] == "top" else frame[gh - rows:] sub = gw // cfg["leds"] if sub < 1: @@ -358,7 +380,7 @@ class DBusService: value = bool(value) elif key == "leds": value = max(1, min(256, int(value))) - elif key in ("port", "side", "baud", "fps"): + elif key in ("port", "side", "baud", "fps", "capture", "drm_device"): value = type(DEFAULTS[key])(value) else: return False, f"parametro sconosciuto: {key}" diff --git a/setup-kmsgrab.sh b/setup-kmsgrab.sh new file mode 100755 index 0000000..0c36120 --- /dev/null +++ b/setup-kmsgrab.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Installa (via pkexec) una copia di ffmpeg con CAP_SYS_ADMIN, necessaria per kmsgrab. +# La capability e' confinata a QUELLA copia del binario, non a /usr/bin/ffmpeg. +# +# ./setup-kmsgrab.sh installa +# ./setup-kmsgrab.sh --remove rimuove +set -euo pipefail + +DEST="/usr/local/libexec/ambilight/ffmpeg-kmsgrab" +SRC="$(command -v ffmpeg)" + +say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } + +if [ "${1:-}" = "--remove" ]; then + say "Rimuovo $DEST" + pkexec sh -c "rm -f '$DEST' && rmdir --ignore-fail-on-non-empty /usr/local/libexec/ambilight 2>/dev/null || true" + say "Fatto." + exit 0 +fi + +[ -x "$SRC" ] || { echo "ffmpeg non trovato" >&2; exit 1; } + +say "Installo $SRC -> $DEST con cap_sys_admin+ep (pkexec chiedera' la password)" +pkexec sh -c "install -D -m 0755 -o root -g root '$SRC' '$DEST' \ + && setcap cap_sys_admin+ep '$DEST' \ + && echo '--- capability applicata:' && getcap '$DEST'" + +say "Verifica rapida (1 frame, senza portal e senza indicatore nel vassoio)" +"$DEST" -hide_banner -loglevel error -nostdin \ + -f kmsgrab -device /dev/dri/card1 -i - \ + -vf "hwdownload,format=bgr0,scale=188:72:flags=bilinear,format=rgb24" \ + -frames:v 1 -f rawvideo -pix_fmt rgb24 /tmp/kmsgrab_probe.rgb +ls -l /tmp/kmsgrab_probe.rgb +python3 - <<'PY' +import numpy as np +a = np.frombuffer(open('/tmp/kmsgrab_probe.rgb','rb').read(), dtype=np.uint8) +print("frame:", a.size, "byte ->", a[:9].tolist(), "| media:", a.reshape(-1,3).mean(axis=0).round(1).tolist()) +PY + +cat <