commit a3b6bc66c9f5a2ff277574f44396d4861c26e0f7 Author: enne2 Date: Sun Nov 1 13:11:02 2026 +0100 Ambilight per Plasma 6: demone Adalight + plasmoide con slider - backend/ambicapture.py: cattura Wayland via ScreenCast portal + PipeWire - backend/ambilightd.py: demone, 47 zone sul bordo superiore, luminosita/contrasto/ saturazione/banda, invio Adalight su seriale, servizio D-Bus org.enne2.Ambilight - backend/ambilight-ctl: CLI JSON per il plasmoide - backend/ambilight.service: avvio automatico (systemd --user) - package/org.enne2.ambilight: applet vassoio con popup e slider - install.sh / uninstall.sh senza root diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b11a7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab0935d --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# ambilight-plasmoid + +Ambilight per KDE Plasma 6: cattura il bordo superiore dello schermo su **Wayland** +(portal ScreenCast + PipeWire), calcola il colore medio di 47 zone e pilota una +striscia **WS2812B** collegata a un **ESP8266 (Wemos D1 mini)** con firmware +**Adalight** via seriale. Il controllo sta in un **plasmoide nel vassoio di sistema**, +con popup per luminosità, contrasto, saturazione e banda campionata. + +``` +schermo ──portal──▶ ambilightd ──Adalight/seriale──▶ ESP8266 ──▶ striscia + ▲ + │ D-Bus org.enne2.Ambilight + │ + ambilight-ctl ◀── plasmoide (Plasma 6, QML) +``` + +## Componenti + +| File | Ruolo | +|---|---| +| `backend/ambicapture.py` | negoziazione ScreenCast portal + pipeline GStreamer (`pipewiresrc`) | +| `backend/ambilightd.py` | demone: cattura, calcolo zone, luminosità/contrasto/saturazione, invio Adalight, servizio D-Bus | +| `backend/ambilight-ctl` | CLI che parla col demone e stampa JSON (usata dal plasmoide) | +| `backend/ambilight.service` | unit `systemd --user` (avvio automatico al login) | +| `package/org.enne2.ambilight/` | plasmoide Plasma 6 (icona nel vassoio + popup con slider) | +| `install.sh` / `uninstall.sh` | installazione e rimozione, senza root | + +## Hardware + +- ESP8266 Wemos D1 mini (clone), firmware **Adalight-FastLED** con `PIN_DATA 5` (= D1/GPIO5), + `Num_Leds` = dimensione buffer (256): il numero reale di LED lo decide il software che invia i frame. +- Striscia WS2812B **47 LED** sul solo bordo superiore. +- Cablaggio: `D1 → 470 Ω → DIN`, alimentatore 5 V separato (~60 mA/LED), **massa comune**, + 1000 µF sull'alimentazione; level shifter 74AHCT125 se il filo dati è lungo. + +## Installazione + +```sh +./install.sh # backend + servizio utente + plasmoide +``` + +Al primo avvio il desktop chiede il permesso di **condividere lo schermo**: va concesso +una volta (viene memorizzato in `~/.cache/ambilight/restore_token`). + +Il plasmoide appare nel vassoio come **Ambilight** (icona TV); clic per il popup. + +## Uso da terminale + +```sh +ambilight-ctl state # JSON con tutto lo stato +ambilight-ctl start | stop | toggle +ambilight-ctl set brightness 0.8 # brightness|contrast|saturation|band|reverse|leds +ambilight-ctl get fps +systemctl --user status ambilight.service +``` + +## Configurazione + +`~/.config/ambilight/config.json` (scritto dal demone a ogni modifica): + +| chiave | range | default | note | +|---|---|---|---| +| `brightness` | 0.05 – 2.0 | 1.0 | moltiplicatore | +| `contrast` | 0.5 – 2.0 | 1.0 | pivot sul grigio 128 | +| `saturation` | 0.0 – 2.0 | 1.0 | 0 = bianco e nero | +| `band` | 0.02 – 0.5 | 0.12 | frazione di altezza campionata | +| `reverse` | bool | true | LED 0 sul lato destro (verificato a schermo) | +| `leds` | 1 – 256 | 47 | LED della striscia | +| `port`, `baud` | – | `/dev/ttyUSB0`, 115200 | seriale | +| `side` | top/bottom | top | bordo campionato | +| `auto_start` | bool | true | streaming attivo all'avvio del demone | + +## Prestazioni misurate + +- 47 LED, Adalight a 115200: ~**78 fps** (limite teorico della seriale per 147 byte/frame). +- Cattura: portal + PipeWire, pipeline ridotta a `47×4 × 72` px RGB → costo CPU trascurabile. + +## Note + +- Il firmware Adalight manda `Ada\n` una volta al secondo quando non riceve frame: è + l'heartbeat del protocollo, non un errore. +- Il demone tiene la seriale in esclusiva: Prismatik (o altri programmi Adalight) vanno chiusi. +- `ModemManager` su Fedora può contendere `/dev/ttyUSB0`: se la porta risulta occupata + serve una regola udev con `ID_MM_DEVICE_IGNORE`. + +## Licenza + +GPL-3.0-or-later (come i componenti KDE con cui interagisce). diff --git a/backend/ambicapture.py b/backend/ambicapture.py new file mode 100644 index 0000000..f2b92ec --- /dev/null +++ b/backend/ambicapture.py @@ -0,0 +1,115 @@ +"""Cattura dello schermo per ambilight su Wayland/KDE (ScreenCast portal + PipeWire). + +Restituisce i colori medi di N zone lungo un bordo dello schermo. +Nessuna dipendenza da X11: usa xdg-desktop-portal (consenso una volta sola, +token persistente) e GStreamer con pipewiresrc. +""" +import os + +CACHE_DIR = os.path.expanduser("~/.cache/ambilight") +PORTAL = "org.freedesktop.portal.Desktop" +PPATH = "/org/freedesktop/portal/desktop" + + +def screencast_stream(cache_dir=CACHE_DIR): + """Negozia la condivisione del monitor; ritorna (node_id, width, height).""" + import dbus + from dbus.mainloop.glib import DBusGMainLoop + from gi.repository import GLib + + DBusGMainLoop(set_as_default=True) + bus = dbus.SessionBus() + sc = dbus.Interface(bus.get_object(PORTAL, PPATH), "org.freedesktop.portal.ScreenCast") + sender = bus.get_unique_name()[1:].replace(".", "_") + counter = [0] + + def request(fn, *args, **opts): + counter[0] += 1 + token = f"ambi{os.getpid()}{counter[0]}" + path = f"{PPATH}/request/{sender}/{token}" + iface = dbus.Interface(bus.get_object(PORTAL, path, introspect=False), + "org.freedesktop.portal.Request") + box = {} + loop = GLib.MainLoop() + + def on_response(code, results): + box["code"], box["results"] = code, results + loop.quit() + + iface.connect_to_signal("Response", on_response) + fn(*args, dbus.Dictionary({"handle_token": token, **opts}, signature="sv")) + GLib.timeout_add_seconds(180, lambda: (loop.quit(), False)[1]) + loop.run() + if box.get("code") != 0: + raise RuntimeError("condivisione schermo annullata o rifiutata") + return box["results"] + + res = request(sc.CreateSession, session_handle_token="ambilight") + session = str(res["session_handle"]) + + token_file = os.path.join(cache_dir, "restore_token") + opts = {} + if os.path.exists(token_file): + tok = open(token_file).read().strip() + if tok: + opts["restore_token"] = tok + + request(sc.SelectSources, session, types=dbus.UInt32(1), multiple=False, + cursor_mode=dbus.UInt32(1), persist_mode=dbus.UInt32(2), **opts) + res = request(sc.Start, session, "") + + node = int(res["streams"][0][0]) + size = [int(v) for v in res["streams"][0][1].get("size", [0, 0])] + if "restore_token" in res: + os.makedirs(cache_dir, exist_ok=True) + with open(token_file, "w") as f: + f.write(str(res["restore_token"])) + return node, size[0], size[1] + + +class Capture: + """Pipeline GStreamer ridotta a (led*sub) x gh pixel RGB, pronta per il calcolo zone.""" + + def __init__(self, node, leds, sub=4, rows=72, fps=60): + import gi + gi.require_version("Gst", "1.0") + gi.require_version("GstApp", "1.0") + from gi.repository import Gst, GstApp # noqa: F401 (registra il tipo AppSink) + Gst.init(None) + self.gw = leds * sub + self.gh = rows + desc = (f"pipewiresrc path={node} do-timestamp=true ! " + f"videoconvert ! videoscale ! videorate ! " + f"video/x-raw,format=RGB,width={self.gw},height={self.gh}," + f"framerate={int(fps)}/1 ! " + f"appsink name=sink emit-signals=false max-buffers=2 drop=true sync=false") + self.pipe = Gst.parse_launch(desc) + self.sink = self.pipe.get_by_name("sink") + + def start(self): + from gi.repository import Gst + self.pipe.set_state(Gst.State.PLAYING) + + def stop(self): + from gi.repository import Gst + self.pipe.set_state(Gst.State.NULL) + + def frame(self, timeout_ms=200): + """Ritorna un array numpy (gh, gw, 3) uint8, oppure None.""" + import numpy as np + from gi.repository import Gst + sample = self.sink.try_pull_sample(timeout_ms * Gst.MSECOND) + if sample is None: + return None + buf = sample.get_buffer() + ok, info = buf.map(Gst.MapFlags.READ) + if not ok: + return None + try: + need = self.gw * self.gh * 3 + data = bytes(info.data)[:need] + finally: + buf.unmap(info) + if len(data) < need: + return None + return np.frombuffer(data, dtype=np.uint8).reshape(self.gh, self.gw, 3) diff --git a/backend/ambilight-ctl b/backend/ambilight-ctl new file mode 100755 index 0000000..cbab1af --- /dev/null +++ b/backend/ambilight-ctl @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""ambilight-ctl — interfaccia a riga di comando per il demone ambilightd. + +Parla con il demone via D-Bus e stampa JSON, cosi' il plasmoide puo' usarla +attraverso il DataSource "executable" di Plasma. + + ambilight-ctl state # JSON con tutto lo stato + ambilight-ctl start | stop | toggle + ambilight-ctl set brightness 0.8 # brightness|contrast|saturation|band|reverse|leds + ambilight-ctl get contrast +""" +import json +import sys + +BUS_NAME = "org.enne2.Ambilight" +OBJ_PATH = "/org/enne2/Ambilight" +IFACE = "org.enne2.Ambilight" + +FALLBACK = {"daemon": False, "running": False, "brightness": 1.0, "contrast": 1.0, + "saturation": 1.0, "band": 0.12, "reverse": True, "leds": 47, "fps": 0.0, + "error": "demone non raggiungibile"} + + +def proxy(): + from gi.repository import Gio, GLib + return Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, + BUS_NAME, OBJ_PATH, IFACE, None) + + +def call(method, args=None): + from gi.repository import Gio + return proxy().call_sync(method, args, Gio.DBusCallFlags.NONE, 1500, None) + + +def state(): + try: + res = call("GetState") + data = json.loads(res.unpack()[0] if isinstance(res.unpack(), tuple) else res.unpack()) + data["daemon"] = True + return data + except Exception as e: + d = dict(FALLBACK) + d["error"] = f"{type(e).__name__}: {e}".strip() + return d + + +def main(argv): + cmd = argv[1] if len(argv) > 1 else "state" + + if cmd == "state": + d = state() + print(json.dumps(d)) + return 0 if d.get("daemon") else 1 + + if cmd in ("start", "stop", "toggle"): + try: + call({"start": "Start", "stop": "Stop", "toggle": "Toggle"}[cmd]) + except Exception as e: + print(json.dumps({"ok": False, "error": str(e)})) + return 1 + print(json.dumps({"ok": True, "state": state()})) + return 0 + + if cmd == "set" and len(argv) >= 4: + key, raw = argv[2], argv[3] + if key in ("reverse",): + value = raw.lower() in ("1", "true", "yes", "on") + elif key == "leds": + value = int(raw) + else: + value = float(raw) + try: + from gi.repository import GLib + 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})) + return 1 + except Exception as e: + print(json.dumps({"ok": False, "error": str(e)})) + return 1 + print(json.dumps({"ok": True, "state": state()})) + return 0 + + if cmd == "get" and len(argv) >= 3: + d = state() + key = argv[2] + print(d.get(key, "")) + return 0 if d.get("daemon") else 1 + + print(__doc__) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/backend/ambilight.service b/backend/ambilight.service new file mode 100644 index 0000000..b97caa0 --- /dev/null +++ b/backend/ambilight.service @@ -0,0 +1,15 @@ +[Unit] +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 + +[Service] +Type=simple +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 new file mode 100755 index 0000000..969aed1 --- /dev/null +++ b/backend/ambilightd.py @@ -0,0 +1,421 @@ +#!/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 + "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), + "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 e riconnessione.""" + + def __init__(self, port, baud): + self.port, self.baud = port, baud + self.ser = None + self.error = "" + + def _open(self): + import serial + self.ser = serial.Serial(self.port, 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.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["fps"] = round(self.fps, 1) + d["stream_error"] = self.stream_error + d["last_error"] = self.last_error + 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 + 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() + while True: + cfg = state.snapshot() + if not cfg["running"]: + if cap is not None: + cap.stop() + cap = None + prev = 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["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: + 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) + 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() + + if strip.write(adalight_frame(px)): + 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}) + save_config(self.state.snapshot()) + 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"]) + 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()) diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..c70340a --- /dev/null +++ b/install.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Installa ambilightd + ambilight-ctl + il plasmoide + il servizio utente. +# Nessun privilegio di root: tutto in ~/.local e ~/.config. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BIN="${XDG_BIN_HOME:-$HOME/.local/bin}" +SYSDIR="$HOME/.config/systemd/user" +APPLET_ID="org.enne2.ambilight" + +say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*"; } + +# ---------------------------------------------------------------- dipendenze +say "Controllo dipendenze Python" +missing=0 +check() { + if ! python3 -c "$1" >/dev/null 2>&1; then + warn "manca: $2" + missing=1 + fi +} +check "import gi; gi.require_version('Gio','2.0'); gi.require_version('GLib','2.0')" \ + "python3-gobject (Gio/GLib)" +check "import gi; gi.require_version('Gst','1.0'); gi.require_version('GstApp','1.0')" \ + "GStreamer + python3-gobject (Gst, GstApp)" +check "import dbus" "python3-dbus" +check "import serial" "python3-pyserial" +check "import numpy" "python3-numpy" +if ! gst-inspect-1.0 pipewiresrc >/dev/null 2>&1; then + warn "manca: gstreamer1-plugin-pipewire (pipewiresrc)" + missing=1 +fi +if [ "$missing" = 1 ]; then + warn "Installa i pacchetti mancanti (es. sudo dnf install python3-gobject python3-dbus python3-pyserial python3-numpy gstreamer1-plugin-pipewire) e rilancia." + exit 1 +fi + +# ---------------------------------------------------------------- file +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/ambilight-ctl" "$BIN/ambilight-ctl" + +say "Installo il servizio utente" +install -d "$SYSDIR" +install -m 644 "$HERE/backend/ambilight.service" "$SYSDIR/ambilight.service" +systemctl --user daemon-reload +systemctl --user enable --now ambilight.service + +# ---------------------------------------------------------------- plasmoide +say "Installo il plasmoide $APPLET_ID" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +cp -r "$HERE/package/$APPLET_ID" "$TMP/" +sed -i "s|@CTL@|$BIN/ambilight-ctl|g" "$TMP/$APPLET_ID/contents/ui/main.qml" +if kpackagetool6 --type Plasma/Applet --show "$APPLET_ID" >/dev/null 2>&1; then + kpackagetool6 --type Plasma/Applet --upgrade "$TMP/$APPLET_ID" +else + kpackagetool6 --type Plasma/Applet --install "$TMP/$APPLET_ID" +fi + +say "Fatto." +cat <\033[0m %s\n' "$*"; } + +say "Fermo e disabilito il servizio" +systemctl --user disable --now ambilight.service 2>/dev/null || true +rm -f "$SYSDIR/ambilight.service" +systemctl --user daemon-reload + +say "Rimuovo il plasmoide" +kpackagetool6 --type Plasma/Applet --remove "$APPLET_ID" 2>/dev/null || true + +say "Rimuovo il backend" +rm -f "$BIN/ambilightd.py" "$BIN/ambicapture.py" "$BIN/ambilight-ctl" + +say "Fatto. Configurazione lasciata in ~/.config/ambilight"