#!/usr/bin/env python3 """ambilightd — demone dell'ambilight (ESP8266 + striscia WS2812B via Adalight). Cattura lo schermo su Wayland (ScreenCast portal + PipeWire), calcola il colore medio delle zone lungo il bordo scelto, applica luminosita'/contrasto/saturazione e invia i frame sulla seriale con il protocollo Adalight. Espone un servizio D-Bus di sessione: nome: org.enne2.Ambilight oggetto: /org/enne2/Ambilight interfaccia: org.enne2.Ambilight metodi: Start(), Stop(), GetState() -> s (JSON), SetParam(s,s), Quit() proprieta': Running, Brightness, Contrast, Saturation, Band, Reverse, Leds, Fps segnale: Changed() Config: ~/.config/ambilight/config.json """ import json import os import threading import time import numpy as np APP = "ambilightd" BUS_NAME = "org.enne2.Ambilight" OBJ_PATH = "/org/enne2/Ambilight" IFACE = "org.enne2.Ambilight" CONFIG_DIR = os.path.expanduser("~/.config/ambilight") CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") 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, "port": "/dev/ttyUSB0", "baud": 115200, "fps": 60, "side": "top", # top | bottom "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), } def log(*a): print(time.strftime("%H:%M:%S"), *a, flush=True) # ------------------------------------------------------------------ config def load_config(): cfg = dict(DEFAULTS) try: with open(CONFIG_FILE) as f: cfg.update(json.load(f)) except FileNotFoundError: pass except Exception as e: log("config illeggibile, uso i default:", e) return cfg def save_config(cfg): """Salva solo le chiavi di configurazione (niente fps/stato transitorio).""" os.makedirs(CONFIG_DIR, exist_ok=True) data = {k: cfg[k] for k in DEFAULTS if k in cfg} tmp = CONFIG_FILE + ".tmp" with open(tmp, "w") as f: json.dump(data, f, indent=2) os.replace(tmp, CONFIG_FILE) # ------------------------------------------------------------------ adalight def adalight_frame(pixels): count = len(pixels) - 1 hi, lo = (count >> 8) & 0xFF, count & 0xFF return b"Ada" + bytes((hi, lo, hi ^ lo ^ 0x55)) + bytes(int(c) for px in pixels for c in px) class Strip: """Porta seriale con apertura pigra, nome stabile e riconnessione. Il percorso configurato ha la precedenza; se non esiste si prova il symlink udev /dev/ambilight e infine /dev/ttyUSB0 (primo disponibile). """ def __init__(self, port, baud): self.port, self.baud = port, baud self.ser = None self.resolved = port self.error = "" def _candidates(self): seen = [] for p in (self.port, "/dev/ambilight", "/dev/ttyUSB0"): if p and p not in seen: seen.append(p) return [p for p in seen if os.path.exists(p)] def _open(self): import serial cands = self._candidates() if not cands: raise RuntimeError(f"nessun dispositivo seriale fra {self.port}, /dev/ambilight, /dev/ttyUSB0") self.resolved = cands[0] self.ser = serial.Serial(self.resolved, self.baud, timeout=0.2, write_timeout=0.2) try: self.ser.setDTR(False) self.ser.setRTS(False) except Exception: pass def write(self, frame): try: if self.ser is None: self._open() self.error = "" self.ser.write(frame) return True except Exception as e: self.error = f"{type(e).__name__}: {e}" try: if self.ser is not None: self.ser.close() except Exception: pass self.ser = None return False def close(self): try: if self.ser is not None: self.ser.close() except Exception: pass self.ser = None # ------------------------------------------------------------------ stato class State: def __init__(self, cfg): self.lock = threading.Lock() self.cfg = cfg self.strip_dev = cfg.get("port", "") self.running = bool(cfg.get("auto_start", True)) self.fps = 0.0 self.stream_error = "" self.last_error = "" def snapshot(self): 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 d["dev"] = getattr(self, "strip_dev", "") return d def update(self, **kw): with self.lock: self.cfg.update(kw) return dict(self.cfg) # ------------------------------------------------------------------ cattura def apply_params(zones, cfg): """zones: (led, 3) float. Applica saturazione, contrasto, luminosita', gamma.""" z = zones sat = cfg["saturation"] if sat != 1.0: m = z.mean(axis=1, keepdims=True) z = m + (z - m) * sat c = cfg["contrast"] if c != 1.0: z = (z - 128.0) * c + 128.0 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): from ambicapture import Capture, screencast_stream cap = None leds_cfg = None prev = None sent = 0 t_win = time.time() last_write = 0.0 t_log = time.time() n_none = 0 last_frame = None while True: cfg = state.snapshot() if not cfg["running"]: if cap is not None: cap.stop() cap = None prev = None last_frame = None time.sleep(0.15) continue if cap is None or leds_cfg != cfg["leds"]: if cap is not None: cap.stop() try: 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 = "" except Exception as e: with state.lock: state.stream_error = f"{type(e).__name__}: {e}" log("cattura non disponibile:", e) time.sleep(5) continue frame = cap.frame(200) if frame is None: # Una sorgente a frequenza variabile può fermarsi su una schermata # statica; rinnova il frame per il timeout del firmware Adalight. if last_frame is not None and time.monotonic() - last_write >= 10: if strip.write(last_frame): last_write = time.monotonic() n_none += 1 time.sleep(0.005) if time.time() - t_log >= 10: log(f"diagnostica: attese cattura negli ultimi 10 s (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(rows, gh) band = frame[:rows] if cfg["side"] == "top" else frame[gh - rows:] sub = gw // cfg["leds"] if sub < 1: continue zones = band.reshape(rows, cfg["leds"], sub, 3).mean(axis=(0, 2)) if prev is not None: zones = zones * 0.65 + prev * 0.35 prev = zones colors = apply_params(zones, cfg) px = [tuple(int(v) for v in c) for c in colors] if cfg["reverse"]: px.reverse() state.strip_dev = strip.resolved # 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.monotonic() - last_write) if wait > 0: time.sleep(wait) last_frame = adalight_frame(px) ok = strip.write(last_frame) if ok: last_write = time.monotonic() sent += 1 now = time.time() if now - t_win >= 1.0: with state.lock: state.fps = sent / (now - t_win) sent, t_win = 0, now else: with state.lock: state.last_error = strip.error time.sleep(0.5) # ------------------------------------------------------------------ D-Bus INTROSPECTION = f""" """ PROPS = ["Running", "Brightness", "Contrast", "Saturation", "Band", "Reverse", "Leds", "Fps"] class DBusService: def __init__(self, state, strip): from gi.repository import Gio, GLib self.Gio, self.GLib = Gio, GLib self.state, self.strip = state, strip node = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION) self.info = node.interfaces[0] self.conn = Gio.bus_get_sync(Gio.BusType.SESSION, None) self.loop = GLib.MainLoop() self.reg_id = self.conn.register_object(OBJ_PATH, self.info, self.on_call, self.on_get, self.on_set) Gio.bus_own_name_on_connection(self.conn, BUS_NAME, Gio.BusNameOwnerFlags.NONE, self.on_acquired, None) # --- helpers def _prop_value(self, name): snap = self.state.snapshot() table = { "Running": snap["running"], "Brightness": float(snap["brightness"]), "Contrast": float(snap["contrast"]), "Saturation": float(snap["saturation"]), "Band": float(snap["band"]), "Reverse": bool(snap["reverse"]), "Leds": int(snap["leds"]), "Fps": float(snap["fps"]), } return table[name] def _emit_changed(self, props=None): try: self.conn.emit_signal(None, OBJ_PATH, IFACE, "Changed", None) if props: changed = {} for p in props: changed[p] = self._variant(p) self.conn.emit_signal( None, OBJ_PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged", self.GLib.Variant("(sa{sv}as)", (IFACE, changed, []))) except Exception as e: log("emit fallita:", e) def _variant(self, name): v = self._prop_value(name) return self.GLib.Variant("d" if isinstance(v, float) else "b" if isinstance(v, bool) else "i", v) # --- Properties def on_get(self, conn, sender, path, iface, prop): if prop not in PROPS: return None return self._variant(prop) def on_set(self, conn, sender, path, iface, prop, value): ok, msg = self.set_param(prop.lower(), value.unpack()) if not ok: return False self._emit_changed([prop]) return True def set_param(self, key, value): if key in PARAM_RANGES: lo, hi = PARAM_RANGES[key] value = min(hi, max(lo, float(value))) elif key == "reverse": value = bool(value) elif key == "leds": value = max(1, min(256, int(value))) elif key in ("port", "side", "baud", "fps"): value = type(DEFAULTS[key])(value) else: return False, f"parametro sconosciuto: {key}" self.state.update(**{key: value}) with self.state.lock: config = dict(self.state.cfg) save_config(config) return True, "" # --- metodi def on_call(self, conn, sender, path, iface, method, params, invocation): try: if method == "Start": self.state.running = True invocation.return_value(None) self._emit_changed(["Running"]) elif method == "Stop": self.state.running = False self.strip.write(adalight_frame([(0, 0, 0)] * self.state.cfg["leds"])) invocation.return_value(None) self._emit_changed(["Running"]) elif method == "Toggle": self.state.running = not self.state.running if not self.state.running: self.strip.write(adalight_frame([(0, 0, 0)] * self.state.cfg["leds"])) invocation.return_value(None) self._emit_changed(["Running"]) elif method == "GetState": invocation.return_value(self.GLib.Variant("(s)", (json.dumps(self.state.snapshot()),))) elif method == "SetParam": key, val = params.unpack() ok, msg = self.set_param(str(key), val) if ok: self._emit_changed() invocation.return_value(self.GLib.Variant("(s)", ("" if ok else msg,))) elif method == "Quit": invocation.return_value(None) self.GLib.timeout_add(100, self.quit) else: invocation.return_dbus_error("org.enne2.Ambilight.UnknownMethod", method) except Exception as e: log("errore metodo", method, e) invocation.return_value(self.GLib.Variant("(s)", (str(e),))) def on_acquired(self, conn, name, *a): log(f"servizio D-Bus attivo: {name}") def quit(self): self.state.running = False self.strip.close() self.loop.quit() return False def run(self): log("in ascolto su", OBJ_PATH) self.loop.run() def main(): cfg = load_config() state = State(cfg) strip = Strip(cfg["port"], cfg["baud"]) state.strip_dev = strip.resolved svc = DBusService(state, strip) th = threading.Thread(target=capture_loop, args=(state, strip), daemon=True) th.start() try: svc.run() except KeyboardInterrupt: pass finally: state.running = False try: strip.write(adalight_frame([(0, 0, 0)] * cfg["leds"])) except Exception: pass strip.close() return 0 if __name__ == "__main__": raise SystemExit(main())