This commit is contained in:
=
2026-05-18 16:05:11 -04:00
commit c61fe1a5c4
13 changed files with 582 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.venv
__pycache__

10
README.md Normal file
View File

@@ -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`

3
patlite_mqtt/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Patlite tower light controlled over MQTT."""
__version__ = "0.1.0"

3
patlite_mqtt/__main__.py Normal file
View File

@@ -0,0 +1,3 @@
from patlite_mqtt.daemon import main
raise SystemExit(main())

174
patlite_mqtt/channels.py Normal file
View File

@@ -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()

184
patlite_mqtt/daemon.py Normal file
View File

@@ -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 <prefix>/<color> (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())

57
patlite_mqtt/device.py Normal file
View File

@@ -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)

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
paho-mqtt>=2.0.0
pyusb>=1.2.1

80
scripts/install-systemd.sh Executable file
View File

@@ -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"

25
scripts/install-udev.sh Executable file
View File

@@ -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"

View File

@@ -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

View File

@@ -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

9
udev/99-patlite.rules Normal file
View File

@@ -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"