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
This commit is contained in:
Sean Doran committed 2026-02-27 14:38:36 -05:00
1 parent 2e77bf804e
commit 916541d815
9 files changed
+281 -117

No files matched your search

+3 -3
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
+1 -1
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
+2 -2
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
+46 -22
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
-56
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
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 (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"
+2 -1
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]
+1 -1
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"
+128 -31
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 (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( 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)