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
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.log
|
||||||
@@ -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).
|
||||||
@@ -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)
|
||||||
Executable
+99
@@ -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))
|
||||||
@@ -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
|
||||||
Executable
+421
@@ -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"""<node>
|
||||||
|
<interface name="{IFACE}">
|
||||||
|
<method name="Start"/>
|
||||||
|
<method name="Stop"/>
|
||||||
|
<method name="Toggle"/>
|
||||||
|
<method name="Quit"/>
|
||||||
|
<method name="GetState"><arg type="s" direction="out"/></method>
|
||||||
|
<method name="SetParam">
|
||||||
|
<arg type="s" direction="in"/><arg type="v" direction="in"/>
|
||||||
|
<arg type="s" direction="out"/>
|
||||||
|
</method>
|
||||||
|
<signal name="Changed"/>
|
||||||
|
<property name="Running" type="b" access="read"/>
|
||||||
|
<property name="Brightness" type="d" access="readwrite"/>
|
||||||
|
<property name="Contrast" type="d" access="readwrite"/>
|
||||||
|
<property name="Saturation" type="d" access="readwrite"/>
|
||||||
|
<property name="Band" type="d" access="readwrite"/>
|
||||||
|
<property name="Reverse" type="b" access="readwrite"/>
|
||||||
|
<property name="Leds" type="i" access="readwrite"/>
|
||||||
|
<property name="Fps" type="d" access="read"/>
|
||||||
|
</interface>
|
||||||
|
</node>"""
|
||||||
|
|
||||||
|
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())
|
||||||
Executable
+74
@@ -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 <<EOF
|
||||||
|
|
||||||
|
Verifica:
|
||||||
|
systemctl --user status ambilight.service --no-pager
|
||||||
|
$BIN/ambilight-ctl state
|
||||||
|
|
||||||
|
Il plasmoide compare nel vassoio di sistema come "Ambilight" (icona TV).
|
||||||
|
Al primo avvio del demone il desktop chiede il permesso di condividere lo
|
||||||
|
schermo: concedilo una volta, viene ricordato.
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import org.kde.plasma.plasmoid
|
||||||
|
import org.kde.plasma.components 3.0 as PC3
|
||||||
|
import org.kde.plasma.plasma5support as P5Support
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
|
||||||
|
PlasmoidItem {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// Percorso dell'helper, sostituito da install.sh con il percorso assoluto
|
||||||
|
readonly property string ctl: "@CTL@"
|
||||||
|
|
||||||
|
property var st: ({ daemon: false, running: false, brightness: 1.0,
|
||||||
|
contrast: 1.0, saturation: 1.0, band: 0.12,
|
||||||
|
leds: 47, fps: 0.0, error: "" })
|
||||||
|
property bool busy: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
preferredRepresentation: compactRepresentation
|
||||||
|
toolTipMainText: "Ambilight"
|
||||||
|
toolTipSubText: {
|
||||||
|
if (!st.daemon)
|
||||||
|
return i18n("Demone non raggiungibile")
|
||||||
|
if (!st.running)
|
||||||
|
return i18n("Fermo")
|
||||||
|
return i18n("%1 LED · %2 fps", st.leds, Math.round(st.fps))
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
poll.connectSource(root.ctl + " state")
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(args) {
|
||||||
|
if (root.busy)
|
||||||
|
return
|
||||||
|
root.busy = true
|
||||||
|
exec.connectSource(root.ctl + " " + args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stato (polling)
|
||||||
|
P5Support.DataSource {
|
||||||
|
id: poll
|
||||||
|
engine: "executable"
|
||||||
|
interval: 2000
|
||||||
|
connectedSources: []
|
||||||
|
onNewData: function(source, data) {
|
||||||
|
poll.disconnectSource(source)
|
||||||
|
const out = String(data["stdout"] || "").trim()
|
||||||
|
if (!out)
|
||||||
|
return
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(out)
|
||||||
|
root.st = s
|
||||||
|
root.lastError = s.error || ""
|
||||||
|
} catch (e) {
|
||||||
|
root.lastError = "stato illeggibile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- comandi (uno alla volta)
|
||||||
|
P5Support.DataSource {
|
||||||
|
id: exec
|
||||||
|
engine: "executable"
|
||||||
|
interval: 0
|
||||||
|
connectedSources: []
|
||||||
|
onNewData: function(source, data) {
|
||||||
|
exec.disconnectSource(source)
|
||||||
|
root.busy = false
|
||||||
|
const out = String(data["stdout"] || "").trim()
|
||||||
|
if (out !== "") {
|
||||||
|
try {
|
||||||
|
const r = JSON.parse(out)
|
||||||
|
root.lastError = (r.ok === false) ? (r.error || "errore") : ""
|
||||||
|
if (r.state)
|
||||||
|
root.st = r.state
|
||||||
|
} catch (e) {
|
||||||
|
root.lastError = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- icona nel vassoio
|
||||||
|
compactRepresentation: Item {
|
||||||
|
implicitWidth: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
implicitHeight: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
Layout.minimumWidth: implicitWidth
|
||||||
|
Layout.minimumHeight: implicitHeight
|
||||||
|
Layout.preferredWidth: implicitWidth
|
||||||
|
Layout.preferredHeight: implicitHeight
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: compactMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
acceptedButtons: Qt.LeftButton
|
||||||
|
onClicked: root.expanded = !root.expanded
|
||||||
|
onContainsMouseChanged: {
|
||||||
|
if (containsMouse)
|
||||||
|
root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
Kirigami.Icon {
|
||||||
|
anchors.fill: parent
|
||||||
|
source: "video-television"
|
||||||
|
active: compactMouse.containsMouse
|
||||||
|
opacity: root.st.running ? 1.0 : 0.45
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- popup
|
||||||
|
fullRepresentation: Item {
|
||||||
|
implicitWidth: Kirigami.Units.gridUnit * 17
|
||||||
|
implicitHeight: col.implicitHeight + 2 * Kirigami.Units.largeSpacing
|
||||||
|
Layout.preferredWidth: implicitWidth
|
||||||
|
Layout.preferredHeight: implicitHeight
|
||||||
|
Layout.minimumWidth: Kirigami.Units.gridUnit * 15
|
||||||
|
Layout.minimumHeight: implicitHeight
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: col
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: Kirigami.Units.largeSpacing
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
Kirigami.Icon {
|
||||||
|
source: "video-television"
|
||||||
|
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
}
|
||||||
|
PC3.Label {
|
||||||
|
text: i18n("Ambilight")
|
||||||
|
font.bold: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
PC3.Switch {
|
||||||
|
id: powerSwitch
|
||||||
|
checked: root.st.running
|
||||||
|
enabled: root.st.daemon
|
||||||
|
onToggled: root.run(powerSwitch.checked ? "start" : "stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PC3.Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: !root.st.daemon
|
||||||
|
? i18n("Demone non attivo")
|
||||||
|
: (root.st.running
|
||||||
|
? i18n("%1 LED · %2 fps · banda %3%",
|
||||||
|
root.st.leds, Math.round(root.st.fps),
|
||||||
|
Math.round(root.st.band * 100))
|
||||||
|
: i18n("Spento · %1 LED", root.st.leds))
|
||||||
|
opacity: 0.75
|
||||||
|
font: Kirigami.Theme.smallFont
|
||||||
|
color: root.st.daemon ? Kirigami.Theme.textColor : Kirigami.Theme.negativeTextColor
|
||||||
|
}
|
||||||
|
|
||||||
|
component ParamRow: RowLayout {
|
||||||
|
id: row
|
||||||
|
|
||||||
|
property string label
|
||||||
|
property real from: 0
|
||||||
|
property real to: 200
|
||||||
|
property real current: 100
|
||||||
|
property bool percent: true
|
||||||
|
property bool active: true
|
||||||
|
signal applied(real v)
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
PC3.Label {
|
||||||
|
text: row.label
|
||||||
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 4
|
||||||
|
font: Kirigami.Theme.smallFont
|
||||||
|
}
|
||||||
|
PC3.Slider {
|
||||||
|
id: sl
|
||||||
|
Layout.fillWidth: true
|
||||||
|
from: row.from
|
||||||
|
to: row.to
|
||||||
|
stepSize: 1
|
||||||
|
enabled: row.active
|
||||||
|
value: row.current
|
||||||
|
onMoved: row.applied(value)
|
||||||
|
}
|
||||||
|
PC3.Label {
|
||||||
|
text: Math.round(sl.value) + (row.percent ? "%" : "")
|
||||||
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 2.2
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
font: Kirigami.Theme.smallFont
|
||||||
|
opacity: 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ParamRow {
|
||||||
|
label: i18n("Luminosità")
|
||||||
|
from: 5
|
||||||
|
to: 200
|
||||||
|
active: root.st.daemon
|
||||||
|
current: root.st.brightness * 100
|
||||||
|
onApplied: function(v) { root.run("set brightness " + (v / 100.0).toFixed(2)) }
|
||||||
|
}
|
||||||
|
ParamRow {
|
||||||
|
label: i18n("Contrasto")
|
||||||
|
from: 50
|
||||||
|
to: 200
|
||||||
|
active: root.st.daemon
|
||||||
|
current: root.st.contrast * 100
|
||||||
|
onApplied: function(v) { root.run("set contrast " + (v / 100.0).toFixed(2)) }
|
||||||
|
}
|
||||||
|
ParamRow {
|
||||||
|
label: i18n("Saturazione")
|
||||||
|
from: 0
|
||||||
|
to: 200
|
||||||
|
active: root.st.daemon
|
||||||
|
current: root.st.saturation * 100
|
||||||
|
onApplied: function(v) { root.run("set saturation " + (v / 100.0).toFixed(2)) }
|
||||||
|
}
|
||||||
|
ParamRow {
|
||||||
|
label: i18n("Banda")
|
||||||
|
from: 2
|
||||||
|
to: 50
|
||||||
|
active: root.st.daemon
|
||||||
|
current: root.st.band * 100
|
||||||
|
onApplied: function(v) { root.run("set band " + (v / 100.0).toFixed(2)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
PC3.Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: root.lastError !== ""
|
||||||
|
text: root.lastError
|
||||||
|
color: Kirigami.Theme.negativeTextColor
|
||||||
|
font: Kirigami.Theme.smallFont
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
PC3.Button {
|
||||||
|
text: i18n("Riavvia demone")
|
||||||
|
font: Kirigami.Theme.smallFont
|
||||||
|
onClicked: {
|
||||||
|
root.busy = true
|
||||||
|
restartSource.connectSource("systemctl --user restart ambilight.service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
PC3.Button {
|
||||||
|
text: i18n("Aggiorna")
|
||||||
|
font: Kirigami.Theme.smallFont
|
||||||
|
onClicked: root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sorgente dedicata al riavvio: nessun ritorno JSON, quindi handler separato
|
||||||
|
P5Support.DataSource {
|
||||||
|
id: restartSource
|
||||||
|
engine: "executable"
|
||||||
|
interval: 0
|
||||||
|
connectedSources: []
|
||||||
|
onNewData: function(source, data) {
|
||||||
|
restartSource.disconnectSource(source)
|
||||||
|
root.busy = false
|
||||||
|
root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"KPlugin": {
|
||||||
|
"Id": "org.enne2.ambilight",
|
||||||
|
"Name": "Ambilight",
|
||||||
|
"Description": "Controlla la striscia LED del bordo superiore (ESP8266 + Adalight)",
|
||||||
|
"Icon": "video-television",
|
||||||
|
"Version": "1.0.0",
|
||||||
|
"License": "GPL-3.0-or-later",
|
||||||
|
"Category": "Utilities",
|
||||||
|
"EnabledByDefault": true,
|
||||||
|
"Authors": [
|
||||||
|
{
|
||||||
|
"Name": "enne2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"KPackageStructure": "Plasma/Applet",
|
||||||
|
"X-Plasma-API-Minimum-Version": "6.0",
|
||||||
|
"X-Plasma-NotificationArea": "true",
|
||||||
|
"X-Plasma-NotificationAreaCategory": "ApplicationStatus"
|
||||||
|
}
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rimuove servizio, backend e plasmoide. La configurazione utente resta in
|
||||||
|
# ~/.config/ambilight (cancellala a mano se non ti serve piu').
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
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' "$*"; }
|
||||||
|
|
||||||
|
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"
|
||||||
Reference in New Issue
Block a user