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

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)