ソースを参照

Initial Commit

ArtyomV2X 1 ヶ月 前
コミット
b2a6f60849
8 ファイル変更2543 行追加0 行削除
  1. 131 0
      README.md
  2. 13 0
      firmware/boot.py
  3. 440 0
      firmware/code.py
  4. 1342 0
      host/luxstats_configurator.py
  5. 3 0
      host/requirements.txt
  6. 482 0
      host/system_monitor.py
  7. 110 0
      luxstats.sh
  8. 22 0
      packaging/luxstats.service

+ 131 - 0
README.md

@@ -0,0 +1,131 @@
+# LuxStats
+
+Hardware/software status LEDs for your desk: a Pimoroni **Tiny 2040** drives a
+NeoPixel strip while a **PyQt5 configurator applet** owns the configuration,
+maps LED zones to live statuses, and pushes every change to the board
+instantly. The board is treated as read-only: config lives on the host and is
+re-pushed on connect.
+
+```
+luxstats/
+├── firmware/
+│   ├── boot.py     # enables the second USB CDC (data) serial channel
+│   └── code.py     # LuxStats firmware v2 (CircuitPython, RAM-only config)
+├── host/
+│   ├── luxstats_configurator.py   # PyQt5 applet
+│   ├── system_monitor.py          # status probes + notification watcher
+│   └── requirements.txt
+└── README.md
+```
+
+## Hardware setup
+
+Wire NeoPixel data-in to **GP0** (configurable), power from 5V/USB, share
+ground. Default pixel order `GRBW` (SK6812 RGBW); use `GRB` for WS2812B.
+
+1. Install CircuitPython 8/9 on the Tiny 2040.
+2. Copy Adafruit `neopixel.mpy` into `CIRCUITPY/lib/`.
+3. Copy `firmware/boot.py` and `firmware/code.py` to the `CIRCUITPY` root.
+4. Power-cycle once; the board then exposes two serial ports (REPL + the
+   LuxStats data channel).
+
+## Host setup
+
+```bash
+cd host
+pip install -r requirements.txt        # PyQt5, pyserial, psutil
+python3 luxstats_configurator.py
+```
+
+Linux extras: `dialout` group for serial, `fuser` (psmisc) for camera
+detection, `dbus-monitor` for the notification watcher, `nvidia-smi` for
+NVIDIA GPUs (AMD uses sysfs/hwmon automatically), and the `drivetemp`/`nvme`
+kernel sensors for drive temperatures.
+
+## Running in the background (Arch Linux)
+
+`./luxstats.sh` auto-detects the newest installed Python 3, runs directly on
+the system interpreter when `python-pyqt5 python-pyserial python-psutil` are
+installed via pacman (no venv), and otherwise creates a managed venv under
+`~/.local/share/luxstats/venv`. It launches the app detached
+(`start`/`stop`/`status`), logging to `~/.local/state/luxstats/luxstats.log`.
+An optional systemd user unit is in `packaging/luxstats.service`.
+
+The app auto-connects at startup (and on every link drop) to the device named
+`Tiny 2040 (8MB) - CircuitPython CDC2 control` on any `/dev/ttyACM#` port -
+the name is stored in the config and editable on the Connection tab.
+
+## Zone model
+
+Everything on the strip is an **ordered list of zones**. A zone is either a
+status source or an ambient effect region:
+
+* **Status zones** — known sources are locked to their correct type:
+  `cpu`/`gpu`/`ram`/`disk*` are percent (gradient, optional bar-graph fill);
+  `*_temp` sources report **raw Celsius** and map onto a gradient between a
+  per-zone min/max °C; `eth` and `wifi` are **positional** — one color per
+  position (down/10M/100M/1G/2.5G/10G and down/2.4/5/6 GHz, no blending) —
+  and `eth` reads 0 unless the link carrier is actually up; `vpn`/`cam` are
+  on/off colors; `custom` takes pushed RGBW values.
+* **Ambient zones** — off/solid/breathe/rainbow/sparkle with color and speed,
+  assigned to an explicit range. Pixels not claimed by any zone stay off.
+
+Zones **may not overlap**: the app flags conflicts (and ranges past the end
+of the strip) and blocks pushes until fixed. Reorder zones with Move
+up/down. Each row has a **Test** start/stop toggle that blinks that exact LED
+range on the strip while you dial in positions; the camera glow zone
+(start/count) has its own start/stop test button.
+
+The **camera glow** overlay uses assigned LEDs with range syntax
+(`1-5,9-13`) and the same animation presets as ambient zones
+(solid/breathe/rainbow/sparkle). Overlays render on top of zones, in order:
+camera glow → notification blinker → range-test blink. Any start/stop test
+(zone range, camera glow, raw fill) pauses automated polling and resumes it
+when the test stops; fake-status buttons pause polling for 10 s.
+
+## Automation
+
+The Automation tab discovers every source — including **all mounted physical
+drives** (usage per drive, temperature where a `nvme`/`drivetemp` sensor
+exists, plus `disk`/`disk_temp` aggregates) and **separate CPU/GPU
+utilisation and temperature** — and pushes the enabled ones at your chosen
+interval (adjustable in 100 ms steps; slow probes are cached internally so
+10 Hz polling stays cheap). Camera use toggles the glow automatically —
+detection ignores PipeWire/WirePlumber/PulseAudio holding `/dev/video*`
+open, and both on AND off transitions are pushed. Desktop notifications
+(Linux/DBus) fire the blinker. The watcher toggle, camera-automation toggle,
+blinker period/count, and poll interval all persist in the host config.
+
+## Configuration & persistence
+
+Every edit auto-pushes to the device after a short debounce — there is no
+Apply button. Config is saved on the host at
+`~/.config/luxstats/config.json` (Linux/macOS) or `%APPDATA%\luxstats`
+(Windows), reloaded at startup, and pushed on every connect. Profiles can be
+exported/imported as JSON.
+
+## Connection reliability
+
+The firmware uses non-blocking serial writes (it can never stall waiting on
+the host to read a reply) and only acks `status` updates when asked. The app
+drains stale input before every request so request/response can't desync,
+serialises writes across threads, pings the device after ~10 s of idle, and
+auto-reconnects every few seconds if the link drops without a manual
+disconnect.
+
+## Serial protocol (JSON lines, host → device)
+
+| Command | Purpose |
+|---|---|
+| `{"cmd":"ping"}` | health check, returns `{"pong":1,"fw":2}` |
+| `{"cmd":"config","config":{...}}` | apply full configuration (RAM only) |
+| `{"cmd":"get_config"}` | read back the active config |
+| `{"cmd":"status","values":{"cpu":0.4,"gpu_temp":0.6,"disk_sda":0.8,...}}` | push live values (silent; add `"ack":1` for a reply) |
+| `{"cmd":"notify","color":[r,g,b,w],"period":0.4,"count":6}` | blinker (`count:-1` = until cleared) |
+| `{"cmd":"notify_clear"}` | stop the blinker |
+| `{"cmd":"camera","active":true}` | camera glow on/off |
+| `{"cmd":"test","start":2,"count":3,"active":true}` | blink a range while configuring (auto-expires after 3 min) |
+| `{"cmd":"raw","pixels":[[r,g,b,w],...]}` / `{"cmd":"raw_off"}` | direct test mode |
+
+Custom sources: add a zone with any name (type `custom` or `bool`) and push
+matching keys via `status` — CI state, mic mute, meeting status, etc.

+ 13 - 0
firmware/boot.py

@@ -0,0 +1,13 @@
+# LuxStats - boot.py for Pimoroni Tiny 2040 (CircuitPython)
+#
+# Enables a second USB CDC serial channel ("data") so the LuxStats
+# configurator can talk to the board without fighting the REPL console.
+#
+# After copying this file to CIRCUITPY, power-cycle the board once.
+# The board will then expose TWO serial ports:
+#   - port 1: normal REPL/console
+#   - port 2: LuxStats data channel (the configurator auto-detects it)
+
+import usb_cdc
+
+usb_cdc.enable(console=True, data=True)

+ 440 - 0
firmware/code.py

