Inital!
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
10
README.md
Normal file
10
README.md
Normal 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
3
patlite_mqtt/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Patlite tower light controlled over MQTT."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
3
patlite_mqtt/__main__.py
Normal file
3
patlite_mqtt/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from patlite_mqtt.daemon import main
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
174
patlite_mqtt/channels.py
Normal file
174
patlite_mqtt/channels.py
Normal 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
184
patlite_mqtt/daemon.py
Normal 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
57
patlite_mqtt/device.py
Normal 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
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
paho-mqtt>=2.0.0
|
||||||
|
pyusb>=1.2.1
|
||||||
80
scripts/install-systemd.sh
Executable file
80
scripts/install-systemd.sh
Executable 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
25
scripts/install-udev.sh
Executable 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"
|
||||||
11
systemd/patlite-mqtt.env.example
Normal file
11
systemd/patlite-mqtt.env.example
Normal 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
|
||||||
22
systemd/patlite-mqtt.service.in
Normal file
22
systemd/patlite-mqtt.service.in
Normal 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
9
udev/99-patlite.rules
Normal 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"
|
||||||
Reference in New Issue
Block a user