turn-up-arch/src/turnup/turnupd.py

330 lines
11 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/usr/bin/env python3
"""
turnupd — Turn Up mixer daemon for Linux
Bridges a USB serial device (knobs + buttons) to PipeWire/PulseAudio via
pulsectl, mapping hardware inputs to per-sink, per-source, and per-app
volume control as well as mute toggles and arbitrary shell commands.
LED feedback: after every knob move the device's RGB LEDs are updated to
reflect the current volume using the per-knob (or global) colour scheme
from config.
"""
import logging
import signal
import subprocess
import sys
import time
import pulsectl
import serial
from turnup.config import get_knob_led_cfg, get_led_color, load_config
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("turnupd")
# Maximum raw ADC value reported by the hardware.
KNOB_MAX: int = 1012
# Maximum output volume multiplier (1.5 = 150 %).
VOLUME_MAX: float = 1.5
# Number of physical knobs (and therefore LED groups).
NUM_KNOBS: int = 5
# Number of LEDs per knob.
LEDS_PER_KNOB: int = 3
# ── Protocol parser ────────────────────────────────────────────────────────────
def parse_messages(buf: bytearray) -> tuple[list[dict], bytearray]:
"""Parse framed messages out of *buf* and return ``(messages, remainder)``."""
messages: list[dict] = []
i = 0
while i < len(buf):
if buf[i] != 0xFE:
i += 1
continue
remaining = len(buf) - i
if remaining >= 3 and buf[i + 1] == 0x02 and buf[i + 2] == 0xFF:
messages.append({"type": "heartbeat"})
i += 3
elif (
remaining >= 4
and buf[i + 1] in (0x06, 0x07)
and buf[i + 3] == 0xFF
):
messages.append({
"type": "button",
"action": "press" if buf[i + 1] == 0x06 else "release",
"id": buf[i + 2],
})
i += 4
elif (
remaining >= 6
and buf[i + 1] == 0x03
and buf[i + 5] == 0xFF
):
messages.append({
"type": "knob",
"id": buf[i + 2],
"value": (buf[i + 3] << 8) | buf[i + 4],
})
i += 6
else:
i += 1
return messages, bytearray(buf[i:])
def knob_to_volume(value: int) -> float:
"""Convert a raw knob value (0``KNOB_MAX``) to a sink volume (0.01.5)."""
return round((value / KNOB_MAX) * VOLUME_MAX, 4)
def knob_to_norm(value: int) -> float:
"""Convert a raw knob value (0``KNOB_MAX``) to a normalised float (0.01.0)."""
return round(value / KNOB_MAX, 4)
# ── LED control ────────────────────────────────────────────────────────────────
def build_led_packet(colors: list[tuple[int, int, int]]) -> bytes:
"""Build the 47-byte LED packet for all 5 knobs.
Frame format: ``FE 05 [R G B * LEDS_PER_KNOB] * NUM_KNOBS FF``
"""
assert len(colors) == NUM_KNOBS
payload = bytearray([0xFE, 0x05])
for r, g, b in colors:
payload += bytes([r, g, b]) * LEDS_PER_KNOB
payload.append(0xFF)
return bytes(payload)
def send_leds(ser: serial.Serial, colors: list[tuple[int, int, int]]) -> None:
"""Write an LED packet to the open serial port, swallowing any I/O errors."""
try:
ser.write(build_led_packet(colors))
except serial.SerialException as exc:
log.warning("LED write failed: %s", exc)
def all_led_colors(
config: dict, knob_norms: list[float]
) -> list[tuple[int, int, int]]:
"""Return one ``(r, g, b)`` per knob based on each knob's LED config."""
return [
get_led_color(get_knob_led_cfg(config, i), knob_norms[i])
for i in range(NUM_KNOBS)
]
# ── PulseAudio / PipeWire controller ──────────────────────────────────────────
class PulseController:
"""Thin wrapper around :class:`pulsectl.Pulse` for volume and mute control."""
def __init__(self) -> None:
self._pulse = pulsectl.Pulse("turnupd")
def close(self) -> None:
self._pulse.close()
def set_sink_volume(self, sink_name: str, volume: float) -> None:
volume = max(0.0, min(VOLUME_MAX, volume))
try:
if sink_name == "default":
info = self._pulse.server_info()
sink = self._pulse.get_sink_by_name(info.default_sink_name)
else:
sink = self._pulse.get_sink_by_name(sink_name)
self._pulse.volume_set_all_chans(sink, volume)
except Exception as exc:
log.warning("set_sink_volume(%r) failed: %s", sink_name, exc)
def toggle_mute_sink(self, sink_name: str) -> None:
try:
if sink_name == "default":
info = self._pulse.server_info()
sink = self._pulse.get_sink_by_name(info.default_sink_name)
else:
sink = self._pulse.get_sink_by_name(sink_name)
self._pulse.mute(sink, not sink.mute)
log.info("Sink %r mute toggled", sink_name)
except Exception as exc:
log.warning("toggle_mute_sink(%r) failed: %s", sink_name, exc)
def set_source_volume(self, source_name: str, volume: float) -> None:
volume = max(0.0, min(1.0, volume))
try:
if source_name == "default":
info = self._pulse.server_info()
source = self._pulse.get_source_by_name(info.default_source_name)
else:
source = self._pulse.get_source_by_name(source_name)
self._pulse.volume_set_all_chans(source, volume)
except Exception as exc:
log.warning("set_source_volume(%r) failed: %s", source_name, exc)
def toggle_mute_source(self, source_name: str) -> None:
try:
if source_name == "default":
info = self._pulse.server_info()
source = self._pulse.get_source_by_name(info.default_source_name)
else:
source = self._pulse.get_source_by_name(source_name)
self._pulse.mute(source, not source.mute)
log.info("Source %r mute toggled", source_name)
except Exception as exc:
log.warning("toggle_mute_source(%r) failed: %s", source_name, exc)
def set_app_volume(self, app_name: str, volume: float) -> None:
volume = max(0.0, min(VOLUME_MAX, volume))
needle = app_name.lower()
try:
for inp in self._pulse.sink_input_list():
name = inp.proplist.get("application.name", "")
binary = inp.proplist.get("application.process.binary", "")
if needle in name.lower() or needle in binary.lower():
self._pulse.volume_set_all_chans(inp, volume)
return
log.debug("App %r not found in sink inputs", app_name)
except Exception as exc:
log.warning("set_app_volume(%r) failed: %s", app_name, exc)
# ── Event handlers ─────────────────────────────────────────────────────────────
def handle_knob(
knob_id: int,
value: int,
config: dict,
pulse: PulseController,
ser: serial.Serial,
knob_norms: list[float],
) -> None:
"""Dispatch a knob event, update PulseAudio, then refresh the LEDs."""
knob_cfg = config.get("knobs", {}).get(str(knob_id))
if not knob_cfg:
return
action = knob_cfg.get("action", "sink_volume")
target = knob_cfg.get("target", "default")
norm = knob_to_norm(value)
if action == "sink_volume":
vol = knob_to_volume(value)
pulse.set_sink_volume(target, vol)
log.info("Knob %d → sink %r = %.2f", knob_id, target, vol)
elif action == "source_volume":
pulse.set_source_volume(target, norm)
log.info("Knob %d → source %r = %.2f", knob_id, target, norm)
elif action == "app_volume":
vol = knob_to_volume(value)
pulse.set_app_volume(target, vol)
log.info("Knob %d → app %r = %.2f", knob_id, target, vol)
elif action == "group_volume":
vol = knob_to_volume(value)
for t in knob_cfg.get("targets", []):
pulse.set_app_volume(t, vol)
log.info("Knob %d → group %s = %.2f", knob_id, knob_cfg.get("targets"), vol)
knob_norms[knob_id] = norm
send_leds(ser, all_led_colors(config, knob_norms))
def handle_button(
button_id: int, action: str, config: dict, pulse: PulseController
) -> None:
"""Dispatch a button press event."""
if action != "press":
return
btn_cfg = config.get("buttons", {}).get(str(button_id))
if not btn_cfg:
return
btn_action = btn_cfg.get("action", "")
target = btn_cfg.get("target", "default")
if btn_action == "mute_sink":
pulse.toggle_mute_sink(target)
elif btn_action == "mute_source":
pulse.toggle_mute_source(target)
elif btn_action == "command":
try:
subprocess.Popen(target, shell=True) # noqa: S602
log.info("Button %d → command %r", button_id, target)
except Exception as exc:
log.warning("Button %d command failed: %s", button_id, exc)
# ── Main loop ──────────────────────────────────────────────────────────────────
def main() -> None:
config = load_config()
port: str = config.get("port", "/dev/ttyACM0")
baud: int = config.get("baud", 115200)
log.info("Turn Up daemon starting — %s @ %d baud", port, baud)
pulse = PulseController()
knob_norms = [0.0] * NUM_KNOBS
buf = bytearray()
def _shutdown(sig: int, _frame: object) -> None:
log.info("Received signal %d — shutting down", sig)
pulse.close()
sys.exit(0)
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
while True:
try:
with serial.Serial(port, baud, timeout=0.1) as ser:
log.info("Connected to %s", port)
buf.clear()
send_leds(ser, all_led_colors(config, knob_norms))
while True:
data = ser.read(64)
if not data:
continue
buf.extend(data)
messages, buf = parse_messages(buf)
for msg in messages:
if msg["type"] == "knob":
handle_knob(
msg["id"], msg["value"],
config, pulse, ser, knob_norms,
)
elif msg["type"] == "button":
handle_button(
msg["id"], msg["action"], config, pulse
)
except serial.SerialException as exc:
log.warning("Serial error: %s — retrying in 3 s", exc)
time.sleep(3)
except Exception as exc: # noqa: BLE001
log.error("Unexpected error: %s — retrying in 3 s", exc)
time.sleep(3)
if __name__ == "__main__":
main()