From 916541d815d0570f1155a21f664521f983e5b7db Mon Sep 17 00:00:00 2001 From: Sean Doran Date: Fri, 27 Feb 2026 14:38:36 -0500 Subject: [PATCH] Add TOML config, playerctl dep, and media button defaults; bump to v1.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch config format from JSON to TOML (tomllib built-in on 3.11+, tomli fallback for 3.10) so the generated config can include comments - Write an annotated config.toml on first run with descriptions for every knob/button and hints for all available actions - Promote playerctl from optdepends to depends so media buttons work out of the box - Default buttons 1–3 now pre-configured for playerctl previous/play-pause/next - Rename contrib/config.example.json → config.example.toml with full TOML rewrite including per-knob LED colour override examples - Log a migration hint when a legacy config.json is detected --- .SRCINFO | 6 +- .github/workflows/publish-aur.yml | 2 +- PKGBUILD | 4 +- README.md | 68 ++++++++----- contrib/config.example.json | 56 ----------- contrib/config.example.toml | 98 ++++++++++++++++++ pyproject.toml | 3 +- src/turnup/__init__.py | 2 +- src/turnup/config.py | 159 ++++++++++++++++++++++++------ 9 files changed, 281 insertions(+), 117 deletions(-) delete mode 100755 contrib/config.example.json create mode 100755 contrib/config.example.toml diff --git a/.SRCINFO b/.SRCINFO index 1fc146f..9bd7bb3 100644 --- a/.SRCINFO +++ b/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = turn-up-arch pkgdesc = USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux - pkgver = 1.0.0 + pkgver = 1.1.0 pkgrel = 1 url = https://github.com/sean351/turn-up-arch arch = any @@ -13,10 +13,10 @@ pkgbase = turn-up-arch depends = python-pyserial depends = python-pulsectl depends = pipewire-pulse - optdepends = playerctl: media key support via button commands + depends = playerctl optdepends = pulseaudio: alternative to pipewire-pulse install = turnupd.install - source = turn-up-arch-1.0.0.tar.gz::https://github.com/sean351/turn-up-arch/archive/refs/tags/v1.0.0.tar.gz + source = turn-up-arch-1.1.0.tar.gz::https://github.com/sean351/turn-up-arch/archive/refs/tags/v1.1.0.tar.gz sha256sums = SKIP pkgname = turn-up-arch diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index 486c97d..90b0b8f 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -53,7 +53,7 @@ jobs: depends = python-pyserial depends = python-pulsectl depends = pipewire-pulse - optdepends = playerctl: media key support via button commands + depends = playerctl optdepends = pulseaudio: alternative to pipewire-pulse install = turnupd.install source = ${PKGNAME}-${VERSION}.tar.gz::https://github.com/${{ github.repository }}/archive/refs/tags/v${VERSION}.tar.gz diff --git a/PKGBUILD b/PKGBUILD index 31c8c5d..87ed0b5 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Sean Doran # AUR updates are automated via GitHub Actions on version tag push pkgname=turn-up-arch -pkgver=1.0.0 +pkgver=1.1.0 pkgrel=1 pkgdesc="USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux" arch=('any') @@ -12,6 +12,7 @@ depends=( 'python-pyserial' 'python-pulsectl' 'pipewire-pulse' + 'playerctl' ) makedepends=( 'python-build' @@ -20,7 +21,6 @@ makedepends=( 'python-hatchling' ) optdepends=( - 'playerctl: media key support via button commands' 'pulseaudio: alternative to pipewire-pulse' ) install=turnupd.install diff --git a/README.md b/README.md index a2f8474..081e970 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ arbitrary shell commands. - [pyserial](https://pypi.org/project/pyserial/) - [pulsectl](https://pypi.org/project/pulsectl/) - PipeWire (with `pipewire-pulse`) or PulseAudio -- `playerctl` *(optional — for media key bindings)* +- `playerctl` ## Installation @@ -40,30 +40,54 @@ pip install . ## Configuration On first run `turnupd` writes a default config to -`~/.config/turnup/config.json`. Edit it to match your device layout. -See [`contrib/config.example.json`](contrib/config.example.json) for a -fully commented example. +`~/.config/turnup/config.toml`. Edit it to match your device layout. +See [`contrib/config.example.toml`](contrib/config.example.toml) for a +fully annotated example. -```jsonc -{ - "port": "/dev/ttyACM0", - "baud": 115200, +```toml +port = "/dev/ttyACM0" +baud = 115200 - "knobs": { - "0": { "action": "sink_volume", "target": "default" }, - "1": { "action": "group_volume", "targets": ["vlc", "spotify"] }, - "2": { "action": "app_volume", "target": "Brave" }, - "3": { "action": "source_volume","target": "default" } - }, +[leds] +mode = "volume" +low_color = [255, 0, 0] # red at 0 % +high_color = [0, 255, 0] # green at 100 % - "buttons": { - "0": { "action": "mute_sink", "target": "default" }, - "1": { "action": "command", "target": "playerctl previous" }, - "2": { "action": "command", "target": "playerctl play-pause" }, - "3": { "action": "command", "target": "playerctl next" }, - "4": { "action": "mute_source","target": "default" } - } -} +[knobs.0] +action = "sink_volume" +target = "default" + +[knobs.1] +action = "group_volume" +targets = ["vlc", "spotify"] + +[knobs.2] +action = "app_volume" +target = "Brave" + +[knobs.3] +action = "source_volume" +target = "default" + +[buttons.0] +action = "mute_sink" +target = "default" + +[buttons.1] +action = "command" +target = "playerctl previous" + +[buttons.2] +action = "command" +target = "playerctl play-pause" + +[buttons.3] +action = "command" +target = "playerctl next" + +[buttons.4] +action = "mute_source" +target = "default" ``` ### Knob actions diff --git a/contrib/config.example.json b/contrib/config.example.json deleted file mode 100755 index 079ff1a..0000000 --- a/contrib/config.example.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "port": "/dev/ttyACM0", - "baud": 115200, - - "leds": { - "mode": "volume", - "low_color": [255, 0, 0], - "high_color": [0, 255, 0] - }, - - "knobs": { - "0": { - "action": "sink_volume", - "target": "default" - }, - "1": { - "action": "group_volume", - "targets": ["vlc", "spotify", "Cider"], - "led": { - "mode": "volume", - "low_color": [255, 0, 0], - "high_color": [0, 0, 255] - } - }, - "2": { - "action": "app_volume", - "target": "Brave", - "led": { - "mode": "static", - "high_color": [255, 165, 0] - } - }, - "3": { - "action": "group_volume", - "targets": ["electron", "discord"], - "led": { "mode": "off" } - }, - "4": { - "action": "source_volume", - "target": "default", - "led": { - "mode": "volume", - "low_color": [255, 0, 0], - "high_color": [0, 255, 255] - } - } - }, - - "buttons": { - "0": { "action": "mute_sink", "target": "default" }, - "1": { "action": "command", "target": "playerctl previous" }, - "2": { "action": "command", "target": "playerctl play-pause" }, - "3": { "action": "command", "target": "playerctl next" }, - "4": { "action": "mute_source", "target": "default" } - } -} diff --git a/contrib/config.example.toml b/contrib/config.example.toml new file mode 100755 index 0000000..8cbc87b --- /dev/null +++ b/contrib/config.example.toml @@ -0,0 +1,98 @@ +# turnup configuration — fully annotated example. +# Copy this file to ~/.config/turnup/config.toml and edit to taste. +# Changes are picked up automatically (no restart needed). + +# Serial port your device enumerates as (check `ls /dev/ttyACM*`). +port = "/dev/ttyACM0" +baud = 115200 + +# ── LED settings ────────────────────────────────────────────────────────────── +[leds] +# Global LED behaviour — can be overridden per-knob via a [knobs.N.led] block. +# +# mode: "volume" — fade from low_color → high_color based on the knob position +# "static" — always show high_color regardless of volume +# "off" — LEDs disabled +mode = "volume" +low_color = [255, 0, 0] # red at 0 % +high_color = [0, 255, 0] # green at 100 % + +# ── Knobs ───────────────────────────────────────────────────────────────────── +# Available actions: +# sink_volume — output device volume (0–150 %) +# source_volume — mic / input volume (0–100 %) +# app_volume — single application matched by name or binary; set target +# group_volume — multiple applications at once; set targets = ["app1", "app2"] + +[knobs.0] +# Master output volume — uses global LED colours +action = "sink_volume" +target = "default" + +[knobs.1] +# Media players — custom blue LED to distinguish from the others +action = "group_volume" +targets = ["vlc", "spotify", "Cider"] + +[knobs.1.led] +mode = "volume" +low_color = [255, 0, 0] +high_color = [0, 0, 255] # blue at 100 % + +[knobs.2] +# Browser — static orange LED (always on, no volume feedback) +action = "app_volume" +target = "Brave" + +[knobs.2.led] +mode = "static" +high_color = [255, 165, 0] + +[knobs.3] +# Chat apps — LED disabled for this knob +action = "group_volume" +targets = ["electron", "discord"] + +[knobs.3.led] +mode = "off" + +[knobs.4] +# Microphone / input — teal LED +action = "source_volume" +target = "default" + +[knobs.4.led] +mode = "volume" +low_color = [255, 0, 0] +high_color = [0, 255, 255] # teal at 100 % + +# ── Buttons ─────────────────────────────────────────────────────────────────── +# Available actions: +# mute_sink — toggle output mute +# mute_source — toggle mic mute +# command — run an arbitrary shell command; set target = "command args" + +[buttons.0] +# Mute output +action = "mute_sink" +target = "default" + +[buttons.1] +# Previous track (requires playerctl) +action = "command" +target = "playerctl previous" + +[buttons.2] +# Play / pause (requires playerctl) +action = "command" +target = "playerctl play-pause" + +[buttons.3] +# Next track (requires playerctl) +action = "command" +target = "playerctl next" + +[buttons.4] +# Mute microphone +action = "mute_source" +target = "default" diff --git a/pyproject.toml b/pyproject.toml index 35149ab..803df1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "turnup" -version = "0.3.3" +version = "1.1.0" description = "USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux" readme = "README.md" license = { text = "MIT" } @@ -12,6 +12,7 @@ requires-python = ">=3.10" dependencies = [ "pyserial>=3.5", "pulsectl>=22.0", + "tomli>=1.1; python_version < '3.11'", ] [project.scripts] diff --git a/src/turnup/__init__.py b/src/turnup/__init__.py index 09693b7..d1c83f6 100644 --- a/src/turnup/__init__.py +++ b/src/turnup/__init__.py @@ -1,3 +1,3 @@ """Turn Up — USB serial mixer daemon for PipeWire/PulseAudio.""" -__version__ = "0.3.3" +__version__ = "1.1.0" diff --git a/src/turnup/config.py b/src/turnup/config.py index e0e3264..768da10 100755 --- a/src/turnup/config.py +++ b/src/turnup/config.py @@ -1,46 +1,132 @@ #!/usr/bin/env python3 """ -config.py — Load and validate turnup configuration from config.json +config.py — Load and validate turnup configuration from config.toml """ -import json import logging import os import sys +try: + import tomllib +except ImportError: # Python < 3.11 + import tomli as tomllib # type: ignore[no-reuse-ignore] + log = logging.getLogger("turnupd") +# In-memory defaults used as fallback when the config file is missing or a +# field is omitted. The on-disk template (DEFAULT_CONFIG_TOML) is the +# authoritative human-readable version of these values. DEFAULT_CONFIG: dict = { "port": "/dev/ttyACM0", "baud": 115200, "leds": { - # Global default LED behaviour — overridable per-knob via a "led": {} - # block inside each knob entry. - # - # mode: - # "volume" — interpolate low_color→high_color based on knob position - # "static" — always show high_color regardless of volume - # "off" — LEDs disabled "mode": "volume", - "low_color": [255, 0, 0], # red at volume 0.0 - "high_color": [0, 255, 0], # green at volume 1.0 + "low_color": [255, 0, 0], + "high_color": [0, 255, 0], }, "knobs": { "0": {"action": "sink_volume", "target": "default"}, - "1": {"action": "sink_volume", "target": "default"}, - "2": {"action": "source_volume", "target": "default"}, - "3": {"action": "sink_volume", "target": "default"}, + "1": {"action": "group_volume", "targets": ["spotify", "vlc", "Cider"]}, + "2": {"action": "app_volume", "target": "brave"}, + "3": {"action": "source_volume", "target": "default"}, "4": {"action": "sink_volume", "target": "default"}, }, "buttons": { "0": {"action": "mute_sink", "target": "default"}, - "1": {"action": "mute_source", "target": "default"}, - "2": {"action": "command", "target": ""}, - "3": {"action": "command", "target": ""}, - "4": {"action": "command", "target": ""}, + "1": {"action": "command", "target": "playerctl previous"}, + "2": {"action": "command", "target": "playerctl play-pause"}, + "3": {"action": "command", "target": "playerctl next"}, + "4": {"action": "mute_source", "target": "default"}, }, } +# Annotated TOML template written to ~/.config/turnup/config.toml on first run. +DEFAULT_CONFIG_TOML: str = """\ +# turnup configuration — edit this file to match your device layout. +# Changes are picked up automatically (no restart needed). +# See https://github.com/sean351/turn-up-arch for full documentation. + +# Serial port your device enumerates as (check `ls /dev/ttyACM*`). +port = "/dev/ttyACM0" +baud = 115200 + +# ── LED settings ────────────────────────────────────────────────────────────── +[leds] +# mode: "volume" — fade from low_color → high_color based on the knob position +# "static" — always show high_color regardless of volume +# "off" — LEDs disabled +mode = "volume" +low_color = [255, 0, 0] # red at 0 % +high_color = [0, 255, 0] # green at 100 % + +# ── Knobs ───────────────────────────────────────────────────────────────────── +# Available actions: +# sink_volume — output device volume (0–150 %) +# source_volume — mic / input volume (0–100 %) +# app_volume — single application; set target = "AppName" +# group_volume — multiple applications at once; set targets = ["app1", "app2"] +# +# Each knob can optionally include a [knobs.N.led] block to override the +# global LED colours for that specific knob. + +[knobs.0] +# Knob 0 — master output volume +action = "sink_volume" +target = "default" + +[knobs.1] +# Knob 1 — media players (group volume example) +action = "group_volume" +targets = ["spotify", "vlc", "Cider"] + +[knobs.2] +# Knob 2 — browser (app volume example) +action = "app_volume" +target = "brave" + +[knobs.3] +# Knob 3 — microphone / input +action = "source_volume" +target = "default" + +[knobs.4] +# Knob 4 — unused; change to taste +action = "sink_volume" +target = "default" + +# ── Buttons ─────────────────────────────────────────────────────────────────── +# Available actions: +# mute_sink — toggle output mute +# mute_source — toggle mic mute +# command — run an arbitrary shell command; set target = "command args" + +[buttons.0] +# Button 0 — mute output +action = "mute_sink" +target = "default" + +[buttons.1] +# Button 1 — previous track (requires playerctl) +action = "command" +target = "playerctl previous" + +[buttons.2] +# Button 2 — play / pause (requires playerctl) +action = "command" +target = "playerctl play-pause" + +[buttons.3] +# Button 3 — next track (requires playerctl) +action = "command" +target = "playerctl next" + +[buttons.4] +# Button 4 — mute microphone +action = "mute_source" +target = "default" +""" + VALID_KNOB_ACTIONS: frozenset = frozenset( {"sink_volume", "source_volume", "app_volume", "group_volume"} ) @@ -53,7 +139,7 @@ _XDG_CONFIG_DIR = os.path.join( os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "turnup", ) -DEFAULT_CONFIG_PATH = os.path.join(_XDG_CONFIG_DIR, "config.json") +DEFAULT_CONFIG_PATH = os.path.join(_XDG_CONFIG_DIR, "config.toml") # ── LED helpers ──────────────────────────────────────────────────────────────── @@ -147,11 +233,15 @@ def get_led_color(led_cfg: dict, norm: float) -> tuple[int, int, int]: high = led_cfg.get("high_color", DEFAULT_CONFIG["leds"]["high_color"]) if mode == "static": - return tuple(high) + return (high[0], high[1], high[2]) low = led_cfg.get("low_color", DEFAULT_CONFIG["leds"]["low_color"]) t = max(0.0, min(1.0, norm)) - return tuple(int(low[i] + (high[i] - low[i]) * t) for i in range(3)) + return ( + int(low[0] + (high[0] - low[0]) * t), + int(low[1] + (high[1] - low[1]) * t), + int(low[2] + (high[2] - low[2]) * t), + ) # ── Config I/O ───────────────────────────────────────────────────────────────── @@ -160,37 +250,44 @@ def load_config(path: str | None = None) -> dict: """Load configuration from *path*. If *path* is ``None`` the XDG-compliant location - ``~/.config/turnup/config.json`` is used. + ``~/.config/turnup/config.toml`` is used. Returns the parsed and validated configuration dictionary. - Exits with status 1 on malformed JSON. + Exits with status 1 on malformed TOML. """ if path is None: path = DEFAULT_CONFIG_PATH if not os.path.exists(path): - log.warning("No config.json found at %s — writing defaults", path) + # Hint for users upgrading from the old JSON format. + legacy_path = path.replace(".toml", ".json") + if os.path.exists(legacy_path): + log.warning( + "Found legacy JSON config at %s — " + "please migrate to TOML format at %s", + legacy_path, path, + ) + log.warning("No config.toml found at %s — writing defaults", path) _write_default(path) return dict(DEFAULT_CONFIG) try: - with open(path) as f: - cfg: dict = json.load(f) + with open(path, "rb") as f: + cfg: dict = tomllib.load(f) cfg["leds"] = _validate_leds(cfg.get("leds", {})) log.info("Loaded config from %s", path) return cfg - except json.JSONDecodeError as exc: - log.error("Invalid JSON in %s: %s", path, exc) + except tomllib.TOMLDecodeError as exc: + log.error("Invalid TOML in %s: %s", path, exc) sys.exit(1) def _write_default(path: str) -> None: - """Write the default configuration to *path*, creating directories as needed.""" + """Write the annotated default configuration to *path*, creating directories as needed.""" try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as f: - json.dump(DEFAULT_CONFIG, f, indent=2) - f.write("\n") + f.write(DEFAULT_CONFIG_TOML) log.info("Created default config at %s", path) except OSError as exc: log.warning("Could not write default config: %s", exc)