@@ -0,0 +1,440 @@
+# LuxStats firmware v3 - Pimoroni Tiny 2040 + NeoPixels (CircuitPython 8/9)
+#
+# Config lives in RAM only; the host configurator owns it and re-pushes it on
+# connect and on every edit.
+#
+# Segment types:
+#   percent  0.0-1.0 value -> gradient (optional per-segment "gradient")
+#   temp     RAW Celsius value -> gradient positioned by min_c/max_c
+#   link     Ethernet speed -> POSITION palette "colors"
+#            positions: [down, 10M, 100M, 1G, 2.5G, 10G]
+#   wifi     Wi-Fi band -> POSITION palette "colors"
+#            positions: [down, 2.4GHz, 5GHz, 6GHz]
+#   vpn/bool on_color / off_color
+#   custom   pushed [r,g,b,w] value, else fallback "color"
+#   ambient  effect zone: off|solid|breathe|rainbow|sparkle + color + speed
+#
+# Camera glow: an ASSIGNED LED list (host sends expanded indices) with the
+# same effect presets as ambient zones, rendered as an overlay when active.
+#
+# Overlay order: segments -> camera glow -> notification blinker -> test blink
+#
+# Protocol (host -> device), one JSON object per line:
+#   {"cmd":"ping"} {"cmd":"config","config":{...}} {"cmd":"get_config"}
+#   {"cmd":"status","values":{...}}          silent unless "ack":1
+#   {"cmd":"notify","color":[..],"period":s,"count":n}  n -1 = until cleared
+#   {"cmd":"notify_clear"} {"cmd":"camera","active":bool}
+#   {"cmd":"test","start":i,"count":n,"active":bool}
+#   {"cmd":"raw","pixels":[[..],...]} {"cmd":"raw_off"}
+
+import json
+import math
+import random
+import time
+
+import board
+import neopixel
+import usb_cdc
+
+LINK_TIERS = [0, 10, 100, 1000, 2500, 10000]
+DEFAULT_LINK_COLORS = [
+    [0, 0, 0, 0], [255, 0, 0, 0], [255, 90, 0, 0],
+    [0, 255, 0, 0], [0, 255, 180, 0], [80, 0, 255, 0],
+]
+WIFI_BANDS = [0, 2, 5, 6]
+DEFAULT_WIFI_COLORS = [
+    [0, 0, 0, 0], [255, 120, 0, 0], [0, 255, 60, 0], [40, 80, 255, 0],
+]
+
+DEFAULT_CONFIG = {
+    "num_pixels": 8,
+    "pin": "GP0",
+    "pixel_order": "GRBW",
+    "brightness": 0.10,
+    "fps": 30,
+    "segments": [
+        {"name": "cpu", "type": "percent", "start": 0, "count": 2,
+         "fill": True,
+         "gradient": [[0.0, [0, 0, 255, 0]], [0.5, [0, 255, 0, 0]],
+                      [1.0, [255, 0, 0, 0]]]},
+        {"name": "eth",  "type": "link", "start": 2, "count": 1},
+        {"name": "wifi", "type": "wifi", "start": 3, "count": 1},
+        {"name": "vpn",  "type": "vpn",  "start": 4, "count": 1},
+        {"name": "glow", "type": "ambient", "start": 5, "count": 3,
+         "effect": "breathe", "color": [10, 10, 30, 0], "speed": 1.0},
+    ],
+    "camera": {"pixels": [5, 6, 7], "color": [255, 40, 0, 0],
+               "effect": "breathe", "speed": 1.0},
+    "notify": {"start": 0, "count": 0, "blinks": 6,
+               "default_color": [0, 60, 255, 0], "default_period": 0.4},
+}
+
+TEST_TIMEOUT = 180.0
+
+
+def clamp(x, a, b):
+    return a if x < a else (b if x > b else x)
+
+
+def lerp(a, b, t):
+    return a + (b - a) * t
+
+
+def pad4(c):
+    c = list(c)[:4]
+    return c + [0] * (4 - len(c))
+
+
+def scale(color, b):
+    return tuple(int(c * b) for c in color)
+
+
+def default_percent_color(t):
+    t = clamp(t, 0.0, 1.0)
+    if t < 0.33:
+        f = t / 0.33
+        r, g, b = 0, int(255 * f), int(255 * (1 - f))
+    elif t < 0.66:
+        f = (t - 0.33) / 0.33
+        r, g, b = int(255 * f), 255, 0
+    else:
+        f = (t - 0.66) / 0.34
+        r, g, b = 255, int(255 * (1 - f)), 0
+    return (r, g, b, 0)
+
+
+def gradient_color(stops, t):
+    if not stops:
+        return default_percent_color(t)
+    stops = sorted(stops, key=lambda s: s[0])
+    t = clamp(t, 0.0, 1.0)
+    if t <= stops[0][0]:
+        return tuple(pad4(stops[0][1]))
+    for i in range(1, len(stops)):
+        p0, c0 = stops[i - 1][0], pad4(stops[i - 1][1])
+        p1, c1 = stops[i][0], pad4(stops[i][1])
+        if t <= p1:
+            f = 0.0 if p1 == p0 else (t - p0) / (p1 - p0)
+            return tuple(int(lerp(a, b, f)) for a, b in zip(c0, c1))
+    return tuple(pad4(stops[-1][1]))
+
+
+def palette_color(seg, defaults, index):
+    """Positional palette lookup (link/wifi): one color per position."""
+    colors = seg.get("colors") or defaults
+    index = int(clamp(index, 0, len(colors) - 1))
+    return tuple(pad4(colors[index]))
+
+
+def wheel(pos):
+    pos = int(pos) & 255
+    if pos < 85:
+        return (255 - pos * 3, pos * 3, 0, 0)
+    if pos < 170:
+        pos -= 85
+        return (0, 255 - pos * 3, pos * 3, 0)
+    pos -= 170
+    return (pos * 3, 0, 255 - pos * 3, 0)
+
+
+class LuxStats:
+    def __init__(self):
+        self.serial = usb_cdc.data or usb_cdc.console
+        # Non-blocking IO: a blocked write must never stall the loop
+        if self.serial is not None:
+            try:
+                self.serial.timeout = 0
+                self.serial.write_timeout = 0
+            except (AttributeError, ValueError):
+                pass
+        self.buf = b""
+        self.pixels = None
+        self.statuses = {}
+        self.notify = None
+        self.camera_active = False
+        self.test = None
+        self.raw = None
+        self.sparkles = {}          # zone key -> [[pixel, t0], ...]
+        self.apply_config(DEFAULT_CONFIG)
+
+    # ---------- config ----------
+
+    def apply_config(self, cfg):
+        merged = dict(DEFAULT_CONFIG)
+        merged.update(cfg or {})
+        self.config = merged
+        self.sparkles = {}
+        if self.pixels is not None:
+            try:
+                self.pixels.deinit()
+            except Exception:
+                pass
+        pin = getattr(board, merged.get("pin", "GP0"), board.GP0)
+        order = merged.get("pixel_order", "GRBW")
+        self.bpp = 4 if "W" in order else 3
+        self.pixels = neopixel.NeoPixel(
+            pin, int(merged["num_pixels"]),
+            brightness=1.0, auto_write=False, pixel_order=order,
+        )
+        self.frame = [(0, 0, 0, 0)] * int(merged["num_pixels"])
+
+    # ---------- serial ----------
+
+    def poll_serial(self):
+        s = self.serial
+        if s is None:
+            return
+        n = s.in_waiting
+        if n:
+            self.buf += s.read(n)
+            while b"\n" in self.buf:
+                line, self.buf = self.buf.split(b"\n", 1)
+                line = line.strip()
+                if line:
+                    self.handle_line(line)
+        if len(self.buf) > 8192:
+            self.buf = b""
+
+    def send(self, obj):
+        try:
+            self.serial.write((json.dumps(obj) + "\n").encode())
+        except Exception:
+            pass
+
+    def handle_line(self, line):
+        try:
+            msg = json.loads(line)
+        except ValueError:
+            self.send({"err": "bad json"})
+            return
+        cmd = msg.get("cmd", "")
+
+        if cmd == "ping":
+            self.send({"pong": 1, "num_pixels": self.config["num_pixels"],
+                       "fw": 3})
+        elif cmd == "config":
+            self.apply_config(msg.get("config", {}))
+            self.send({"ok": "config"})
+        elif cmd == "get_config":
+            self.send({"config": self.config})
+        elif cmd == "status":
+            vals = msg.get("values", {})
+            if isinstance(vals, dict):
+                self.statuses.update(vals)
+            if msg.get("ack"):
+                self.send({"ok": "status"})
+        elif cmd == "notify":
+            nz = self.config["notify"]
+            self.notify = {
+                "color": tuple(pad4(msg.get("color",
+                                            nz["default_color"]))),
+                "period": float(msg.get("period", nz["default_period"])),
+                "count": int(msg.get("count", nz.get("blinks", 6))),
+                "t0": time.monotonic(),
+            }
+            self.send({"ok": "notify"})
+        elif cmd == "notify_clear":
+            self.notify = None
+            self.send({"ok": "notify_clear"})
+        elif cmd == "camera":
+            self.camera_active = bool(msg.get("active", False))
+            self.send({"ok": "camera"})
+        elif cmd == "test":
+            if msg.get("active", True):
+                self.test = {"start": int(msg.get("start", 0)),
+                             "count": int(msg.get("count", 1)),
+                             "t0": time.monotonic()}
+            else:
+                self.test = None
+            self.send({"ok": "test"})
+        elif cmd == "raw":
+            self.raw = [tuple(pad4(p)) for p in msg.get("pixels", [])]
+            self.send({"ok": "raw"})
+        elif cmd == "raw_off":
+            self.raw = None
+            self.send({"ok": "raw_off"})
+        else:
+            self.send({"err": "unknown cmd '%s'" % cmd})
+
+    # ---------- effect helper (ambient zones + camera glow) ----------
+
+    def effect_color(self, key, effect, base, speed, now, k, idxs):
+        """Color for the k-th pixel of an effect zone at time `now`."""
+        if effect == "solid" or effect == "off":
+            return base
+        if effect == "breathe":
+            t = 0.5 + 0.5 * math.sin(now * 2.0 * speed + k * 0.4)
+            return scale(base, 0.15 + 0.85 * t)
+        if effect == "rainbow":
+            return wheel(now * 40 * speed + k * (256 // max(len(idxs), 1)))
+        if effect == "sparkle":
+            return scale(base, 0.10)
+        return base
+
+    def apply_sparkles(self, key, idxs, speed, now):
+        sp = self.sparkles.setdefault(key, [])
+        if idxs and random.random() < 0.15 * speed:
+            sp.append([random.choice(idxs), now])
+        keep = []
+        for i, t0 in sp:
+            age = now - t0
+            if age < 0.6 and i in idxs:
+                self.frame[i] = scale((255, 255, 255, 40), 1.0 - age / 0.6)
+                keep.append([i, t0])
+        self.sparkles[key] = keep
+
+    # ---------- rendering ----------
+
+    def segment_color(self, seg):
+        stype = seg.get("type", "percent")
+        val = self.statuses.get(seg.get("name", ""), None)
+        grad = seg.get("gradient") or []
+
+        if stype == "percent":
+            if val is None:
+                return (4, 4, 4, 0)
+            return gradient_color(grad, float(val))
+        if stype == "temp":
+            # raw Celsius, positioned by the segment's min/max
+            if val is None:
+                return (4, 4, 4, 0)
+            lo = float(seg.get("min_c", 20.0))
+            hi = float(seg.get("max_c", 90.0))
+            t = 0.0 if hi <= lo else (float(val) - lo) / (hi - lo)
+            return gradient_color(grad, t)
+        if stype == "link":
+            speed = int(val or 0)
+            tier = 0
+            for i, k in enumerate(LINK_TIERS):
+                if speed >= k and speed > 0:
+                    tier = i
+            if speed <= 0:
+                tier = 0
+            return palette_color(seg, DEFAULT_LINK_COLORS, tier)
+        if stype == "wifi":
+            band = int(val or 0)
+            pos = WIFI_BANDS.index(band) if band in WIFI_BANDS else 0
+            return palette_color(seg, DEFAULT_WIFI_COLORS, pos)
+        if stype == "vpn":
+            on = tuple(pad4(seg.get("on_color", [170, 0, 255, 0])))
+            off = tuple(pad4(seg.get("off_color", [6, 6, 6, 0])))
+            return on if val else off
+        if stype == "bool":
+            on = tuple(pad4(seg.get("on_color", [0, 255, 0, 0])))
+            off = tuple(pad4(seg.get("off_color", [4, 0, 0, 0])))
+            return on if val else off
+        if stype == "custom":
+            if isinstance(val, (list, tuple)) and len(val) >= 3:
+                return tuple(pad4(val))
+            return tuple(pad4(seg.get("color", [0, 0, 0, 0])))
+        return (0, 0, 0, 0)
+
+    def render_status_segment(self, seg, n):
+        c = self.segment_color(seg)
+        start, count = int(seg.get("start", 0)), int(seg.get("count", 1))
+        fill = seg.get("fill", False) and seg.get("type") == "percent"
+        val = self.statuses.get(seg.get("name", ""), 0) or 0
+        for j in range(count):
+            i = start + j
+            if not (0 <= i < n):
+                continue
+            if fill:
+                lit = float(val) * count
+                if j + 1 <= lit:
+                    self.frame[i] = c
+                elif j < lit:
+                    self.frame[i] = scale(c, lit - j)
+                else:
+                    self.frame[i] = (2, 2, 2, 0)
+            else:
+                self.frame[i] = c
+
+    def render_ambient_segment(self, seg, key, now, n):
+        effect = seg.get("effect", "breathe")
+        speed = float(seg.get("speed", 1.0))
+        color = tuple(pad4(seg.get("color", [10, 10, 30, 0])))
+        start, count = int(seg.get("start", 0)), int(seg.get("count", 1))
+        idxs = [start + j for j in range(count) if 0 <= start + j < n]
+        if effect == "off":
+            for i in idxs:
+                self.frame[i] = (0, 0, 0, 0)
+            return
+        for k, i in enumerate(idxs):
+            self.frame[i] = self.effect_color(
+                key, effect, color, speed, now, k, idxs)
+        if effect == "sparkle":
+            self.apply_sparkles(key, idxs, speed, now)
+
+    def render(self):
+        now = time.monotonic()
+        n = self.config["num_pixels"]
+
+        if self.raw is not None:
+            for i in range(n):
+                self.frame[i] = self.raw[i] if i < len(self.raw) \
+                    else (0, 0, 0, 0)
+        else:
+            self.frame = [(0, 0, 0, 0)] * n
+
+            for idx, seg in enumerate(self.config.get("segments", [])):
+                if seg.get("type") == "ambient":
+                    self.render_ambient_segment(seg, "seg%d" % idx, now, n)
+                else:
+                    self.render_status_segment(seg, n)
+
+            # camera glow overlay: assigned LED list + effect preset
+            if self.camera_active:
+                cam = self.config["camera"]
+                color = tuple(pad4(cam.get("color", [255, 40, 0, 0])))
+                effect = cam.get("effect", "breathe")
+                speed = float(cam.get("speed", 1.0))
+                idxs = [i for i in cam.get("pixels", []) if 0 <= i < n]
+                for k, i in enumerate(idxs):
+                    self.frame[i] = self.effect_color(
+                        "cam", effect, color, speed, now, k, idxs)
+                if effect == "sparkle":
+                    self.apply_sparkles("cam", idxs, speed, now)
+
+            if self.notify:
+                nt = self.notify
+                cycle = int((now - nt["t0"]) / nt["period"])
+                if nt["count"] >= 0 and cycle >= nt["count"] * 2:
+                    self.notify = None
+                elif cycle % 2 == 0:
+                    nz = self.config["notify"]
+                    s, cnt = int(nz.get("start", 0)), int(nz.get("count", 0))
+                    rng = range(n) if cnt <= 0 else range(s, s + cnt)
+                    for i in rng:
+                        if 0 <= i < n:
+                            self.frame[i] = nt["color"]
+
+            if self.test:
+                if now - self.test["t0"] > TEST_TIMEOUT:
+                    self.test = None
+                else:
+                    on = int(now * 4) % 2 == 0
+                    c = (255, 140, 0, 0) if on else (255, 255, 255, 60)
+                    s, cnt = self.test["start"], self.test["count"]
+                    for i in range(s, s + cnt):
+                        if 0 <= i < n:
+                            self.frame[i] = c
+
+        b = float(self.config.get("brightness", 0.1))
+        for i in range(n):
+            c = scale(self.frame[i], b)
+            self.pixels[i] = c if self.bpp == 4 else c[:3]
+        self.pixels.show()
+
+    def run(self):
+        next_frame = time.monotonic()
+        while True:
+            self.poll_serial()
+            now = time.monotonic()
+            if now >= next_frame:
+                self.render()
+                next_frame = now + 1.0 / max(
+                    int(self.config.get("fps", 30)), 5)
+            time.sleep(0.002)
+
+
+LuxStats().run()

+ 1342 - 0
host/luxstats_configurator.py

@@ -0,0 +1,1342 @@
+#!/usr/bin/env python3
+"""LuxStats Configurator v3 - PyQt5 applet for the Tiny 2040 status-LED firmware.
+
+v3 changes
+----------
+* Known source types are enforced: picking cpu/eth/wifi/vpn/cam/*_temp/etc
+  locks the zone to its correct type so displays can't be misconfigured
+* Temperature sources report RAW Celsius; temp zones map them onto a
+  gradient via per-zone min/max degC
+* Ethernet & Wi-Fi are positional: one color per position (down/10M/100M/
+  1G/2.5G/10G and down/2.4/5/6 GHz) instead of an interpolated range;
+  eth reports 0 unless the link is actually active (carrier up)
+* Camera detection ignores PipeWire/WirePlumber/PulseAudio holding the
+  device open, and camera state is edge-triggered (on AND off are sent)
+* Camera glow uses assigned LEDs with range syntax ("1-5,9-13") and the
+  same animation presets as ambient zones (solid/breathe/rainbow/sparkle)
+* Any start/stop test (zone range, camera glow, raw fill) pauses automated
+  polling and resumes it when the test ends; fake-status buttons pause
+  polling for 10 s so the fake value stays visible
+* Polling interval is set in 100 ms steps (minimum 100 ms; slow probes are
+  internally cached so 10 Hz polling stays cheap)
+* Config persists everything on the host - zones, device identity
+  ("Tiny 2040 (8MB) - CircuitPython CDC2 control" on any /dev/ttyACM#),
+  blinker period/count, notification-watcher toggle, camera-automation
+  toggle - and the app auto-connects to the named device on startup
+
+Run:  python3 luxstats_configurator.py
+"""
+
+import copy
+import json
+import os
+import sys
+import threading
+import time
+
+import serial
+import serial.tools.list_ports
+from PyQt5.QtCore import Qt, QThread, QTimer, pyqtSignal
+from PyQt5.QtGui import QColor
+from PyQt5.QtWidgets import (
+    QApplication, QCheckBox, QColorDialog, QComboBox, QDialog,
+    QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGridLayout,
+    QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
+    QPushButton, QSlider, QSpinBox, QStatusBar, QTableWidget, QTabWidget,
+    QTextEdit, QVBoxLayout, QWidget,
+)
+
+import system_monitor
+
+RP2040_VID = 0x2E8A
+DEFAULT_DEVICE_NAME = "Tiny 2040 (8MB) - CircuitPython CDC2 control"
+
+SEG_TYPES = ["percent", "temp", "link", "wifi", "vpn", "bool", "custom",
+             "ambient"]
+GRADIENT_TYPES = {"percent", "temp"}
+PALETTE_TYPES = {"link", "wifi"}
+PIXEL_ORDERS = ["GRBW", "RGBW", "GRB", "RGB"]
+AMBIENT_EFFECTS = ["off", "solid", "breathe", "rainbow", "sparkle"]
+CAMERA_EFFECTS = ["solid", "breathe", "rainbow", "sparkle"]
+
+LINK_POSITIONS = ["down", "10M", "100M", "1G", "2.5G", "10G"]
+DEFAULT_LINK_COLORS = [[0, 0, 0, 0], [255, 0, 0, 0], [255, 90, 0, 0],
+                       [0, 255, 0, 0], [0, 255, 180, 0], [80, 0, 255, 0]]
+WIFI_POSITIONS = ["down", "2.4 GHz", "5 GHz", "6 GHz"]
+DEFAULT_WIFI_COLORS = [[0, 0, 0, 0], [255, 120, 0, 0], [0, 255, 60, 0],
+                       [40, 80, 255, 0]]
+
+DEFAULT_GRADIENT = [[0.0, [0, 0, 255, 0]], [0.5, [0, 255, 0, 0]],
+                    [1.0, [255, 0, 0, 0]]]
+
+# Per-source default degC windows for temp zones
+TEMP_DEFAULTS = {"cpu_temp": (20, 90), "gpu_temp": (20, 95)}
+DISK_TEMP_DEFAULT = (20, 70)
+
+DEFAULT_SEGMENTS = [
+    {"name": "cpu", "type": "percent", "start": 0, "count": 2, "fill": True,
+     "gradient": copy.deepcopy(DEFAULT_GRADIENT)},
+    {"name": "eth", "type": "link", "start": 2, "count": 1},
+    {"name": "wifi", "type": "wifi", "start": 3, "count": 1},
+    {"name": "vpn", "type": "vpn", "start": 4, "count": 1},
+    {"name": "glow", "type": "ambient", "start": 5, "count": 3,
+     "effect": "breathe", "color": [10, 10, 30, 0], "speed": 1.0},
+]
+
+
+def known_type(name):
+    """Fixed type for a known source name (None = user's choice)."""
+    name = (name or "").strip()
+    if name in ("cpu", "ram", "gpu"):
+        return "percent"
+    if name == "eth":
+        return "link"
+    if name == "wifi":
+        return "wifi"
+    if name == "vpn":
+        return "vpn"
+    if name == "cam":
+        return "bool"
+    if name.endswith("_temp") or name == "disk_temp":
+        return "temp"
+    if name.startswith("disk"):
+        return "percent"
+    return None
+
+
+def default_temp_range(name):
+    if name in TEMP_DEFAULTS:
+        return TEMP_DEFAULTS[name]
+    if name.startswith("disk"):
+        return DISK_TEMP_DEFAULT
+    return (20, 90)
+
+
+def parse_ranges(text):
+    """'1-5,9-13,20' -> [1,2,3,4,5,9,10,11,12,13,20] (LED indices)."""
+    out = []
+    for part in (text or "").replace(";", ",").split(","):
+        part = part.strip()
+        if not part:
+            continue
+        if "-" in part:
+            a, _, b = part.partition("-")
+            if a.strip().isdigit() and b.strip().isdigit():
+                lo, hi = int(a), int(b)
+                if lo > hi:
+                    lo, hi = hi, lo
+                out.extend(range(lo, hi + 1))
+        elif part.isdigit():
+            out.append(int(part))
+    return sorted(set(out))
+
+
+def format_ranges(indices):
+    """[1,2,3,4,5,9] -> '1-5,9'."""
+    indices = sorted(set(int(i) for i in indices))
+    if not indices:
+        return ""
+    parts, a, b = [], indices[0], indices[0]
+    for i in indices[1:]:
+        if i == b + 1:
+            b = i
+        else:
+            parts.append(f"{a}-{b}" if b > a else f"{a}")
+            a = b = i
+    parts.append(f"{a}-{b}" if b > a else f"{a}")
+    return ",".join(parts)
+
+
+def app_config_path():
+    if sys.platform == "win32":
+        base = os.environ.get("APPDATA", os.path.expanduser("~"))
+    else:
+        base = os.environ.get("XDG_CONFIG_HOME",
+                              os.path.expanduser("~/.config"))
+    d = os.path.join(base, "luxstats")
+    os.makedirs(d, exist_ok=True)
+    return os.path.join(d, "config.json")
+
+
+# --------------------------------------------------------------------------
+# Serial link
+# --------------------------------------------------------------------------
+
+class SerialLink:
+    def __init__(self, log):
+        self.ser = None
+        self.log = log
+        self.lock = threading.Lock()
+        self.last_activity = 0.0
+
+    @staticmethod
+    def list_ports():
+        ports = list(serial.tools.list_ports.comports())
+        ports.sort(key=lambda p: (p.vid != RP2040_VID, p.device))
+        return ports
+
+    @staticmethod
+    def find_device(device_name):
+        """Locate the named CDC2 control port on any /dev/ttyACM#."""
+        matches = []
+        for p in serial.tools.list_ports.comports():
+            desc = " ".join(filter(None, [p.description, p.interface or ""]))
+            if device_name and device_name.lower() in desc.lower():
+                matches.append(p)
+        if not matches:
+            return None
+        # Prefer the interface explicitly named as the CDC2 data channel,
+        # otherwise the highest-numbered interface of the board.
+        for p in matches:
+            joined = f"{p.description} {p.interface or ''}".lower()
+            if "cdc2" in joined or "data" in joined:
+                return p.device
+        matches.sort(key=lambda p: (p.location or "", p.device))
+        return matches[-1].device
+
+    def open(self, device):
+        self.close()
+        self.ser = serial.Serial(device, 115200, timeout=0.5, write_timeout=1)
+        time.sleep(0.2)
+        self.ser.reset_input_buffer()
+        self.last_activity = time.monotonic()
+
+    def close(self):
+        if self.ser:
+            try:
+                self.ser.close()
+            except OSError:
+                pass
+        self.ser = None
+
+    @property
+    def connected(self):
+        return self.ser is not None and self.ser.is_open
+
+    def _drain(self):
+        while self.ser.in_waiting:
+            self.ser.readline()
+
+    def send(self, obj, expect_reply=True):
+        if not self.connected:
+            return None
+        with self.lock:
+            if not self.connected:
+                return None
+            try:
+                self._drain()
+                self.ser.write((json.dumps(obj) + "\n").encode())
+                self.last_activity = time.monotonic()
+                if not expect_reply:
+                    return None
+                reply = self.ser.readline().decode(errors="replace").strip()
+                if reply:
+                    self.last_activity = time.monotonic()
+                    try:
+                        return json.loads(reply)
+                    except ValueError:
+                        return {"raw": reply}
+            except (OSError, serial.SerialException) as e:
+                self.log(f"serial error: {e}")
+                self.close()
+        return None
+
+
+# --------------------------------------------------------------------------
+# Background automation thread (pausable)
+# --------------------------------------------------------------------------
+
+class MonitorThread(QThread):
+    statuses = pyqtSignal(dict)
+
+    def __init__(self):
+        super().__init__()
+        self.interval = 2.0
+        self.enabled_probes = None
+        self.running = True
+        self.paused = False
+
+    def run(self):
+        system_monitor.psutil.cpu_percent(interval=None)
+        while self.running:
+            if not self.paused:
+                try:
+                    self.statuses.emit(
+                        system_monitor.collect(self.enabled_probes))
+                except Exception as e:
+                    self.statuses.emit({"_error": str(e)})
+            t = 0.0
+            while self.running and t < self.interval:
+                self.msleep(50)
+                t += 0.05
+
+    def stop(self):
+        self.running = False
+
+
+# --------------------------------------------------------------------------
+# Small widgets
+# --------------------------------------------------------------------------
+
+class ColorButton(QPushButton):
+    changed = pyqtSignal()
+
+    def __init__(self, rgbw=(0, 60, 255, 0)):
+        super().__init__()
+        self._rgbw = list(rgbw)[:4] + [0] * (4 - len(rgbw))
+        self.clicked.connect(self.pick)
+        self._refresh()
+
+    def _refresh(self):
+        r, g, b, _ = self._rgbw
+        lum = 0.299 * r + 0.587 * g + 0.114 * b
+        fg = "black" if lum > 128 else "white"
+        self.setStyleSheet(
+            f"background-color: rgb({r},{g},{b}); color:{fg};"
+            "min-width: 72px;")
+        self.setText(f"({r},{g},{b},{self._rgbw[3]})")
+
+    def pick(self):
+        r, g, b, _ = self._rgbw
+        c = QColorDialog.getColor(QColor(r, g, b), self, "Pick color")
+        if c.isValid():
+            self._rgbw[:3] = [c.red(), c.green(), c.blue()]
+            self._refresh()
+            self.changed.emit()
+
+    def set_white(self, w):
+        self._rgbw[3] = int(w)
+        self._refresh()
+        self.changed.emit()
+
+    def rgbw(self):
+        return list(self._rgbw)
+
+    def set_rgbw(self, rgbw):
+        self._rgbw = list(rgbw)[:4] + [0] * (4 - len(rgbw))
+        self._refresh()
+
+
+# --------------------------------------------------------------------------
+# Per-zone settings dialog
+# --------------------------------------------------------------------------
+
+class SegmentDialog(QDialog):
+    def __init__(self, seg, parent=None):
+        super().__init__(parent)
+        self.seg = seg
+        stype = seg.get("type", "percent")
+        name = seg.get("name", "")
+        self.setWindowTitle(f"Zone settings - {name} ({stype})")
+        lay = QVBoxLayout(self)
+
+        self.grad_table = None
+        self.palette_btns = None
+        self.effect_combo = None
+        self.on_btn = self.off_btn = self.custom_btn = None
+        self.min_c = self.max_c = None
+
+        if stype == "temp":
+            form = QFormLayout()
+            lo, hi = default_temp_range(name)
+            self.min_c = QDoubleSpinBox()
+            self.min_c.setRange(-50, 200); self.min_c.setSuffix(" °C")
+            self.min_c.setValue(float(seg.get("min_c", lo)))
+            self.max_c = QDoubleSpinBox()
+            self.max_c.setRange(-50, 200); self.max_c.setSuffix(" °C")
+            self.max_c.setValue(float(seg.get("max_c", hi)))
+            form.addRow("Gradient start at:", self.min_c)
+            form.addRow("Gradient end at:", self.max_c)
+            lay.addLayout(form)
+            lay.addWidget(QLabel(
+                "Raw Celsius values map onto the gradient between these "
+                "temperatures (clamped outside)."))
+
+        if stype in GRADIENT_TYPES:
+            lay.addWidget(QLabel("Gradient color stops (position 0.0-1.0). "
+                                 "Remove all stops for the built-in "
+                                 "blue→green→red."))
+            self.grad_table = QTableWidget(0, 3)
+            self.grad_table.setHorizontalHeaderLabels(
+                ["Position", "Color", "White"])
+            self.grad_table.horizontalHeader().setStretchLastSection(True)
+            for pos, color in seg.get("gradient", []) or []:
+                self._add_stop(pos, color)
+            lay.addWidget(self.grad_table)
+            row = QHBoxLayout()
+            b_add = QPushButton("Add stop")
+            b_add.clicked.connect(lambda: self._add_stop(1.0, [255, 0, 0, 0]))
+            b_del = QPushButton("Remove selected")
+            b_del.clicked.connect(lambda: self.grad_table.removeRow(
+                self.grad_table.currentRow()))
+            b_def = QPushButton("Load default gradient")
+            b_def.clicked.connect(self._load_default)
+            row.addWidget(b_add); row.addWidget(b_del); row.addWidget(b_def)
+            row.addStretch(1)
+            lay.addLayout(row)
+
+        elif stype in PALETTE_TYPES:
+            positions = LINK_POSITIONS if stype == "link" else WIFI_POSITIONS
+            defaults = (DEFAULT_LINK_COLORS if stype == "link"
+                        else DEFAULT_WIFI_COLORS)
+            lay.addWidget(QLabel(
+                "One color per position (no blending between positions)."))
+            form = QFormLayout()
+            colors = seg.get("colors") or copy.deepcopy(defaults)
+            self.palette_btns = []
+            for i, label in enumerate(positions):
+                c = colors[i] if i < len(colors) else defaults[i]
+                btn = ColorButton(c)
+                w = QSpinBox(); w.setRange(0, 255)
+                w.setValue(int(c[3]) if len(c) > 3 else 0)
+                w.valueChanged.connect(btn.set_white)
+                row = QHBoxLayout()
+                row.addWidget(btn); row.addWidget(QLabel("W:"))
+                row.addWidget(w); row.addStretch(1)
+                form.addRow(f"{label}:", row)
+                self.palette_btns.append(btn)
+            lay.addLayout(form)
+
+        elif stype == "ambient":
+            form = QFormLayout()
+            self.effect_combo = QComboBox()
+            self.effect_combo.addItems(AMBIENT_EFFECTS)
+            self.effect_combo.setCurrentText(seg.get("effect", "breathe"))
+            self.amb_color = ColorButton(seg.get("color", [10, 10, 30, 0]))
+            self.amb_white = QSpinBox(); self.amb_white.setRange(0, 255)
+            col = seg.get("color", [0, 0, 0, 0])
+            self.amb_white.setValue(int(col[3]) if len(col) > 3 else 0)
+            self.amb_white.valueChanged.connect(self.amb_color.set_white)
+            self.amb_speed = QDoubleSpinBox()
+            self.amb_speed.setRange(0.1, 5.0); self.amb_speed.setSingleStep(0.1)
+            self.amb_speed.setValue(float(seg.get("speed", 1.0)))
+            crow = QHBoxLayout()
+            crow.addWidget(self.amb_color)
+            crow.addWidget(QLabel("W:")); crow.addWidget(self.amb_white)
+            form.addRow("Effect:", self.effect_combo)
+            form.addRow("Color:", crow)
+            form.addRow("Speed:", self.amb_speed)
+            lay.addLayout(form)
+
+        elif stype in ("vpn", "bool"):
+            form = QFormLayout()
+            on_def = [170, 0, 255, 0] if stype == "vpn" else [0, 255, 0, 0]
+            off_def = [6, 6, 6, 0] if stype == "vpn" else [4, 0, 0, 0]
+            self.on_btn = ColorButton(seg.get("on_color", on_def))
+            self.off_btn = ColorButton(seg.get("off_color", off_def))
+            form.addRow("Color when ON:", self.on_btn)
+            form.addRow("Color when OFF:", self.off_btn)
+            lay.addLayout(form)
+
+        elif stype == "custom":
+            form = QFormLayout()
+            self.custom_btn = ColorButton(seg.get("color", [0, 0, 0, 0]))
+            form.addRow("Fallback color (until a value is pushed):",
+                        self.custom_btn)
+            lay.addLayout(form)
+
+        buttons = QDialogButtonBox(
+            QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
+        buttons.accepted.connect(self.accept)
+        buttons.rejected.connect(self.reject)
+        lay.addWidget(buttons)
+
+    def _add_stop(self, pos, color):
+        r = self.grad_table.rowCount()
+        self.grad_table.insertRow(r)
+        spin = QDoubleSpinBox()
+        spin.setRange(0.0, 1.0); spin.setSingleStep(0.05)
+        spin.setDecimals(2); spin.setValue(float(pos))
+        btn = ColorButton(color)
+        w = QSpinBox(); w.setRange(0, 255)
+        w.setValue(int(color[3]) if len(color) > 3 else 0)
+        w.valueChanged.connect(btn.set_white)
+        self.grad_table.setCellWidget(r, 0, spin)
+        self.grad_table.setCellWidget(r, 1, btn)
+        self.grad_table.setCellWidget(r, 2, w)
+
+    def _load_default(self):
+        self.grad_table.setRowCount(0)
+        for pos, color in copy.deepcopy(DEFAULT_GRADIENT):
+            self._add_stop(pos, color)
+
+    def apply_to_segment(self):
+        if self.min_c is not None:
+            self.seg["min_c"] = self.min_c.value()
+            self.seg["max_c"] = self.max_c.value()
+        if self.grad_table is not None:
+            stops = []
+            for r in range(self.grad_table.rowCount()):
+                stops.append([self.grad_table.cellWidget(r, 0).value(),
+                              self.grad_table.cellWidget(r, 1).rgbw()])
+            stops.sort(key=lambda s: s[0])
+            self.seg["gradient"] = stops
+        if self.palette_btns is not None:
+            self.seg["colors"] = [b.rgbw() for b in self.palette_btns]
+        if self.effect_combo is not None:
+            self.seg["effect"] = self.effect_combo.currentText()
+            self.seg["color"] = self.amb_color.rgbw()
+            self.seg["speed"] = self.amb_speed.value()
+        if self.on_btn is not None:
+            self.seg["on_color"] = self.on_btn.rgbw()
+            self.seg["off_color"] = self.off_btn.rgbw()
+        if self.custom_btn is not None:
+            self.seg["color"] = self.custom_btn.rgbw()
+
+
+# --------------------------------------------------------------------------
+# Main window
+# --------------------------------------------------------------------------
+
+class MainWindow(QMainWindow):
+    KEEPALIVE_S = 10
+    RECONNECT_S = 3
+    FAKE_PAUSE_S = 10      # fake-status buttons pause polling this long
+
+    notification_seen = pyqtSignal()
+
+    def __init__(self):
+        super().__init__()
+        self.setWindowTitle("LuxStats Configurator")
+        self.resize(960, 720)
+
+        self.link = SerialLink(self.log)
+        self.monitor = MonitorThread()
+        self.monitor.statuses.connect(self.on_statuses)
+        self.notify_watcher = None
+        self.notification_seen.connect(self.fire_notification)
+        self.segments = copy.deepcopy(DEFAULT_SEGMENTS)
+        self._building = False
+        self._active_test_row = None
+        self._raw_test = False
+        self._last_cam = None
+        self.device_name = DEFAULT_DEVICE_NAME
+        self._last_port = None
+        self._user_disconnected = False   # auto-connect from the start
+
+        self.push_timer = QTimer(self)
+        self.push_timer.setSingleShot(True)
+        self.push_timer.setInterval(400)
+        self.push_timer.timeout.connect(self._auto_push)
+
+        self.fake_resume_timer = QTimer(self)
+        self.fake_resume_timer.setSingleShot(True)
+        self.fake_resume_timer.setInterval(self.FAKE_PAUSE_S * 1000)
+        self.fake_resume_timer.timeout.connect(
+            lambda: self.resume_polling("fake status"))
+
+        self.keepalive_timer = QTimer(self)
+        self.keepalive_timer.setInterval(self.RECONNECT_S * 1000)
+        self.keepalive_timer.timeout.connect(self._keepalive)
+        self.keepalive_timer.start()
+
+        tabs = QTabWidget()
+        tabs.addTab(self._build_connection_tab(), "Connection")
+        tabs.addTab(self._build_device_tab(), "Device")
+        tabs.addTab(self._build_segments_tab(), "Zones && Mapping")
+        tabs.addTab(self._build_automation_tab(), "Automation")
+        tabs.addTab(self._build_test_tab(), "Test")
+        self.setCentralWidget(tabs)
+        self.setStatusBar(QStatusBar())
+
+        self._wire_autopush()
+        self.load_app_config()
+        self.refresh_ports()
+        self.rebuild_seg_table()
+        QTimer.singleShot(300, self._startup_connect)
+
+    # ---------------- Connection tab ----------------
+
+    def _build_connection_tab(self):
+        w = QWidget()
+        lay = QVBoxLayout(w)
+
+        row0 = QHBoxLayout()
+        self.device_name_edit = QLineEdit(DEFAULT_DEVICE_NAME)
+        row0.addWidget(QLabel("Device name (auto-connect):"))
+        row0.addWidget(self.device_name_edit, 1)
+        lay.addLayout(row0)
+
+        row = QHBoxLayout()
+        self.port_combo = QComboBox()
+        btn_refresh = QPushButton("Refresh")
+        btn_refresh.clicked.connect(self.refresh_ports)
+        self.btn_connect = QPushButton("Connect")
+        self.btn_connect.clicked.connect(self.toggle_connect)
+        row.addWidget(QLabel("Serial port:"))
+        row.addWidget(self.port_combo, 1)
+        row.addWidget(btn_refresh)
+        row.addWidget(self.btn_connect)
+        lay.addLayout(row)
+
+        hint = QLabel(
+            "The app looks for the named device on any /dev/ttyACM# port at "
+            "startup and whenever the link drops, and re-pushes the stored "
+            "config on every connect. Config lives on this computer only.")
+        hint.setWordWrap(True)
+        lay.addWidget(hint)
+
+        self.log_box = QTextEdit()
+        self.log_box.setReadOnly(True)
+        lay.addWidget(self.log_box, 1)
+
+        row2 = QHBoxLayout()
+        for label, fn in [
+            ("Ping", self.do_ping),
+            ("Push config now", self.push_config),
+            ("Export profile...", self.save_profile),
+            ("Import profile...", self.load_profile),
+        ]:
+            b = QPushButton(label)
+            b.clicked.connect(fn)
+            row2.addWidget(b)
+        lay.addLayout(row2)
+        return w
+
+    def refresh_ports(self):
+        self.port_combo.clear()
+        for p in SerialLink.list_ports():
+            tag = " [RP2040]" if p.vid == RP2040_VID else ""
+            self.port_combo.addItem(
+                f"{p.device} - {p.description}{tag}", p.device)
+
+    def _startup_connect(self):
+        if self.link.connected or self._user_disconnected:
+            return
+        dev = SerialLink.find_device(self.device_name_edit.text()) \
+            or self._last_port
+        if dev and self._connect_to(dev, quiet=True):
+            self.log("auto-connected on startup")
+
+    def toggle_connect(self):
+        if self.link.connected:
+            self._user_disconnected = True
+            self.link.close()
+            self.btn_connect.setText("Connect")
+            self.log("disconnected")
+            return
+        dev = self.port_combo.currentData() \
+            or SerialLink.find_device(self.device_name_edit.text())
+        if not dev:
+            self.log("no port selected and named device not found")
+            return
+        self._user_disconnected = False
+        self._connect_to(dev)
+
+    def _connect_to(self, dev, quiet=False):
+        try:
+            self.link.open(dev)
+        except (OSError, serial.SerialException) as e:
+            if not quiet:
+                self.log(f"open failed: {e}")
+            return False
+        self._last_port = dev
+        self.btn_connect.setText("Disconnect")
+        self.log(f"connected to {dev}")
+        reply = self.link.send({"cmd": "ping"})
+        if reply and reply.get("pong"):
+            self.log("device answered ping - pushing config")
+            self.push_config()
+            return True
+        self.log("no pong - is this the DATA port and is code.py running?")
+        return False
+
+    def _keepalive(self):
+        if self._user_disconnected:
+            return
+        if self.link.connected:
+            if time.monotonic() - self.link.last_activity >= self.KEEPALIVE_S:
+                reply = self.link.send({"cmd": "ping"})
+                if not (reply and reply.get("pong")):
+                    self.log("keepalive ping failed - link lost, will "
+                             "auto-reconnect")
+                    self.link.close()
+            return
+        self.btn_connect.setText("Connect")
+        dev = SerialLink.find_device(self.device_name_edit.text()) \
+            or self._last_port
+        if dev and self._connect_to(dev, quiet=True):
+            self.log("auto-reconnected")
+
+    def do_ping(self):
+        self.log(f"ping -> {self.link.send({'cmd': 'ping'})}")
+
+    def send_logged(self, obj):
+        self.log(f"{obj.get('cmd')} -> {self.link.send(obj)}")
+
+    def log(self, msg):
+        self.log_box.append(f"[{time.strftime('%H:%M:%S')}] {msg}")
+        self.statusBar().showMessage(msg, 4000)
+
+    # ---------------- Device tab ----------------
+
+    def _build_device_tab(self):
+        w = QWidget()
+        lay = QVBoxLayout(w)
+        gb = QGroupBox("Strip")
+        form = QFormLayout(gb)
+        self.num_pixels = QSpinBox(); self.num_pixels.setRange(1, 1024)
+        self.num_pixels.setValue(8)
+        self.pin_edit = QLineEdit("GP0")
+        self.order_combo = QComboBox(); self.order_combo.addItems(PIXEL_ORDERS)
+        self.fps_spin = QSpinBox(); self.fps_spin.setRange(5, 120)
+        self.fps_spin.setValue(30)
+        self.brightness = QSlider(Qt.Horizontal)
+        self.brightness.setRange(1, 100); self.brightness.setValue(10)
+        self.bright_label = QLabel("10%")
+        self.brightness.valueChanged.connect(
+            lambda v: self.bright_label.setText(f"{v}%"))
+        brow = QHBoxLayout(); brow.addWidget(self.brightness, 1)
+        brow.addWidget(self.bright_label)
+        form.addRow("Number of LEDs:", self.num_pixels)
+        form.addRow("Data pin:", self.pin_edit)
+        form.addRow("Pixel order:", self.order_combo)
+        form.addRow("Frame rate:", self.fps_spin)
+        form.addRow("Brightness:", brow)
+        lay.addWidget(gb)
+        lay.addWidget(QLabel("Changes push to the device automatically."))
+        lay.addStretch(1)
+        return w
+
+    # ---------------- Zones & mapping tab ----------------
+
+    def _build_segments_tab(self):
+        w = QWidget()
+        lay = QVBoxLayout(w)
+        lay.addWidget(QLabel(
+            "Ordered zones. Known sources lock to their correct type "
+            "(temps use raw °C, eth/wifi use one color per position). "
+            "Zones may not overlap; unassigned pixels stay off. Test blinks "
+            "a zone's range and pauses polling until stopped."))
+
+        self.seg_table = QTableWidget(0, 7)
+        self.seg_table.setHorizontalHeaderLabels(
+            ["Source / name", "Type", "Start", "Count", "Fill",
+             "Settings", "Test"])
+        self.seg_table.horizontalHeader().setStretchLastSection(True)
+        lay.addWidget(self.seg_table, 1)
+
+        self.overlap_label = QLabel("")
+        self.overlap_label.setStyleSheet("color: #c02020; font-weight: bold;")
+        self.overlap_label.setWordWrap(True)
+        lay.addWidget(self.overlap_label)
+
+        row = QHBoxLayout()
+        for label, fn in [("Add zone", self.add_segment),
+                          ("Remove selected", self.remove_segment),
+                          ("Move up", lambda: self.move_segment(-1)),
+                          ("Move down", lambda: self.move_segment(+1))]:
+            b = QPushButton(label)
+            b.clicked.connect(fn)
+            row.addWidget(b)
+        row.addStretch(1)
+        lay.addLayout(row)
+
+        gb = QGroupBox("Camera glow (overlay on assigned LEDs)")
+        form = QFormLayout(gb)
+        self.cam_pixels = QLineEdit("5-7")
+        self.cam_pixels.setPlaceholderText("e.g. 1-5,9-13")
+        self.cam_effect = QComboBox(); self.cam_effect.addItems(CAMERA_EFFECTS)
+        self.cam_effect.setCurrentText("breathe")
+        self.cam_speed = QDoubleSpinBox()
+        self.cam_speed.setRange(0.1, 5.0); self.cam_speed.setSingleStep(0.1)
+        self.cam_speed.setValue(1.0)
+        self.cam_color = ColorButton((255, 40, 0, 0))
+        self.cam_test_btn = QPushButton("Test glow (start/stop)")
+        self.cam_test_btn.setCheckable(True)
+        self.cam_test_btn.toggled.connect(self.toggle_camera_test)
+        form.addRow("LEDs (ranges, e.g. 1-5,9-13):", self.cam_pixels)
+        form.addRow("Animation:", self.cam_effect)
+        form.addRow("Speed:", self.cam_speed)
+        form.addRow("Glow color:", self.cam_color)
+        form.addRow("", self.cam_test_btn)
+        lay.addWidget(gb)
+
+        gb2 = QGroupBox("Notification blinker (overlay)")
+        form2 = QFormLayout(gb2)
+        self.notify_start = QSpinBox(); self.notify_start.setRange(0, 1023)
+        self.notify_count = QSpinBox(); self.notify_count.setRange(0, 1024)
+        self.notify_count.setValue(0)
+        self.notify_color = ColorButton((0, 60, 255, 0))
+        self.notify_period = QDoubleSpinBox()
+        self.notify_period.setRange(0.05, 5.0)
+        self.notify_period.setValue(0.4); self.notify_period.setSingleStep(0.05)
+        self.notify_blinks = QSpinBox()
+        self.notify_blinks.setRange(-1, 100); self.notify_blinks.setValue(6)
+        nrow = QHBoxLayout()
+        nrow.addWidget(QLabel("Start:")); nrow.addWidget(self.notify_start)
+        nrow.addWidget(QLabel("Count (0 = all):"))
+        nrow.addWidget(self.notify_count)
+        nrow.addStretch(1)
+        form2.addRow("Range:", nrow)
+        form2.addRow("Default color:", self.notify_color)
+        form2.addRow("Blink period (s):", self.notify_period)
+        form2.addRow("Blink count (-1 = forever):", self.notify_blinks)
+        lay.addWidget(gb2)
+        return w
+
+    # ----- segment model <-> table -----
+
+    def rebuild_seg_table(self):
+        self._building = True
+        sources = system_monitor.discover_sources() + ["glow", "custom1"]
+        self.seg_table.setRowCount(0)
+        for idx, seg in enumerate(self.segments):
+            # enforce known types on load/rebuild (requirement 7)
+            fixed = known_type(seg.get("name", ""))
+            if fixed and seg.get("type") != fixed:
+                seg["type"] = fixed
+
+            r = self.seg_table.rowCount()
+            self.seg_table.insertRow(r)
+
+            name = QComboBox(); name.setEditable(True)
+            name.addItems(sources)
+            name.setCurrentText(str(seg.get("name", "")))
+            name.currentTextChanged.connect(
+                lambda v, i=idx: self._seg_edit(i, "name", v))
+
+            stype = QComboBox(); stype.addItems(SEG_TYPES)
+            stype.setCurrentText(seg.get("type", "percent"))
+            stype.setEnabled(fixed is None)
+            stype.setToolTip("Type is fixed for known sources"
+                             if fixed else "")
+            stype.currentTextChanged.connect(
+                lambda v, i=idx: self._seg_edit(i, "type", v))
+
+            start = QSpinBox(); start.setRange(0, 1023)
+            start.setValue(int(seg.get("start", 0)))
+            start.valueChanged.connect(
+                lambda v, i=idx: self._seg_edit(i, "start", v))
+
+            count = QSpinBox(); count.setRange(1, 1024)
+            count.setValue(int(seg.get("count", 1)))
+            count.valueChanged.connect(
+                lambda v, i=idx: self._seg_edit(i, "count", v))
+
+            fill = QCheckBox()
+            fill.setChecked(bool(seg.get("fill", False)))
+            fill.setEnabled(seg.get("type") == "percent")
+            fill.toggled.connect(
+                lambda v, i=idx: self._seg_edit(i, "fill", v))
+
+            settings = QPushButton("Settings...")
+            settings.clicked.connect(lambda _, i=idx: self.open_settings(i))
+
+            test = QPushButton("Test")
+            test.setCheckable(True)
+            test.setChecked(self._active_test_row == idx)
+            test.toggled.connect(lambda on, i=idx: self.toggle_seg_test(i, on))
+
+            for col, widget in enumerate(
+                    [name, stype, start, count, fill, settings, test]):
+                self.seg_table.setCellWidget(r, col, widget)
+        self._building = False
+        self.validate_segments()
+
+    def _seg_edit(self, idx, key, value):
+        if self._building or idx >= len(self.segments):
+            return
+        seg = self.segments[idx]
+        seg[key] = value
+        if key == "name":
+            fixed = known_type(value)
+            if fixed and seg.get("type") != fixed:
+                seg["type"] = fixed
+                seg.pop("gradient", None)
+                seg.pop("colors", None)
+                self.rebuild_seg_table()
+            else:
+                type_combo = self.seg_table.cellWidget(idx, 1)
+                if type_combo:
+                    type_combo.setEnabled(fixed is None)
+        if key == "type":
+            seg.pop("gradient", None)
+            seg.pop("colors", None)
+            fill = self.seg_table.cellWidget(idx, 4)
+            if fill:
+                fill.setEnabled(value == "percent")
+        self.schedule_push()
+        self.validate_segments()
+        if self._active_test_row == idx and key in ("start", "count"):
+            self.link.send({"cmd": "test", "start": seg["start"],
+                            "count": seg["count"], "active": True},
+                           expect_reply=False)
+
+    def add_segment(self):
+        end = max((s.get("start", 0) + s.get("count", 1)
+                   for s in self.segments), default=0)
+        self.segments.append({"name": "custom1", "type": "custom",
+                              "start": end, "count": 1})
+        self.rebuild_seg_table()
+        self.schedule_push()
+
+    def remove_segment(self):
+        r = self.seg_table.currentRow()
+        if 0 <= r < len(self.segments):
+            if self._active_test_row == r:
+                self._stop_range_test()
+            del self.segments[r]
+            self._active_test_row = None
+            self.rebuild_seg_table()
+            self.schedule_push()
+
+    def move_segment(self, delta):
+        r = self.seg_table.currentRow()
+        nr = r + delta
+        if 0 <= r < len(self.segments) and 0 <= nr < len(self.segments):
+            self.segments[r], self.segments[nr] = \
+                self.segments[nr], self.segments[r]
+            if self._active_test_row == r:
+                self._active_test_row = nr
+            elif self._active_test_row == nr:
+                self._active_test_row = r
+            self.rebuild_seg_table()
+            self.seg_table.selectRow(nr)
+            self.schedule_push()
+
+    def open_settings(self, idx):
+        if idx >= len(self.segments):
+            return
+        dlg = SegmentDialog(self.segments[idx], self)
+        if dlg.exec_() == QDialog.Accepted:
+            dlg.apply_to_segment()
+            self.schedule_push()
+
+    # ----- polling pause/resume around tests (requirement 8) -----
+
+    def pause_polling(self, reason):
+        if not self.monitor.paused:
+            self.monitor.paused = True
+            self.log(f"polling paused ({reason})")
+
+    def resume_polling(self, reason):
+        if self.monitor.paused and self._active_test_row is None \
+                and not self.cam_test_btn.isChecked() and not self._raw_test \
+                and not self.fake_resume_timer.isActive():
+            self.monitor.paused = False
+            self.log(f"polling resumed ({reason} done)")
+
+    # ----- range tests -----
+
+    def toggle_seg_test(self, idx, on):
+        if self._building:
+            return
+        if on:
+            if self._active_test_row is not None \
+                    and self._active_test_row != idx:
+                old = self.seg_table.cellWidget(self._active_test_row, 6)
+                if old:
+                    old.blockSignals(True)
+                    old.setChecked(False)
+                    old.blockSignals(False)
+            self._active_test_row = idx
+            self.pause_polling("zone test")
+            seg = self.segments[idx]
+            self.send_logged({"cmd": "test", "start": seg.get("start", 0),
+                              "count": seg.get("count", 1), "active": True})
+        else:
+            if self._active_test_row == idx:
+                self._stop_range_test()
+
+    def _stop_range_test(self):
+        self._active_test_row = None
+        self.link.send({"cmd": "test", "active": False}, expect_reply=False)
+        self.resume_polling("zone test")
+
+    def toggle_camera_test(self, on):
+        if on:
+            self.pause_polling("camera glow test")
+        self.send_logged({"cmd": "camera", "active": bool(on)})
+        self.cam_test_btn.setText(
+            "Stop glow test" if on else "Test glow (start/stop)")
+        if not on:
+            self._last_cam = None       # re-sync real camera state
+            self.resume_polling("camera glow test")
+
+    # ----- overlap validation -----
+
+    def validate_segments(self):
+        n = self.num_pixels.value()
+        owner = {}
+        problems = []
+        for idx, seg in enumerate(self.segments):
+            s, c = int(seg.get("start", 0)), int(seg.get("count", 1))
+            label = f"#{idx + 1} {seg.get('name', '?')}"
+            if s + c > n:
+                problems.append(f"{label} runs past the strip "
+                                f"(LED {s + c - 1} > {n - 1})")
+            for i in range(s, min(s + c, n)):
+                if i in owner:
+                    problems.append(f"{label} overlaps {owner[i]} at LED {i}")
+                    break
+                owner[i] = label
+        bad_cam = [i for i in parse_ranges(self.cam_pixels.text())
+                   if i >= n] if hasattr(self, "cam_pixels") else []
+        if bad_cam:
+            problems.append(f"camera glow LEDs {bad_cam} past the strip")
+        if problems:
+            self.overlap_label.setText(
+                "Not pushed - fix these first:\n" + "\n".join(problems))
+        else:
+            self.overlap_label.setText("")
+        return not problems
+
+    # ---------------- Automation tab ----------------
+
+    def _build_automation_tab(self):
+        w = QWidget()
+        lay = QVBoxLayout(w)
+
+        self.probe_group = QGroupBox(
+            "Live status sources (drives auto-detected)")
+        self.probe_grid = QGridLayout(self.probe_group)
+        self.probe_checks = {}
+        self._rebuild_probes()
+        lay.addWidget(self.probe_group)
+
+        row = QHBoxLayout()
+        b_re = QPushButton("Re-detect sources")
+        b_re.clicked.connect(self._rebuild_probes)
+        self.interval_spin = QDoubleSpinBox()
+        self.interval_spin.setRange(0.1, 60.0)     # 100 ms steps (req 9)
+        self.interval_spin.setSingleStep(0.1)
+        self.interval_spin.setDecimals(1)
+        self.interval_spin.setSuffix(" s")
+        self.interval_spin.setValue(1.0)
+        self.btn_monitor = QPushButton("Start automation")
+        self.btn_monitor.setCheckable(True)
+        self.btn_monitor.toggled.connect(self.toggle_monitor)
+        row.addWidget(b_re)
+        row.addWidget(QLabel("Poll interval (100 ms steps):"))
+        row.addWidget(self.interval_spin)
+        row.addWidget(self.btn_monitor)
+        row.addStretch(1)
+        lay.addLayout(row)
+
+        gb2 = QGroupBox("Desktop notification watcher (Linux/DBus)")
+        v2 = QVBoxLayout(gb2)
+        self.notify_watch_chk = QCheckBox(
+            "Blink the notification zone on any desktop notification")
+        self.notify_watch_chk.toggled.connect(self.toggle_notify_watcher)
+        v2.addWidget(self.notify_watch_chk)
+        lay.addWidget(gb2)
+
+        gb3 = QGroupBox("Camera automation")
+        v3 = QVBoxLayout(gb3)
+        self.cam_auto_chk = QCheckBox(
+            "Automatically glow while a real app uses the camera "
+            "(media daemons like PipeWire are ignored)")
+        self.cam_auto_chk.setChecked(True)
+        v3.addWidget(self.cam_auto_chk)
+        lay.addWidget(gb3)
+
+        self.live_label = QLabel("Live values: -")
+        self.live_label.setWordWrap(True)
+        lay.addWidget(self.live_label)
+        lay.addStretch(1)
+        return w
+
+    def _rebuild_probes(self):
+        prev = {k: cb.isChecked() for k, cb in self.probe_checks.items()}
+        while self.probe_grid.count():
+            item = self.probe_grid.takeAt(0)
+            if item.widget():
+                item.widget().deleteLater()
+        self.probe_checks = {}
+        for i, src in enumerate(system_monitor.discover_sources()):
+            cb = QCheckBox(src)
+            cb.setChecked(prev.get(src, True))
+            cb.toggled.connect(self._probes_changed)
+            self.probe_checks[src] = cb
+            self.probe_grid.addWidget(cb, i // 4, i % 4)
+
+    def _probes_changed(self):
+        self.monitor.enabled_probes = {
+            k for k, cb in self.probe_checks.items() if cb.isChecked()}
+        self.save_app_config()
+
+    def toggle_monitor(self, on):
+        if on:
+            self.monitor.interval = self.interval_spin.value()
+            self._probes_changed()
+            if not self.monitor.isRunning():
+                self.monitor.running = True
+                self.monitor.start()
+            self.btn_monitor.setText("Stop automation")
+            self.log("automation started")
+        else:
+            self.monitor.stop()
+            self.btn_monitor.setText("Start automation")
+            self.log("automation stopped")
+
+    def on_statuses(self, values):
+        if "_error" in values:
+            self.log(f"monitor error: {values['_error']}")
+            return
+        self.live_label.setText("Live values: " + json.dumps(values))
+        if not self.link.connected:
+            return
+        cam = values.pop("cam", None)
+        self.link.send({"cmd": "status", "values": values},
+                       expect_reply=False)
+        # edge-triggered camera state: send BOTH transitions (fixes stuck-on)
+        if cam is not None and self.cam_auto_chk.isChecked() \
+                and not self.cam_test_btn.isChecked():
+            if cam != self._last_cam:
+                self._last_cam = cam
+                self.send_logged({"cmd": "camera", "active": bool(cam)})
+
+    def toggle_notify_watcher(self, on):
+        if on:
+            if self.notify_watcher is None:
+                self.notify_watcher = system_monitor.NotificationWatcher(
+                    self.notification_seen.emit)
+                self.notify_watcher.start()
+                self.log("notification watcher started (needs dbus-monitor)")
+        elif self.notify_watcher:
+            self.notify_watcher.stop()
+            self.notify_watcher = None
+            self.log("notification watcher stopped")
+        self.save_app_config()
+
+    def fire_notification(self):
+        self.link.send({"cmd": "notify", "color": self.notify_color.rgbw(),
+                        "period": self.notify_period.value(),
+                        "count": self.notify_blinks.value()},
+                       expect_reply=False)
+
+    # ---------------- Test tab ----------------
+
+    def _build_test_tab(self):
+        w = QWidget()
+        lay = QVBoxLayout(w)
+        lay.addWidget(QLabel(
+            "Raw fill pauses polling until you exit test mode; fake-status "
+            f"buttons pause polling for {self.FAKE_PAUSE_S} s so the value "
+            "stays visible."))
+        self.test_color = ColorButton((0, 255, 0, 0))
+        row = QHBoxLayout()
+        row.addWidget(QLabel("Test color:"))
+        row.addWidget(self.test_color)
+        row.addStretch(1)
+        lay.addLayout(row)
+
+        grid = QGridLayout()
+        fakes = [
+            ("Fake CPU 90% / GPU 60%", {"cpu": 0.9, "gpu": 0.6}),
+            ("Fake CPU 85 °C / GPU 70 °C", {"cpu_temp": 85.0,
+                                            "gpu_temp": 70.0}),
+            ("Fake 10G link", {"eth": 10000}),
+            ("Fake eth inactive", {"eth": 0}),
+            ("Fake Wi-Fi 6 GHz", {"wifi": 6}),
+            ("Fake VPN on", {"vpn": True}),
+            ("Fake disk 85% full", {"disk": 0.85}),
+            ("Fake disk 55 °C", {"disk_temp": 55.0}),
+        ]
+        buttons = [
+            ("Fire notification", self.fire_notification),
+            ("Clear notification",
+             lambda: self.send_logged({"cmd": "notify_clear"})),
+            ("Fill all (test color)", self.test_fill),
+            ("Exit raw/test mode", self.test_raw_off),
+        ] + [(label, lambda _=None, v=vals: self.fake_status(v))
+             for label, vals in fakes]
+        for i, (label, fn) in enumerate(buttons):
+            b = QPushButton(label)
+            b.clicked.connect(fn)
+            grid.addWidget(b, i // 2, i % 2)
+        lay.addLayout(grid)
+        lay.addStretch(1)
+        return w
+
+    def fake_status(self, values):
+        self.pause_polling("fake status")
+        self.fake_resume_timer.start()
+        self.send_logged({"cmd": "status", "values": values, "ack": 1})
+
+    def test_fill(self):
+        self._raw_test = True
+        self.pause_polling("raw fill")
+        self.send_logged({"cmd": "raw",
+                          "pixels": [self.test_color.rgbw()]
+                          * self.num_pixels.value()})
+
+    def test_raw_off(self):
+        self.send_logged({"cmd": "raw_off"})
+        self._raw_test = False
+        self.resume_polling("raw fill")
+
+    # ---------------- Config assembly / persistence ----------------
+
+    def build_config(self):
+        return {
+            "num_pixels": self.num_pixels.value(),
+            "pin": self.pin_edit.text().strip() or "GP0",
+            "pixel_order": self.order_combo.currentText(),
+            "brightness": self.brightness.value() / 100.0,
+            "fps": self.fps_spin.value(),
+            "segments": copy.deepcopy(self.segments),
+            "camera": {"pixels": parse_ranges(self.cam_pixels.text()),
+                       "color": self.cam_color.rgbw(),
+                       "effect": self.cam_effect.currentText(),
+                       "speed": self.cam_speed.value()},
+            "notify": {"start": self.notify_start.value(),
+                       "count": self.notify_count.value(),
+                       "blinks": self.notify_blinks.value(),
+                       "default_color": self.notify_color.rgbw(),
+                       "default_period": self.notify_period.value()},
+        }
+
+    def apply_config_to_ui(self, cfg):
+        self._building = True
+        self.num_pixels.setValue(int(cfg.get("num_pixels", 8)))
+        self.pin_edit.setText(cfg.get("pin", "GP0"))
+        self.order_combo.setCurrentText(cfg.get("pixel_order", "GRBW"))
+        self.brightness.setValue(int(float(cfg.get("brightness", 0.1)) * 100))
+        self.fps_spin.setValue(int(cfg.get("fps", 30)))
+        self.segments = copy.deepcopy(cfg.get("segments", DEFAULT_SEGMENTS))
+        cam = cfg.get("camera", {})
+        pixels = cam.get("pixels")
+        if pixels is None and "start" in cam:      # migrate v2 start/count
+            pixels = list(range(int(cam.get("start", 5)),
+                                int(cam.get("start", 5))
+                                + int(cam.get("count", 3))))
+        self.cam_pixels.setText(format_ranges(pixels or [5, 6, 7]))
+        self.cam_color.set_rgbw(cam.get("color", [255, 40, 0, 0]))
+        self.cam_effect.setCurrentText(cam.get(
+            "effect", "breathe" if cam.get("breathe", True) else "solid"))
+        self.cam_speed.setValue(float(cam.get("speed", 1.0)))
+        nt = cfg.get("notify", {})
+        self.notify_start.setValue(int(nt.get("start", 0)))
+        self.notify_count.setValue(int(nt.get("count", 0)))
+        self.notify_blinks.setValue(int(nt.get("blinks", 6)))
+        self.notify_color.set_rgbw(nt.get("default_color", [0, 60, 255, 0]))
+        self.notify_period.setValue(float(nt.get("default_period", 0.4)))
+        self._building = False
+        self.rebuild_seg_table()
+
+    def _wire_autopush(self):
+        for w in (self.num_pixels, self.fps_spin, self.notify_start,
+                  self.notify_count, self.notify_blinks):
+            w.valueChanged.connect(self.schedule_push)
+        for w in (self.notify_period, self.cam_speed):
+            w.valueChanged.connect(self.schedule_push)
+        self.brightness.valueChanged.connect(self.schedule_push)
+        self.order_combo.currentIndexChanged.connect(self.schedule_push)
+        self.cam_effect.currentIndexChanged.connect(self.schedule_push)
+        self.pin_edit.editingFinished.connect(self.schedule_push)
+        self.cam_pixels.editingFinished.connect(self.schedule_push)
+        self.device_name_edit.editingFinished.connect(self.save_app_config)
+        for cb in (self.cam_color, self.notify_color):
+            cb.changed.connect(self.schedule_push)
+        self.interval_spin.valueChanged.connect(
+            lambda v: (setattr(self.monitor, "interval", v),
+                       self.save_app_config()))
+        self.cam_auto_chk.toggled.connect(lambda _: self.save_app_config())
+
+    def schedule_push(self, *_):
+        if self._building:
+            return
+        self.push_timer.start()
+
+    def _auto_push(self):
+        self.save_app_config()
+        if not self.validate_segments():
+            self.log("auto-push blocked: fix the flagged zone issues")
+            return
+        if self.link.connected:
+            reply = self.link.send(
+                {"cmd": "config", "config": self.build_config()})
+            self.log(f"auto-push -> {reply}")
+
+    def push_config(self):
+        if not self.validate_segments():
+            self.log("push blocked: fix the flagged zone issues")
+            return
+        self.log("push config -> "
+                 f"{self.link.send({'cmd': 'config', 'config': self.build_config()})}")
+        self.save_app_config()
+
+    def save_app_config(self):
+        data = {
+            "device": self.build_config(),
+            "connection": {
+                "device_name": self.device_name_edit.text().strip()
+                or DEFAULT_DEVICE_NAME,
+                "last_port": self._last_port,
+            },
+            "ui": {
+                "interval": self.interval_spin.value(),
+                "probes": {k: cb.isChecked()
+                           for k, cb in self.probe_checks.items()},
+                "cam_auto": self.cam_auto_chk.isChecked(),
+                "notify_watch": self.notify_watch_chk.isChecked(),
+            },
+        }
+        try:
+            with open(app_config_path(), "w") as f:
+                json.dump(data, f, indent=2)
+        except OSError as e:
+            self.log(f"config save failed: {e}")
+
+    def load_app_config(self):
+        try:
+            with open(app_config_path()) as f:
+                data = json.load(f)
+        except (OSError, ValueError):
+            return
+        self.apply_config_to_ui(data.get("device", {}))
+        conn = data.get("connection", {})
+        self.device_name_edit.setText(
+            conn.get("device_name") or DEFAULT_DEVICE_NAME)
+        self._last_port = conn.get("last_port")
+        ui = data.get("ui", {})
+        self.interval_spin.setValue(float(ui.get("interval", 1.0)))
+        for k, on in ui.get("probes", {}).items():
+            if k in self.probe_checks:
+                self.probe_checks[k].setChecked(bool(on))
+        self.cam_auto_chk.setChecked(bool(ui.get("cam_auto", True)))
+        self.notify_watch_chk.setChecked(bool(ui.get("notify_watch", False)))
+        self.log(f"loaded config from {app_config_path()}")
+
+    def save_profile(self):
+        path, _ = QFileDialog.getSaveFileName(
+            self, "Export profile", "luxstats_profile.json", "JSON (*.json)")
+        if path:
+            with open(path, "w") as f:
+                json.dump(self.build_config(), f, indent=2)
+            self.log(f"profile exported: {path}")
+
+    def load_profile(self):
+        path, _ = QFileDialog.getOpenFileName(
+            self, "Import profile", "", "JSON (*.json)")
+        if path:
+            try:
+                with open(path) as f:
+                    self.apply_config_to_ui(json.load(f))
+                self.schedule_push()
+                self.log(f"profile imported: {path}")
+            except (OSError, ValueError) as e:
+                QMessageBox.warning(self, "Import failed", str(e))
+
+    def closeEvent(self, event):
+        self.save_app_config()
+        self._stop_range_test()
+        self.monitor.stop()
+        self.monitor.wait(1500)
+        if self.notify_watcher:
+            self.notify_watcher.stop()
+        self.link.close()
+        event.accept()
+
+
+def main():
+    app = QApplication(sys.argv)
+    win = MainWindow()
+    win.show()
+    sys.exit(app.exec_())
+
+
+if __name__ == "__main__":
+    main()

+ 3 - 0
host/requirements.txt

@@ -0,0 +1,3 @@
+PyQt5>=5.15
+pyserial>=3.5
+psutil>=5.9

+ 482 - 0
host/system_monitor.py

@@ -0,0 +1,482 @@
+"""LuxStats system monitor v3.
+
+Sources (all toggleable in the configurator):
+
+  cpu, gpu, ram         0-1 utilisation
+  cpu_temp, gpu_temp    RAW Celsius (not normalised)
+  eth                   wired link speed in Mbps; 0 unless the link is
+                        actually ACTIVE (carrier up + operstate up)
+  wifi                  band GHz (0/2/5/6)
+  vpn, cam              bool
+  disk_<dev>            0-1 usage per detected physical drive
+  disk_<dev>_temp       RAW Celsius where a nvme/drivetemp sensor exists
+  disk / disk_temp      aggregates (max usage / max temp)
+
+Camera detection ignores media daemons (PipeWire/WirePlumber/PulseAudio)
+that hold /dev/video* open permanently on modern Arch/GNOME setups - only a
+"real" consumer (browser, OBS, Zoom, ...) counts as camera-in-use.
+
+Slow probes are cached with per-probe minimum ages so the configurator can
+poll at 100 ms without spawning subprocesses at 10 Hz.
+"""
+
+import glob
+import os
+import re
+import shutil
+import subprocess
+import sys
+import threading
+import time
+
+import psutil
+
+VPN_IF_PATTERNS = re.compile(r"^(tun|tap|wg|ppp|tailscale|zt|nordlynx|proton)", re.I)
+VIRTUAL_IF_PATTERNS = re.compile(
+    r"^(lo|docker|veth|br-|virbr|vmnet|vbox|kube|flannel|cni)", re.I
+)
+
+# Media stacks that keep camera devices open without actually using them.
+# /proc/<pid>/comm is truncated to 15 chars, so compare prefixes.
+CAMERA_IGNORE_PREFIXES = (
+    "pipewire", "wireplumber", "pulseaudio", "v4l2-relayd",
+    "pipewire-media", "pipewire-pulse",
+)
+
+# Minimum seconds between real reads of each slow probe (values are cached
+# in between so 100 ms polling stays cheap).
+PROBE_MIN_AGE = {
+    "cpu_temp": 1.0, "gpu": 1.0, "wifi": 1.0, "cam": 0.5,
+    "eth": 0.3, "drives_usage": 2.0, "drives_temp": 2.0,
+}
+
+_cache = {}
+
+
+def _cached(key, fn):
+    now = time.monotonic()
+    ent = _cache.get(key)
+    if ent and now - ent[0] < PROBE_MIN_AGE.get(key, 0.0):
+        return ent[1]
+    val = fn()
+    _cache[key] = (now, val)
+    return val
+
+
+def _run(cmd, timeout=2):
+    try:
+        out = subprocess.run(cmd, capture_output=True, text=True,
+                             timeout=timeout)
+        return out.stdout
+    except (OSError, subprocess.SubprocessError):
+        return ""
+
+
+def _wireless_ifaces():
+    if sys.platform.startswith("linux"):
+        return [os.path.basename(os.path.dirname(p))
+                for p in glob.glob("/sys/class/net/*/wireless")]
+    return []
+
+
+def _sensors():
+    try:
+        return psutil.sensors_temperatures()
+    except (AttributeError, OSError):
+        return {}
+
+
+# --------------------------------------------------------------------------
+# CPU / RAM / temps (raw Celsius)
+# --------------------------------------------------------------------------
+
+def cpu_percent():
+    return psutil.cpu_percent(interval=None) / 100.0
+
+
+def ram_percent():
+    return psutil.virtual_memory().percent / 100.0
+
+
+def cpu_temp():
+    """Raw CPU temperature in Celsius, or None."""
+    temps = _sensors()
+    for key in ("coretemp", "k10temp", "zenpower", "cpu_thermal", "acpitz"):
+        if key in temps and temps[key]:
+            return float(temps[key][0].current)
+    return None
+
+
+# --------------------------------------------------------------------------
+# GPU (NVIDIA via nvidia-smi, AMD via sysfs/hwmon) - temp is raw Celsius
+# --------------------------------------------------------------------------
+
+_NVSMI = shutil.which("nvidia-smi")
+
+
+def _nvidia_query():
+    if not _NVSMI:
+        return None
+    out = _run([_NVSMI, "--query-gpu=utilization.gpu,temperature.gpu",
+                "--format=csv,noheader,nounits"], timeout=3)
+    m = re.match(r"\s*(\d+)\s*,\s*(\d+)", out or "")
+    if m:
+        return int(m.group(1)) / 100.0, float(m.group(2))
+    return None
+
+
+def _amd_util():
+    for path in glob.glob("/sys/class/drm/card*/device/gpu_busy_percent"):
+        try:
+            with open(path) as f:
+                return int(f.read().strip()) / 100.0
+        except (OSError, ValueError):
+            continue
+    return None
+
+
+def gpu_stats():
+    """Return (util 0-1 or None, temp raw Celsius or None)."""
+    nv = _nvidia_query()
+    if nv:
+        return nv
+    util = _amd_util()
+    temp = None
+    temps = _sensors()
+    for key in ("amdgpu", "radeon", "nouveau"):
+        if key in temps and temps[key]:
+            temp = float(temps[key][0].current)
+            break
+    return util, temp
+
+
+# --------------------------------------------------------------------------
+# Drives - usage per physical drive + raw Celsius temps
+# --------------------------------------------------------------------------
+
+def _device_base(device):
+    name = os.path.basename(device.rstrip("\\/"))
+    if sys.platform == "win32":
+        return name.rstrip(":") or name
+    if name.startswith(("nvme", "mmcblk")):
+        return re.sub(r"p\d+$", "", name)
+    if re.match(r"^(sd[a-z]+|hd[a-z]+|vd[a-z]+|xvd[a-z]+)\d*$", name):
+        return re.sub(r"\d+$", "", name)
+    return name
+
+
+def physical_drives():
+    drives = {}
+    try:
+        parts = psutil.disk_partitions(all=False)
+    except OSError:
+        return drives
+    for p in parts:
+        if not p.device:
+            continue
+        if p.fstype in ("squashfs", "iso9660", "overlay", "tmpfs"):
+            continue
+        drives.setdefault(_device_base(p.device), []).append(p.mountpoint)
+    return drives
+
+
+def _drives_usage():
+    out = {}
+    for d, mounts in physical_drives().items():
+        worst = None
+        for m in mounts:
+            try:
+                worst = max(worst or 0.0,
+                            psutil.disk_usage(m).percent / 100.0)
+            except (OSError, PermissionError):
+                continue
+        if worst is not None:
+            out[d] = worst
+    return out
+
+
+def drives_usage():
+    return _cached("drives_usage", _drives_usage)
+
+
+def _drives_temp():
+    """Best-effort {drive: raw Celsius} from nvme/drivetemp sensors."""
+    temps = _sensors()
+    result = {}
+    drives = sorted(physical_drives())
+    nvme = [d for d in drives if d.startswith("nvme")]
+    sata = [d for d in drives if re.match(r"^(sd|hd)[a-z]+$", d)]
+    for i, entry in enumerate(temps.get("nvme", [])):
+        if i < len(nvme):
+            result[nvme[i]] = float(entry.current)
+    for i, entry in enumerate(temps.get("drivetemp", [])):
+        if i < len(sata):
+            result[sata[i]] = float(entry.current)
+    return result
+
+
+def drives_temp():
+    return _cached("drives_temp", _drives_temp)
+
+
+# --------------------------------------------------------------------------
+# Network - eth requires an ACTIVE link (carrier), not just a reported speed
+# --------------------------------------------------------------------------
+
+def _iface_active(name):
+    """True only when the interface has a live link."""
+    if sys.platform.startswith("linux"):
+        try:
+            with open(f"/sys/class/net/{name}/carrier") as f:
+                if f.read().strip() != "1":
+                    return False
+        except OSError:
+            return False
+        try:
+            with open(f"/sys/class/net/{name}/operstate") as f:
+                return f.read().strip() in ("up", "unknown")
+        except OSError:
+            return False
+    st = psutil.net_if_stats().get(name)
+    return bool(st and st.isup)
+
+
+def _eth_speed():
+    """Best speed (Mbps) among ACTIVE wired links; 0 when nothing is live."""
+    wireless = set(_wireless_ifaces())
+    best = 0
+    for name, st in psutil.net_if_stats().items():
+        if name in wireless:
+            continue
+        if VPN_IF_PATTERNS.match(name) or VIRTUAL_IF_PATTERNS.match(name):
+            continue
+        if not st.isup or not st.speed or st.speed <= 0:
+            continue
+        if not _iface_active(name):
+            continue
+        best = max(best, st.speed)
+    return best
+
+
+def eth_speed():
+    return _cached("eth", _eth_speed)
+
+
+def _wifi_band():
+    freq_mhz = 0
+    if sys.platform.startswith("linux"):
+        iw = shutil.which("iw")
+        for iface in _wireless_ifaces():
+            if not _iface_active(iface):
+                continue
+            if iw:
+                m = re.search(r"freq:\s*(\d+)",
+                              _run([iw, "dev", iface, "link"]))
+                if m:
+                    freq_mhz = max(freq_mhz, int(m.group(1)))
+        if not freq_mhz and shutil.which("nmcli"):
+            out = _run(["nmcli", "-t", "-f", "ACTIVE,FREQ", "dev", "wifi"])
+            for line in out.splitlines():
+                if line.startswith(("yes:", "sí:", "oui:")):
+                    m = re.search(r"(\d+)\s*MHz", line)
+                    if m:
+                        freq_mhz = max(freq_mhz, int(m.group(1)))
+    elif sys.platform == "win32":
+        out = _run(["netsh", "wlan", "show", "interfaces"])
+        m = re.search(r"Band\s*:\s*(\d+(?:\.\d+)?)\s*GHz", out)
+        if m:
+            return {2.4: 2}.get(float(m.group(1)), int(float(m.group(1))))
+        m = re.search(r"Channel\s*:\s*(\d+)", out)
+        if m:
+            return 2 if int(m.group(1)) <= 14 else 5
+    if freq_mhz >= 5925:
+        return 6
+    if freq_mhz >= 4900:
+        return 5
+    if freq_mhz >= 2400:
+        return 2
+    return 0
+
+
+def wifi_band():
+    return _cached("wifi", _wifi_band)
+
+
+def vpn_active():
+    for name, st in psutil.net_if_stats().items():
+        if st.isup and VPN_IF_PATTERNS.match(name):
+            return True
+    return False
+
+
+# --------------------------------------------------------------------------
+# Camera - ignore media daemons that hold /dev/video* open permanently
+# --------------------------------------------------------------------------
+
+def _pid_comm(pid):
+    try:
+        with open(f"/proc/{pid}/comm") as f:
+            return f.read().strip()
+    except OSError:
+        return ""
+
+
+def _camera_holder_pids(devices):
+    pids = set()
+    fuser = shutil.which("fuser")
+    if fuser:
+        for tok in _run([fuser] + devices).split():
+            tok = tok.strip().rstrip("mrce")   # fuser access-mode suffixes
+            if tok.isdigit():
+                pids.add(tok)
+        return pids
+    try:
+        for pid in filter(str.isdigit, os.listdir("/proc")):
+            fd_dir = f"/proc/{pid}/fd"
+            try:
+                for fd in os.listdir(fd_dir):
+                    if os.readlink(os.path.join(fd_dir, fd)) \
+                            .startswith("/dev/video"):
+                        pids.add(pid)
+                        break
+            except OSError:
+                continue
+    except OSError:
+        pass
+    return pids
+
+
+def _camera_active():
+    if not sys.platform.startswith("linux"):
+        return None
+    devices = glob.glob("/dev/video*")
+    if not devices:
+        return False
+    for pid in _camera_holder_pids(devices):
+        comm = _pid_comm(pid).lower()
+        if comm and not comm.startswith(CAMERA_IGNORE_PREFIXES):
+            return True
+    return False
+
+
+def camera_active():
+    return _cached("cam", _camera_active)
+
+
+# --------------------------------------------------------------------------
+# Source registry + collection
+# --------------------------------------------------------------------------
+
+BASE_SOURCES = ["cpu", "cpu_temp", "gpu", "gpu_temp", "ram",
+                "eth", "wifi", "vpn", "cam"]
+
+
+def discover_sources():
+    sources = list(BASE_SOURCES)
+    drives = physical_drives()
+    temps = drives_temp()
+    sources.append("disk")
+    if temps:
+        sources.append("disk_temp")
+    for d in sorted(drives):
+        sources.append(f"disk_{d}")
+        if d in temps:
+            sources.append(f"disk_{d}_temp")
+    return sources
+
+
+def collect(enabled=None):
+    def want(key):
+        return enabled is None or key in enabled
+
+    values = {}
+    if want("cpu"):
+        values["cpu"] = round(cpu_percent(), 3)
+    if want("cpu_temp"):
+        t = _cached("cpu_temp", cpu_temp)
+        if t is not None:
+            values["cpu_temp"] = round(t, 1)
+    if want("gpu") or want("gpu_temp"):
+        util, temp = _cached("gpu", gpu_stats)
+        if want("gpu") and util is not None:
+            values["gpu"] = round(util, 3)
+        if want("gpu_temp") and temp is not None:
+            values["gpu_temp"] = round(temp, 1)
+    if want("ram"):
+        values["ram"] = round(ram_percent(), 3)
+    if want("eth"):
+        values["eth"] = eth_speed()
+    if want("wifi"):
+        values["wifi"] = wifi_band()
+    if want("vpn"):
+        values["vpn"] = vpn_active()
+    if want("cam"):
+        cam = camera_active()
+        if cam is not None:
+            values["cam"] = cam
+
+    usage = drives_usage() if (want("disk") or any(
+        want(f"disk_{d}") for d in physical_drives())) else {}
+    temps = drives_temp() if (want("disk_temp") or any(
+        want(f"disk_{d}_temp") for d in physical_drives())) else {}
+    agg_u, agg_t = None, None
+    for d, u in usage.items():
+        if want(f"disk_{d}"):
+            values[f"disk_{d}"] = round(u, 3)
+        agg_u = max(agg_u or 0.0, u)
+    for d, t in temps.items():
+        if want(f"disk_{d}_temp"):
+            values[f"disk_{d}_temp"] = round(t, 1)
+        agg_t = max(agg_t or 0.0, t)
+    if want("disk") and agg_u is not None:
+        values["disk"] = round(agg_u, 3)
+    if want("disk_temp") and agg_t is not None:
+        values["disk_temp"] = round(agg_t, 1)
+    return values
+
+
+class NotificationWatcher(threading.Thread):
+    """Fires `callback()` on any desktop notification (Linux, dbus-monitor)."""
+
+    MATCH = "interface='org.freedesktop.Notifications',member='Notify'"
+
+    def __init__(self, callback):
+        super().__init__(daemon=True)
+        self.callback = callback
+        self.proc = None
+        self._stop = threading.Event()
+
+    def run(self):
+        if not shutil.which("dbus-monitor"):
+            return
+        try:
+            self.proc = subprocess.Popen(
+                ["dbus-monitor", self.MATCH],
+                stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
+            for line in self.proc.stdout:
+                if self._stop.is_set():
+                    break
+                if "member=Notify" in line:
+                    try:
+                        self.callback()
+                    except Exception:
+                        pass
+        except OSError:
+            pass
+
+    def stop(self):
+        self._stop.set()
+        if self.proc:
+            try:
+                self.proc.terminate()
+            except OSError:
+                pass
+
+
+if __name__ == "__main__":
+    import json
+
+    psutil.cpu_percent(interval=None)
+    time.sleep(0.3)
+    print("sources:", discover_sources())
+    print(json.dumps(collect(), indent=2))

+ 110 - 0
luxstats.sh

@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+# LuxStats background launcher for Arch Linux
+#
+# * Auto-detects every installed CPython 3 and uses the newest one
+# * Runs WITHOUT a venv when the system python already has the deps
+#   (pacman: python-pyqt5 python-pyserial python-psutil)
+# * Otherwise creates/uses a managed venv at
+#   ${XDG_DATA_HOME:-~/.local/share}/luxstats/venv and installs deps there
+# * Launches the configurator detached from the terminal, logging to
+#   ${XDG_STATE_HOME:-~/.local/state}/luxstats/luxstats.log
+#
+# Usage:  ./luxstats.sh            start in the background
+#         ./luxstats.sh stop      stop a running instance
+#         ./luxstats.sh status    show whether it's running
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# works both from the repo root (host/ beside this script) and from packaging/
+if [[ -f "$SCRIPT_DIR/host/luxstats_configurator.py" ]]; then
+    APP="$SCRIPT_DIR/host/luxstats_configurator.py"
+elif [[ -f "$SCRIPT_DIR/../host/luxstats_configurator.py" ]]; then
+    APP="$(cd "$SCRIPT_DIR/../host" && pwd)/luxstats_configurator.py"
+else
+    echo "luxstats: cannot locate host/luxstats_configurator.py" >&2
+    exit 1
+fi
+
+DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/luxstats"
+STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/luxstats"
+VENV="$DATA_DIR/venv"
+LOG="$STATE_DIR/luxstats.log"
+PIDFILE="$STATE_DIR/luxstats.pid"
+DEPS_IMPORT='import PyQt5, serial, psutil'
+mkdir -p "$DATA_DIR" "$STATE_DIR"
+
+find_newest_python() {
+    local best="" bestver=0 py ver base
+    # scan PATH + common locations for pythonN.M binaries
+    for py in $(compgen -c python3 | sort -u) /usr/bin/python3 \
+              /usr/local/bin/python3.*; do
+        base="$(basename "$py" 2>/dev/null || true)"
+        [[ "$base" =~ ^python3(\.[0-9]+)?$ ]] || continue
+        command -v "$py" >/dev/null 2>&1 || [[ -x "$py" ]] || continue
+        ver="$("$py" -c 'import sys;print(sys.version_info[0]*1000+sys.version_info[1])' \
+              2>/dev/null)" || continue
+        if (( ver > bestver )); then bestver=$ver; best="$py"; fi
+    done
+    [[ -n "$best" ]] || { echo "luxstats: no python3 found" >&2; return 1; }
+    echo "$best"
+}
+
+pick_interpreter() {
+    local sys_py; sys_py="$(find_newest_python)"
+    echo "using system interpreter candidate: $sys_py" \
+         "($("$sys_py" -V 2>&1))" >&2
+    if "$sys_py" -c "$DEPS_IMPORT" 2>/dev/null; then
+        echo "$sys_py"                       # no venv needed
+        return
+    fi
+    echo "system python lacks PyQt5/pyserial/psutil - using managed venv" >&2
+    echo "(tip: 'sudo pacman -S python-pyqt5 python-pyserial python-psutil'" \
+         "avoids the venv entirely)" >&2
+    if [[ ! -x "$VENV/bin/python" ]] \
+            || ! "$VENV/bin/python" -c "$DEPS_IMPORT" 2>/dev/null; then
+        "$sys_py" -m venv --clear "$VENV"
+        "$VENV/bin/pip" install --quiet --upgrade pip
+        "$VENV/bin/pip" install --quiet PyQt5 pyserial psutil
+    fi
+    echo "$VENV/bin/python"
+}
+
+is_running() {
+    [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null
+}
+
+case "${1:-start}" in
+    start)
+        if is_running; then
+            echo "luxstats already running (pid $(cat "$PIDFILE"))"
+            exit 0
+        fi
+        PY="$(pick_interpreter)"
+        echo "launching with $PY"
+        setsid "$PY" "$APP" >>"$LOG" 2>&1 < /dev/null &
+        echo $! > "$PIDFILE"
+        disown
+        echo "luxstats started in the background (pid $(cat "$PIDFILE"))"
+        echo "log: $LOG"
+        ;;
+    stop)
+        if is_running; then
+            kill "$(cat "$PIDFILE")" && rm -f "$PIDFILE"
+            echo "luxstats stopped"
+        else
+            echo "luxstats is not running"
+        fi
+        ;;
+    status)
+        if is_running; then
+            echo "running (pid $(cat "$PIDFILE"))"
+        else
+            echo "not running"
+        fi
+        ;;
+    *)
+        echo "usage: $0 [start|stop|status]" >&2
+        exit 1
+        ;;
+esac

+ 22 - 0
packaging/luxstats.service

@@ -0,0 +1,22 @@
+# Optional systemd USER unit to start LuxStats with your desktop session.
+# Install:
+#   mkdir -p ~/.config/systemd/user
+#   cp luxstats.service ~/.config/systemd/user/
+#   # edit ExecStart below to your checkout path, then:
+#   systemctl --user enable --now luxstats.service
+[Unit]
+Description=LuxStats status-LED configurator
+After=graphical-session.target
+PartOf=graphical-session.target
+
+[Service]
+Type=forking
+# EDIT THIS PATH to where you keep the repo:
+ExecStart=%h/luxstats/luxstats.sh start
+ExecStop=%h/luxstats/luxstats.sh stop
+PIDFile=%S/luxstats/luxstats.pid
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=graphical-session.target