Add TOML config, playerctl dep, and media button defaults; bump to v1.1.0

- 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
main
Sean Doran 2026-02-27 14:38:36 -05:00
parent 2e77bf804e
commit 916541d815
No known key found for this signature in database
GPG Key ID: A9D7D25CD95E8579
9 changed files with 281 additions and 117 deletions

View File

@ -1,6 +1,6 @@
pkgbase = turn-up-arch pkgbase = turn-up-arch
pkgdesc = USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux pkgdesc = USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux
pkgver = 1.0.0 pkgver = 1.1.0
pkgrel = 1 pkgrel = 1
url = https://github.com/sean351/turn-up-arch url = https://github.com/sean351/turn-up-arch
arch = any arch = any
@ -13,10 +13,10 @@ pkgbase = turn-up-arch
depends = python-pyserial depends = python-pyserial
depends = python-pulsectl depends = python-pulsectl
depends = pipewire-pulse depends = pipewire-pulse
optdepends = playerctl: media key support via button commands depends = playerctl
optdepends = pulseaudio: alternative to pipewire-pulse optdepends = pulseaudio: alternative to pipewire-pulse
install = turnupd.install 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 sha256sums = SKIP
pkgname = turn-up-arch pkgname = turn-up-arch

View File

@ -53,7 +53,7 @@ jobs:
depends = python-pyserial depends = python-pyserial
depends = python-pulsectl depends = python-pulsectl
depends = pipewire-pulse depends = pipewire-pulse
optdepends = playerctl: media key support via button commands depends = playerctl
optdepends = pulseaudio: alternative to pipewire-pulse optdepends = pulseaudio: alternative to pipewire-pulse
install = turnupd.install install = turnupd.install
source = ${PKGNAME}-${VERSION}.tar.gz::https://github.com/${{ github.repository }}/archive/refs/tags/v${VERSION}.tar.gz source = ${PKGNAME}-${VERSION}.tar.gz::https://github.com/${{ github.repository }}/archive/refs/tags/v${VERSION}.tar.gz

View File

