Integra calibrazione RGB nel plasmoide e ripristina backend funzionante

This commit is contained in:
enne2
2026-11-01 15:43:28 +01:00
parent 72c7141ecd
commit 344e3c8764
9 changed files with 250 additions and 260 deletions
-205
View File
@@ -10,10 +10,6 @@ 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)."""
@@ -68,30 +64,9 @@ 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."""
@@ -138,183 +113,3 @@ 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 ""
# ------------------------------------------------------------------ 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 ""
+4 -8
View File
@@ -66,19 +66,15 @@ def main(argv):
key, raw = argv[2], argv[3]
if key in ("reverse",):
value = raw.lower() in ("1", "true", "yes", "on")
vtype = "b"
elif key in ("leds", "baud"):
elif key == "leds":
value = int(raw)
vtype = "i"
elif key in ("port", "side", "capture", "drm_device"):
value = str(raw)
vtype = "s"
else:
value = float(raw)
vtype = "d"
try:
from gi.repository import GLib
res = call("SetParam", GLib.Variant("(sv)", (key, GLib.Variant(vtype, value))))
res = call("SetParam", GLib.Variant("(sv)", (key, GLib.Variant(
"b" if isinstance(value, bool) else "d" if isinstance(value, float) else "i",
value))))
err = res.unpack()[0]
if err:
print(json.dumps({"ok": False, "error": err}))
+4 -6
View File
@@ -3,17 +3,15 @@ Description=Ambilight: striscia LED sincronizzata con il bordo dello schermo
Documentation=https://git.enne2.net/enne2/ambilight-plasmoid
After=graphical-session.target
PartOf=graphical-session.target
StartLimitIntervalSec=120
StartLimitBurst=3
[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=10
[Install]
WantedBy=graphical-session.target
+42 -39
View File
@@ -19,7 +19,6 @@ Config: ~/.config/ambilight/config.json
"""
import json
import os
import sys
import threading
import time
@@ -36,6 +35,9 @@ DEFAULTS = {
"brightness": 1.0, # moltiplicatore 0.05 .. 2.0
"contrast": 1.0, # 0.5 .. 2.0 (pivot sul grigio 128)
"saturation": 1.0, # 0.0 .. 2.0
"gain_r": 1.0, # bilanciamento del bianco per canale
"gain_g": 1.0,
"gain_b": 1.0,
"band": 0.12, # frazione di altezza campionata sul bordo
"reverse": True, # LED 0 sul lato destro dello schermo (misurato su frigate)
"leds": 47,
@@ -43,14 +45,15 @@ 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 = {
"brightness": (0.05, 2.0),
"contrast": (0.5, 2.0),
"saturation": (0.0, 2.0),
"gain_r": (0.0, 2.0),
"gain_g": (0.0, 2.0),
"gain_b": (0.0, 2.0),
"band": (0.02, 0.5),
}
@@ -163,6 +166,7 @@ class State:
with self.lock:
d = dict(self.cfg)
d["running"] = self.running
d["target_fps"] = d["fps"]
d["fps"] = round(self.fps, 1)
d["stream_error"] = self.stream_error
d["last_error"] = self.last_error
@@ -189,60 +193,40 @@ def apply_params(zones, cfg):
b = cfg["brightness"]
if b != 1.0:
z = z * b
z = z * np.array([cfg["gain_r"], cfg["gain_g"], cfg["gain_b"]])
return np.clip(z, 0, 255)
def capture_loop(state, strip):
"""Ciclo di cattura: sceglie il backend (portal o kmsgrab) e invia i frame."""
from ambicapture import ChildCapture, KmsgrabCapture
from ambicapture import Capture, screencast_stream
cap = None
key = None
leds_cfg = 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']}"
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')}"
last_write = 0.0
t_log = time.time()
n_none = 0
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
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 None or leds_cfg != cfg["leds"]:
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:
cap, desc = build(cfg)
key = want
log(f"cattura avviata ({cfg['capture']}): {desc} led={cfg['leds']}")
node, w, h = screencast_stream()
cap = Capture(node, cfg["leds"], fps=cfg["target_fps"])
cap.start()
leds_cfg = cfg["leds"]
log(f"cattura avviata: node={node} sorgente {w}x{h} led={cfg['leds']}")
with state.lock:
state.stream_error = ""
state.last_error = ""
@@ -255,11 +239,17 @@ def capture_loop(state, strip):
frame = cap.frame(200)
if frame is None:
n_none += 1
time.sleep(0.005)
if time.time() - t_log >= 10:
log(f"diagnostica: 10 s senza frame (timeout={n_none}), "
f"fps={state.fps} sent={sent}")
t_log, n_none = time.time(), 0
continue
rows = max(1, int(round(cap.gh * cfg["band"])))
gh, gw = frame.shape[0], frame.shape[1]
rows = min(max(1, int(round(gh * cfg["band"]))), gh)
rows = min(rows, gh)
band = frame[:rows] if cfg["side"] == "top" else frame[gh - rows:]
sub = gw // cfg["leds"]
if sub < 1:
@@ -274,7 +264,18 @@ def capture_loop(state, strip):
px.reverse()
state.strip_dev = strip.resolved
if strip.write(adalight_frame(px)):
# PACING: mai sopra ~60% della capacità della seriale. Il firmware Adalight fa
# SERIAL_FLUSH alla chiusura del frame: se il frame successivo e' già nel buffer
# ne perde l'header -> frame scartati e striscia nera, senza errori lato host.
frame_bits = (6 + 3 * cfg["leds"]) * 10
min_interval = max(1.0 / max(1, cfg["target_fps"]),
1.7 * frame_bits / max(9600, cfg["baud"]))
wait = min_interval - (time.time() - last_write)
if wait > 0:
time.sleep(wait)
ok = strip.write(adalight_frame(px))
if ok:
last_write = time.time()
sent += 1
now = time.time()
if now - t_win >= 1.0:
@@ -384,12 +385,14 @@ class DBusService:
value = bool(value)
elif key == "leds":
value = max(1, min(256, int(value)))
elif key in ("port", "side", "baud", "fps", "capture", "drm_device"):
elif key in ("port", "side", "baud", "fps"):
value = type(DEFAULTS[key])(value)
else:
return False, f"parametro sconosciuto: {key}"
self.state.update(**{key: value})
save_config(self.state.snapshot())
with self.state.lock:
config = dict(self.state.cfg)
save_config(config)
return True, ""
# --- metodi