From c61fe1a5c498db496b4bb1471b6d1ed3b06ce499 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 18 May 2026 16:05:11 -0400 Subject: [PATCH] Inital! --- .gitignore | 2 + README.md | 10 ++ patlite_mqtt/__init__.py | 3 + patlite_mqtt/__main__.py | 3 + patlite_mqtt/channels.py | 174 +++++++++++++++++++++++++++++ patlite_mqtt/daemon.py | 184 +++++++++++++++++++++++++++++++ patlite_mqtt/device.py | 57 ++++++++++ requirements.txt | 2 + scripts/install-systemd.sh | 80 ++++++++++++++ scripts/install-udev.sh | 25 +++++ systemd/patlite-mqtt.env.example | 11 ++ systemd/patlite-mqtt.service.in | 22 ++++ udev/99-patlite.rules | 9 ++ 13 files changed, 582 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 patlite_mqtt/__init__.py create mode 100644 patlite_mqtt/__main__.py create mode 100644 patlite_mqtt/channels.py create mode 100644 patlite_mqtt/daemon.py create mode 100644 patlite_mqtt/device.py create mode 100644 requirements.txt create mode 100755 scripts/install-systemd.sh create mode 100755 scripts/install-udev.sh create mode 100644 systemd/patlite-mqtt.env.example create mode 100644 systemd/patlite-mqtt.service.in create mode 100644 udev/99-patlite.rules diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..033df5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.venv +__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e5d1be --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# Patlite MQTT + +I picked up one of these cool lights at a hamfest in my area, and it works! You can do +almost anything with these things pretty much. + +Figured the simplest way to get it up and running was to just make it controllable over mqtt, seemed like fun! +To make it work with multiple things changing, I just allow the daemon to be configured to do +one channel per color so they can be set independtantly. + +Modes are `OFF`, `ON`, `FAST_FLASH` or `SLOW_FLASH` diff --git a/patlite_mqtt/__init__.py b/patlite_mqtt/__init__.py new file mode 100644 index 0000000..a32fb27 --- /dev/null +++ b/patlite_mqtt/__init__.py @@ -0,0 +1,3 @@ +"""Patlite tower light controlled over MQTT.""" + +__version__ = "0.1.0" diff --git a/patlite_mqtt/__main__.py b/patlite_mqtt/__main__.py new file mode 100644 index 0000000..539142d --- /dev/null +++ b/patlite_mqtt/__main__.py @@ -0,0 +1,3 @@ +from patlite_mqtt.daemon import main + +raise SystemExit(main()) diff --git a/patlite_mqtt/channels.py b/patlite_mqtt/channels.py new file mode 100644 index 0000000..67c9c22 --- /dev/null +++ b/patlite_mqtt/channels.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import enum +import threading +from typing import Callable + +from patlite_mqtt.device import PatliteDevice + + +class Mode(enum.Enum): + OFF = "OFF" + ON = "ON" + FAST_FLASH = "FAST_FLASH" + SLOW_FLASH = "SLOW_FLASH" + + @classmethod + def parse(cls, value: str) -> "Mode": + try: + return cls(value.strip().upper()) + except ValueError as exc: + raise ValueError( + f"unknown mode {value!r}; expected OFF, ON, FAST_FLASH, or SLOW_FLASH" + ) from exc + + +class ColorChannel: + """One MQTT-controlled color; flash modes share a global on/off gate.""" + + def __init__( + self, + name: str, + apply_lit: Callable[[bool, int, int, int], tuple[int, int, int]], + controller: PatliteController, + ): + self.name = name + self._apply_lit = apply_lit + self._controller = controller + self._lock = threading.Lock() + self._mode = Mode.OFF + + @property + def mode(self) -> Mode: + with self._lock: + return self._mode + + def set_mode(self, mode: Mode): + with self._lock: + if mode == self._mode: + return + self._mode = mode + self._controller.sync_flash() + self._controller.refresh() + + def contribute(self, ry: int, gb: int, w: int) -> tuple[int, int, int]: + with self._lock: + if self._mode == Mode.OFF: + return ry, gb, w + if self._mode == Mode.ON: + lit = True + else: + lit = self._controller.flash_lit + return self._apply_lit(lit, ry, gb, w) + + def shutdown(self): + with self._lock: + self._mode = Mode.OFF + + +class PatliteController: + def __init__( + self, + device: PatliteDevice, + *, + fast_delay: float = 0.25, + slow_delay: float = 0.5, + ): + self._device = device + self._fast_delay = fast_delay + self._slow_delay = slow_delay + self._lock = threading.Lock() + self._flash_lock = threading.Lock() + self._flash_lit = False + self._flash_stop = threading.Event() + self._flash_thread: threading.Thread | None = None + self.channels = { + "red": ColorChannel( + "red", + lambda lit, ry, gb, w: (ry | 0x10, gb, w) if lit else (ry, gb, w), + self, + ), + "yellow": ColorChannel( + "yellow", + lambda lit, ry, gb, w: (ry | 0x01, gb, w) if lit else (ry, gb, w), + self, + ), + "green": ColorChannel( + "green", + lambda lit, ry, gb, w: (ry, gb | 0x10, w) if lit else (ry, gb, w), + self, + ), + "blue": ColorChannel( + "blue", + lambda lit, ry, gb, w: (ry, gb | 0x01, w) if lit else (ry, gb, w), + self, + ), + "white": ColorChannel( + "white", + lambda lit, ry, gb, w: (ry, gb, 0x01) if lit else (ry, gb, w), + self, + ), + } + + @property + def flash_lit(self) -> bool: + with self._flash_lock: + return self._flash_lit + + def _any_flashers(self) -> bool: + return any( + ch.mode in (Mode.FAST_FLASH, Mode.SLOW_FLASH) + for ch in self.channels.values() + ) + + def _flash_delay(self) -> float: + if any(ch.mode == Mode.FAST_FLASH for ch in self.channels.values()): + return self._fast_delay + return self._slow_delay + + def sync_flash(self): + with self._flash_lock: + if not self._any_flashers(): + self._stop_flash_locked() + self._flash_lit = False + return + if self._flash_thread is None: + self._flash_lit = False + self._flash_stop.clear() + self._flash_thread = threading.Thread( + target=self._flash_loop, + name="patlite-flash", + daemon=True, + ) + self._flash_thread.start() + + def _flash_loop(self): + while not self._flash_stop.wait(self._flash_delay()): + with self._flash_lock: + if not self._any_flashers(): + return + self._flash_lit = not self._flash_lit + self.refresh() + + def _stop_flash_locked(self): + self._flash_stop.set() + thread = self._flash_thread + self._flash_thread = None + if thread is not None and thread is not threading.current_thread(): + thread.join(timeout=2.0) + self._flash_stop.clear() + + def refresh(self): + with self._lock: + ry, gb, w = 0, 0, 0 + for channel in self.channels.values(): + ry, gb, w = channel.contribute(ry, gb, w) + self._device.send(ry, gb, w) + + def shutdown(self): + for channel in self.channels.values(): + channel.shutdown() + with self._flash_lock: + self._stop_flash_locked() + self._flash_lit = False + self.refresh() diff --git a/patlite_mqtt/daemon.py b/patlite_mqtt/daemon.py new file mode 100644 index 0000000..7ccd45e --- /dev/null +++ b/patlite_mqtt/daemon.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import argparse +import logging +import os +import signal +import sys + +import paho.mqtt.client as mqtt +from usb.core import USBError + +from patlite_mqtt.channels import Mode, PatliteController +from patlite_mqtt.device import PatliteDevice + +LOG = logging.getLogger("patlite_mqtt") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Patlite tower light MQTT daemon") + parser.add_argument( + "--broker", + default=os.environ.get("MQTT_BROKER", "localhost"), + help="MQTT broker host (env: MQTT_BROKER)", + ) + parser.add_argument( + "--port", + type=int, + default=int(os.environ.get("MQTT_PORT", "1883")), + help="MQTT broker port (env: MQTT_PORT)", + ) + parser.add_argument( + "--username", + default=os.environ.get("MQTT_USERNAME"), + help="MQTT username (env: MQTT_USERNAME)", + ) + parser.add_argument( + "--password", + default=os.environ.get("MQTT_PASSWORD"), + help="MQTT password (env: MQTT_PASSWORD)", + ) + parser.add_argument( + "--prefix", + default=os.environ.get("MQTT_PREFIX", "patlite"), + help="Topic prefix; channels are / (env: MQTT_PREFIX)", + ) + parser.add_argument( + "--client-id", + default=os.environ.get("MQTT_CLIENT_ID", "patlite-mqtt"), + help="MQTT client id (env: MQTT_CLIENT_ID)", + ) + parser.add_argument( + "--fast-delay", + type=float, + default=float(os.environ.get("FAST_FLASH_DELAY", "0.25")), + help="Seconds per half-cycle for FAST_FLASH (env: FAST_FLASH_DELAY)", + ) + parser.add_argument( + "--slow-delay", + type=float, + default=float(os.environ.get("SLOW_FLASH_DELAY", "0.5")), + help="Seconds per half-cycle for SLOW_FLASH (env: SLOW_FLASH_DELAY)", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable debug logging", + ) + return parser.parse_args(argv) + + +class PatliteMqttDaemon: + def __init__(self, args: argparse.Namespace, controller: PatliteController): + self._args = args + self._controller = controller + self._client = mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, + client_id=args.client_id, + ) + if args.username: + self._client.username_pw_set(args.username, args.password or None) + self._client.on_connect = self._on_connect + self._client.on_message = self._on_message + + def _topic(self, color: str) -> str: + return f"{self._args.prefix}/{color}" + + def _on_connect(self, client, userdata, flags, reason_code, properties): + if reason_code != 0: + LOG.error("MQTT connect failed: %s", reason_code) + return + LOG.info("Connected to MQTT broker") + for color in self._controller.channels: + topic = self._topic(color) + client.subscribe(topic) + LOG.info("Subscribed to %s", topic) + + def _on_message(self, client, userdata, message): + color = message.topic.rsplit("/", 1)[-1] + channel = self._controller.channels.get(color) + if channel is None: + LOG.warning("Message on unexpected topic %s", message.topic) + return + + payload = message.payload.decode("utf-8", errors="replace").strip() + try: + mode = Mode.parse(payload) + except ValueError as exc: + LOG.warning("Ignoring %s: %s", message.topic, exc) + return + + LOG.info("%s -> %s", message.topic, mode.value) + channel.set_mode(mode) + + def run(self): + self._client.connect( + self._args.broker, + self._args.port, + keepalive=60, + ) + self._client.loop_forever() + + def stop(self): + self._client.loop_stop() + self._client.disconnect() + + +def main(argv: list[str] | None = None) -> int: + try: + args = parse_args(argv) + except (ValueError, TypeError) as exc: + print(f"Invalid configuration: {exc}", file=sys.stderr) + return 1 + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + ) + + device = PatliteDevice() + try: + LOG.info("Opening Patlite USB device") + device.connect() + except (RuntimeError, USBError) as exc: + LOG.error("%s", exc) + return 1 + + controller = PatliteController( + device, + fast_delay=args.fast_delay, + slow_delay=args.slow_delay, + ) + daemon = PatliteMqttDaemon(args, controller) + + def handle_signal(signum, frame): + LOG.info("Shutting down (signal %s)", signum) + daemon.stop() + controller.shutdown() + device.close() + sys.exit(0) + + signal.signal(signal.SIGINT, handle_signal) + signal.signal(signal.SIGTERM, handle_signal) + + try: + LOG.info( + "Connecting to MQTT broker %s:%s (client_id=%s)", + args.broker, + args.port, + args.client_id, + ) + daemon.run() + except Exception as exc: + LOG.error("MQTT connection failed: %s", exc) + return 1 + finally: + controller.shutdown() + device.close() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/patlite_mqtt/device.py b/patlite_mqtt/device.py new file mode 100644 index 0000000..a97b5c1 --- /dev/null +++ b/patlite_mqtt/device.py @@ -0,0 +1,57 @@ +import errno +import struct + +import usb.core +import usb.util +from usb.core import USBError + +VENDOR_ID = 0x191A +PRODUCT_ID = 0x8003 +ENDPOINT = 1 + + +class PatliteDevice: + def __init__(self): + self._dev = None + + def connect(self): + dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID) + if dev is None: + raise RuntimeError( + f"Patlite not found (vendor={VENDOR_ID:#06x}, product={PRODUCT_ID:#06x})" + ) + + try: + if dev.is_kernel_driver_active(0): + dev.detach_kernel_driver(0) + dev.set_configuration() + except USBError as exc: + if exc.errno == errno.EACCES: + raise RuntimeError( + "USB access denied. On headless hosts, run ./scripts/install-udev.sh, " + "then 'newgrp patlite' (or re-login), unplug/replug the Patlite, and retry." + ) from exc + raise + + self._dev = dev + + def close(self): + if self._dev is not None: + self.send(0x00, 0x00, 0x00) + usb.util.dispose_resources(self._dev) + self._dev = None + + def send(self, ry: int, gb: int, w: int): + if self._dev is None: + return + data = struct.pack( + "BBBBBBBx", + 0x00, + 0x00, + 0x0F, + 0x00, + ry & 0xFF, + gb & 0xFF, + w & 0xFF, + ) + self._dev.write(ENDPOINT, data) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..14bc58f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +paho-mqtt>=2.0.0 +pyusb>=1.2.1 diff --git a/scripts/install-systemd.sh b/scripts/install-systemd.sh new file mode 100755 index 0000000..725cfa1 --- /dev/null +++ b/scripts/install-systemd.sh @@ -0,0 +1,80 @@ +#!/bin/sh +set -e + +cd "$(dirname "$0")/.." +INSTALL_DIR="$(pwd)" +SERVICE_USER="${SERVICE_USER:-${SUDO_USER:-$USER}}" +VENV_PYTHON="$INSTALL_DIR/.venv/bin/python" + +if [ "$SERVICE_USER" = "root" ] || [ -z "$SERVICE_USER" ]; then + echo "Set SERVICE_USER or run via sudo as your normal user." >&2 + exit 1 +fi + +if ! getent group patlite >/dev/null; then + echo "Group 'patlite' not found. Run ./scripts/install-udev.sh first." >&2 + exit 1 +fi + +if ! id -nG "$SERVICE_USER" | tr ' ' '\n' | grep -qx patlite; then + echo "Warning: user '$SERVICE_USER' is not in group 'patlite' (USB access may fail)." >&2 + echo "Run: sudo usermod -aG patlite $SERVICE_USER" >&2 +fi + +if ! command -v python3 >/dev/null; then + echo "python3 is required." >&2 + exit 1 +fi + +if ! ldconfig -p 2>/dev/null | grep -q 'libusb-1.0\.so'; then + echo "Warning: libusb-1.0 may be missing. Install: sudo apt install libusb-1.0-0" >&2 +fi + +if ! python3 -c "import venv" 2>/dev/null; then + echo "python3-venv is required. Install with: sudo apt install python3-venv" >&2 + exit 1 +fi + +if [ ! -x .venv/bin/python ]; then + python3 -m venv .venv +fi + +if ! .venv/bin/python -m pip --version >/dev/null 2>&1; then + if ! .venv/bin/python -m ensurepip --upgrade >/dev/null 2>&1; then + echo "pip is missing from the venv. Install with:" >&2 + echo " sudo apt install python3-venv python3-pip" >&2 + exit 1 + fi +fi + +.venv/bin/python -m pip install -q -U pip +.venv/bin/python -m pip install -q -r requirements.txt + +sudo mkdir -p /etc/patlite-mqtt +if [ ! -f /etc/patlite-mqtt/patlite-mqtt.env ]; then + sudo install -m 640 systemd/patlite-mqtt.env.example /etc/patlite-mqtt/patlite-mqtt.env + sudo chown root:"$SERVICE_USER" /etc/patlite-mqtt/patlite-mqtt.env + echo "Created /etc/patlite-mqtt/patlite-mqtt.env — edit MQTT settings before starting." +else + echo "Keeping existing /etc/patlite-mqtt/patlite-mqtt.env" +fi + +sed \ + -e "s|@INSTALL_DIR@|$INSTALL_DIR|g" \ + -e "s|@VENV_PYTHON@|$VENV_PYTHON|g" \ + -e "s|@SERVICE_USER@|$SERVICE_USER|g" \ + systemd/patlite-mqtt.service.in | sudo tee /etc/systemd/system/patlite-mqtt.service >/dev/null + +sudo systemctl daemon-reload +sudo systemctl enable patlite-mqtt.service + +echo "" +echo "Installed patlite-mqtt.service" +echo " Install dir: $INSTALL_DIR" +echo " Service user: $SERVICE_USER" +echo " Config: /etc/patlite-mqtt/patlite-mqtt.env" +echo "" +echo "Edit config, then:" +echo " sudo systemctl restart patlite-mqtt" +echo " sudo systemctl status patlite-mqtt" +echo " journalctl -u patlite-mqtt -n 30 --no-pager" diff --git a/scripts/install-udev.sh b/scripts/install-udev.sh new file mode 100755 index 0000000..1eb55d0 --- /dev/null +++ b/scripts/install-udev.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -e +cd "$(dirname "$0")/.." + +target_user="${SUDO_USER:-$USER}" +if [ "$target_user" = "root" ] || [ -z "$target_user" ]; then + echo "Run as your normal user (sudo is used internally), e.g.: ./scripts/install-udev.sh" >&2 + exit 1 +fi + +sudo groupadd --system patlite 2>/dev/null || sudo groupadd patlite 2>/dev/null || true +sudo install -m 644 udev/99-patlite.rules /etc/udev/rules.d/99-patlite.rules +sudo usermod -aG patlite "$target_user" +sudo udevadm control --reload-rules +sudo udevadm trigger + +echo "Installed /etc/udev/rules.d/99-patlite.rules" +echo "Added user '$target_user' to group 'patlite'." +echo "" +echo "Next steps:" +echo " 1. Activate the new group (pick one):" +echo " newgrp patlite" +echo " or log out and back in / reboot" +echo " 2. Unplug and replug the Patlite (required for udev to apply)" +echo " 3. Run: python3 -m patlite_mqtt" diff --git a/systemd/patlite-mqtt.env.example b/systemd/patlite-mqtt.env.example new file mode 100644 index 0000000..248b394 --- /dev/null +++ b/systemd/patlite-mqtt.env.example @@ -0,0 +1,11 @@ +# Copy to /etc/patlite-mqtt/patlite-mqtt.env (install-systemd.sh does this on first run). +# chmod 600 recommended if MQTT_PASSWORD is set. + +MQTT_BROKER=localhost +MQTT_PORT=1883 +#MQTT_USERNAME= +#MQTT_PASSWORD= +MQTT_PREFIX=patlite +MQTT_CLIENT_ID=patlite-mqtt +#FAST_FLASH_DELAY=0.25 +#SLOW_FLASH_DELAY=0.5 diff --git a/systemd/patlite-mqtt.service.in b/systemd/patlite-mqtt.service.in new file mode 100644 index 0000000..c4a80bc --- /dev/null +++ b/systemd/patlite-mqtt.service.in @@ -0,0 +1,22 @@ +[Unit] +Description=Patlite signal tower MQTT daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +Environment=PYTHONUNBUFFERED=1 +EnvironmentFile=-/etc/patlite-mqtt/patlite-mqtt.env +WorkingDirectory=@INSTALL_DIR@ +ExecStart=@VENV_PYTHON@ -m patlite_mqtt +Restart=on-failure +RestartSec=5 + +User=@SERVICE_USER@ +Group=patlite + +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/udev/99-patlite.rules b/udev/99-patlite.rules new file mode 100644 index 0000000..06f5a3e --- /dev/null +++ b/udev/99-patlite.rules @@ -0,0 +1,9 @@ +# Patlite signal tower — vendor 0x191A, product 0x8003 +# +# Install: ./scripts/install-udev.sh +# Then unplug/replug the device (or reboot). +# +# Members of the "patlite" group may open the device (headless-friendly). +# uaccess is included for desktop sessions with logind. + +SUBSYSTEM=="usb", ATTR{idVendor}=="191a", ATTR{idProduct}=="8003", GROUP="patlite", MODE="0660", TAG+="uaccess"