@ -1,7 +1,7 @@
# Maintainer: Sean Doran <sdoran35@gmail.com> # Maintainer: Sean Doran <sdoran35@gmail.com>
# AUR updates are automated via GitHub Actions on version tag push # AUR updates are automated via GitHub Actions on version tag push
pkgname=turn-up-arch pkgname=turn-up-arch
pkgver=1.0.0 pkgver=1.1.0
pkgrel=1 pkgrel=1
pkgdesc="USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux" pkgdesc="USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux"
arch=('any') arch=('any')
@ -12,6 +12,7 @@ depends=(
'python-pyserial' 'python-pyserial'
'python-pulsectl' 'python-pulsectl'
'pipewire-pulse' 'pipewire-pulse'
'playerctl'
) )
makedepends=( makedepends=(
'python-build' 'python-build'
@ -20,7 +21,6 @@ makedepends=(
'python-hatchling' 'python-hatchling'
) )
optdepends=( optdepends=(
'playerctl: media key support via button commands'
'pulseaudio: alternative to pipewire-pulse' 'pulseaudio: alternative to pipewire-pulse'
) )
install=turnupd.install install=turnupd.install

View File

@ -11,7 +11,7 @@ arbitrary shell commands.
- [pyserial](https://pypi.org/project/pyserial/) - [pyserial](https://pypi.org/project/pyserial/)
- [pulsectl](https://pypi.org/project/pulsectl/) - [pulsectl](https://pypi.org/project/pulsectl/)
- PipeWire (with `pipewire-pulse`) or PulseAudio - PipeWire (with `pipewire-pulse`) or PulseAudio
- `playerctl` *(optional — for media key bindings)* - `playerctl`
## Installation ## Installation
@ -40,30 +40,54 @@ pip install .
## Configuration ## Configuration
On first run `turnupd` writes a default config to On first run `turnupd` writes a default config to
`~/.config/turnup/config.json`. Edit it to match your device layout. `~/.config/turnup/config.toml`. Edit it to match your device layout.
See [`contrib/config.example.json`](contrib/config.example.json) for a See [`contrib/config.example.toml`](contrib/config.example.toml) for a
fully commented example. fully annotated example.
```jsonc ```toml
{ port = "/dev/ttyACM0"
"port": "/dev/ttyACM0", baud = 115200
"baud": 115200,
"knobs": { [leds]
"0": { "action": "sink_volume", "target": "default" }, mode = "volume"
"1": { "action": "group_volume", "targets": ["vlc", "spotify"] }, low_color = [255, 0, 0] # red at 0 %
"2": { "action": "app_volume", "target": "Brave" }, high_color = [0, 255, 0] # green at 100 %
"3": { "action": "source_volume","target": "default" }
},
"buttons": { [knobs.0]
"0": { "action": "mute_sink", "target": "default" }, action = "sink_volume"
"1": { "action": "command", "target": "playerctl previous" }, target = "default"
"2": { "action": "command", "target": "playerctl play-pause" },
"3": { "action": "command", "target": "playerctl next" }, [knobs.1]
"4": { "action": "mute_source","target": "default" } 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 ### Knob actions

View File

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

98
contrib/config.example.toml Executable file
View File

@ -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 (0150 %)
# source_volume — mic / input volume (0100 %)
# 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"

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "turnup" name = "turnup"
version = "0.3.3" version = "1.1.0"
description = "USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux" description = "USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
@ -12,6 +12,7 @@ requires-python = ">=3.10"
dependencies = [ dependencies = [
"pyserial>=3.5", "pyserial>=3.5",
"pulsectl>=22.0", "pulsectl>=22.0",
"tomli>=1.1; python_version < '3.11'",
] ]
[project.scripts] [project.scripts]

View File

@ -1,3 +1,3 @@
"""Turn Up — USB serial mixer daemon for PipeWire/PulseAudio.""" """Turn Up — USB serial mixer daemon for PipeWire/PulseAudio."""
__version__ = "0.3.3" __version__ = "1.1.0"

View File

@ -1,46 +1,132 @@
#!/usr/bin/env python3 #!/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 logging
import os import os
import sys import sys
try:
import tomllib
except ImportError: # Python < 3.11
import tomli as tomllib # type: ignore[no-reuse-ignore]
log = logging.getLogger("turnupd") 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 = { DEFAULT_CONFIG: dict = {
"port": "/dev/ttyACM0", "port": "/dev/ttyACM0",
"baud": 115200, "baud": 115200,
"leds": { "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", "mode": "volume",
"low_color": [255, 0, 0], # red at volume 0.0 "low_color": [255, 0, 0],
"high_color": [0, 255, 0], # green at volume 1.0 "high_color": [0, 255, 0],
}, },
"knobs": { "knobs": {
"0": {"action": "sink_volume", "target": "default"}, "0": {"action": "sink_volume", "target": "default"},
"1": {"action": "sink_volume", "target": "default"}, "1": {"action": "group_volume", "targets": ["spotify", "vlc", "Cider"]},
"2": {"action": "source_volume", "target": "default"}, "2": {"action": "app_volume", "target": "brave"},
"3": {"action": "sink_volume", "target": "default"}, "3": {"action": "source_volume", "target": "default"},
"4": {"action": "sink_volume", "target": "default"}, "4": {"action": "sink_volume", "target": "default"},
}, },
"buttons": { "buttons": {
"0": {"action": "mute_sink", "target": "default"}, "0": {"action": "mute_sink", "target": "default"},
"1": {"action": "mute_source", "target": "default"}, "1": {"action": "command", "target": "playerctl previous"},
"2": {"action": "command", "target": ""}, "2": {"action": "command", "target": "playerctl play-pause"},
"3": {"action": "command", "target": ""}, "3": {"action": "command", "target": "playerctl next"},
"4": {"action": "command", "target": ""}, "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 (0150 %)
# source_volume — mic / input volume (0100 %)
# 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( VALID_KNOB_ACTIONS: frozenset = frozenset(
{"sink_volume", "source_volume", "app_volume", "group_volume"} {"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")), os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
"turnup", "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 ──────────────────────────────────────────────────────────────── # ── 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"]) high = led_cfg.get("high_color", DEFAULT_CONFIG["leds"]["high_color"])
if mode == "static": if mode == "static":
return tuple(high) return (high[0], high[1], high[2])
low = led_cfg.get("low_color", DEFAULT_CONFIG["leds"]["low_color"]) low = led_cfg.get("low_color", DEFAULT_CONFIG["leds"]["low_color"])
t = max(0.0, min(1.0, norm)) 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 ───────────────────────────────────────────────────────────────── # ── Config I/O ─────────────────────────────────────────────────────────────────
@ -160,37 +250,44 @@ def load_config(path: str | None = None) -> dict:
"""Load configuration from *path*. """Load configuration from *path*.
If *path* is ``None`` the XDG-compliant location 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. 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: if path is None:
path = DEFAULT_CONFIG_PATH path = DEFAULT_CONFIG_PATH
if not os.path.exists(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) _write_default(path)
return dict(DEFAULT_CONFIG) return dict(DEFAULT_CONFIG)
try: try:
with open(path) as f: with open(path, "rb") as f:
cfg: dict = json.load(f) cfg: dict = tomllib.load(f)
cfg["leds"] = _validate_leds(cfg.get("leds", {})) cfg["leds"] = _validate_leds(cfg.get("leds", {}))
log.info("Loaded config from %s", path) log.info("Loaded config from %s", path)
return cfg return cfg
except json.JSONDecodeError as exc: except tomllib.TOMLDecodeError as exc:
log.error("Invalid JSON in %s: %s", path, exc) log.error("Invalid TOML in %s: %s", path, exc)
sys.exit(1) sys.exit(1)
def _write_default(path: str) -> None: 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: try:
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f: with open(path, "w") as f:
json.dump(DEFAULT_CONFIG, f, indent=2) f.write(DEFAULT_CONFIG_TOML)
f.write("\n")
log.info("Created default config at %s", path) log.info("Created default config at %s", path)
except OSError as exc: except OSError as exc:
log.warning("Could not write default config: %s", exc) log.warning("Could not write default config: %s", exc)