feat: initial release

Fixed volume steps for Bluetooth audio devices on PipeWire. Corrects the
coarse internal volume grid of devices such as AirPods Pro (16 AVRCP steps,
6.25 % per swipe) to the desktop's own step size.

The device grid is measured per device; the desktop step size is read from
Plasma. English and German messages.
This commit is contained in:
Felitendo committed 2026-08-16 03:10:23 +02:00
commit 6b36b4f742
7 files changed
+841

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+28
View File
@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, Felitendo
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+21
View File
@@ -0,0 +1,21 @@
PREFIX ?= /usr
DESTDIR ?=
BINDIR = $(DESTDIR)$(PREFIX)/bin
UNITDIR = $(DESTDIR)$(PREFIX)/lib/systemd/user
.PHONY: install uninstall check
install:
install -Dm755 bt-volume-step $(BINDIR)/bt-volume-step
install -Dm644 systemd/bt-volume-step.service \
$(UNITDIR)/bt-volume-step.service
sed -i 's|^ExecStart=.*|ExecStart=$(PREFIX)/bin/bt-volume-step|' \
$(UNITDIR)/bt-volume-step.service
uninstall:
rm -f $(BINDIR)/bt-volume-step
rm -f $(UNITDIR)/bt-volume-step.service
check:
python3 tests/test_bt_volume_step.py
+159
View File
@@ -0,0 +1,159 @@
# bt-volume-step
Fixed volume steps for Bluetooth audio devices on PipeWire.
## The problem
Many Bluetooth devices carry a coarse internal volume grid. AirPods Pro expose
only 16 AVRCP steps, so every swipe on the stem moves the system volume by
6.25 %, and the on-screen display walks through
```
6 · 13 · 19 · 25 · 31 · 38 · 44 · 50 · 56 · 63 · 69 · 75 · 81 · 88 · 94 · 100
```
instead of clean multiples of five. A speaker's volume buttons do the same
thing with whatever grid that speaker happens to use.
This is not a desktop misconfiguration — KDE's own volume step is already
exactly 5 %. The grid lives in the device firmware, and it cannot be changed:
the device sends *absolute* volume values over AVRCP, not increments.
## What this does
`bt-volume-step` watches for device-initiated volume changes, takes only their
*direction* into account, and applies a clean step of its own instead. One
press or swipe on the device becomes one step on your desktop's grid.
It works because such devices adopt a volume written back over AVRCP silently,
without reporting it, so there is no feedback loop. As a side effect the
device's internal position follows the corrected value, which keeps it from
running into the end of its own scale.
## Requirements
- PipeWire with `pactl` (`libpulse`)
- Python 3.9 or newer
- Optional: `kreadconfig6` (KDE Plasma) to follow the desktop's own step size
- Optional: `bluez-utils` for device names in `--show`
## Installation
### Arch Linux
```bash
yay -S bt-volume-step
```
### From source
```bash
sudo make install
```
`make install` honours `PREFIX` and `DESTDIR`; for a per-user install use
`make install PREFIX=$HOME/.local`.
## Usage
Enable it for your user session:
```bash
systemctl --user enable --now bt-volume-step
```
That is the whole setup. Connect a Bluetooth device and use its volume
control.
## Calibration
The daemon measures each device's grid on its own. **While a device's grid is
unknown it does not intervene at all** — it only watches. Once the same jump
has repeated three times, that jump is accepted as the device step and
remembered, and corrections start from then on. In practice: press volume-up
three or four times on a new device and it is set up.
Show what has been measured:
```bash
bt-volume-step --show
```
```
file: /home/you/.local/state/bt-volume-step/devsteps.json
AirPods Pro (30_0E_43_04_52_19) = 6.25 % (16 steps)
```
Discard a measurement (all devices, or one MAC):
```bash
bt-volume-step --reset
bt-volume-step --reset 30_0E_43_04_52_19
```
To keep the daemon from calibrating itself onto keyboard input, jumps that
match the desktop's own step size are excluded from measurement, as are jumps
below 1.5 % or above 20 %. A device whose grid happens to equal the desktop
step therefore never calibrates, and is left alone.
## Configuration
On KDE Plasma the step size comes from *System Settings → Audio → Volume step*
(`plasmaparc [General] VolumeStep`, read through `kreadconfig6` so KDE's
configuration cascade applies). Changes take effect within two seconds, with
no restart. On other desktops, or without `kreadconfig6`, it falls back to
5 %.
Everything can be overridden through the environment — put these in a drop-in
(`systemctl --user edit bt-volume-step`):
| Variable | Effect |
|---|---|
| `BT_VOL_STEP` | fixed step size in percent; ignores the desktop setting |
| `BT_VOL_DEVSTEP` | fixed device grid in percent; skips measurement |
| `BT_VOL_MAC` | only watch this device (MAC with `_` instead of `:`) |
| `BT_VOL_MAX` | upper limit in percent (default 100) |
The daemon handles several connected Bluetooth devices at once, keeping state
and calibration per device.
## Localisation
Messages follow `LC_ALL` / `LC_MESSAGES` / `LANG`. English and German are
included; other languages fall back to English. To add one, extend
`TRANSLATIONS` in the script — English strings are the keys.
## Limitations
**Foreign volume changes can be misread.** PipeWire events carry no
information about where a change came from, so a jump that happens to match
one or two device steps is indistinguishable from a button press. The daemon
accepts at most two steps per event to keep that window narrow, but it cannot
close it entirely.
**Devices that report their volume back are not supported.** The approach
assumes a device accepts a written volume silently. One that echoes a snapped
value back would oscillate. To check, set a volume and watch it for a few
seconds:
```bash
pactl set-sink-volume bluez_output.XX_XX_XX_XX_XX_XX.1 40%
sleep 5 && pactl get-sink-volume bluez_output.XX_XX_XX_XX_XX_XX.1
```
If the value drifts on its own, this tool is the wrong approach; disabling
hardware volume (`bluez5.enable-hw-volume = false` in WirePlumber) is the
alternative, at the cost of the device's own control.
## Tests
```bash
make check
```
Covers the decision logic and the measurement, including simulated devices
with 8, 16 and 32 steps.
## License
BSD 3-Clause. See [LICENSE](LICENSE).
Executable
+475
View File
@@ -0,0 +1,475 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2026, Felitendo
"""bt-volume-step - fixed volume steps for Bluetooth audio devices.
Many Bluetooth devices carry a coarse internal volume grid. AirPods Pro, for
example, expose only 16 AVRCP steps, so every swipe on the stem moves the
volume by 6.25 %: 6, 13, 19, 25, 31 ... Pressing volume-up on a speaker has
the same effect with whatever grid that speaker uses.
This daemon watches for device-initiated volume changes, takes only their
*direction* into account and applies a clean step of its own instead.
It works because such devices adopt a volume written over AVRCP silently,
without reporting it back, so there is no feedback loop. A device that does
report back would make the volume oscillate; check for that before use by
setting a volume and watching it for a few seconds.
On KDE Plasma the step size is taken from the desktop's own setting (System
Settings > Audio > "Volume step", stored as plasmaparc [General] VolumeStep).
Changes there take effect immediately, without restarting the daemon.
The *device* grid is measured per device: while it is unknown the daemon
keeps its hands off. Only once the same jump has repeated several times is it
accepted as the device step and remembered.
"""
import argparse
import json
import math
import os
import subprocess
import sys
import time
from pathlib import Path
__version__ = "1.0.0"
# ---------------------------------------------------------------- i18n
def _detect_language():
for var in ("LC_ALL", "LC_MESSAGES", "LANG"):
value = os.environ.get(var)
if value:
return value.split(".")[0].split("_")[0].lower()
return "en"
LANG = _detect_language()
# English strings are the keys. To add a language, add another table and
# extend TRANSLATIONS; untranslated strings fall back to English.
TRANSLATIONS = {
"de": {
"Fixed volume steps for Bluetooth audio devices.":
"Feste Lautstärkeschritte für Bluetooth-Audiogeräte.",
"show measured device steps and exit":
"gemessene Gerätestufen anzeigen und beenden",
"discard calibration (without MAC: all devices)":
"Kalibrierung verwerfen (ohne MAC: alle Geräte)",
"step size from Plasma": "Schrittweite aus Plasma",
"step size fixed at {step:g} %": "Schrittweite fest auf {step:g} %",
", device step fixed at {devstep:g} %":
", Gerätestufe fest auf {devstep:g} %",
"step size: {step:g} %": "Schrittweite: {step:g} %",
"cannot read the Plasma step size ({error}), using {fallback:g} %":
"Plasma-Schrittweite nicht lesbar ({error}), nutze {fallback:g} %",
"pactl list sinks failed: {error}":
"pactl list sinks fehlgeschlagen: {error}",
"cannot save the calibration: {error}":
"Kalibrierung nicht speicherbar: {error}",
"known device steps:": "bekannte Gerätestufen:",
"watching {device} at {volume:.1f} %":
"beobachte {device} bei {volume:.1f} %",
"{device}: measured device step = {step:.2f} % ({count} steps) - active from now on":
"{device}: Gerätestufe gemessen = {step:.2f} % ({count} Stufen) – ab jetzt aktiv",
"{device}: {last:.1f} % -> device reported {cur:.1f} % -> set {target:.1f} %":
"{device}: {last:.1f} % -> Gerät meldete {cur:.1f} % -> gesetzt {target:.1f} %",
"file: {path}": "Datei: {path}",
"no device step measured yet": "noch keine Gerätestufe gemessen",
"{step:g} % ({count} steps)": "{step:g} % ({count} Stufen)",
"all calibrations discarded": "alle Kalibrierungen verworfen",
"calibration for {device} discarded":
"Kalibrierung für {device} verworfen",
"no calibration for {mac}": "keine Kalibrierung für {mac}",
"restart the service: systemctl --user restart bt-volume-step":
"Dienst neu starten: systemctl --user restart bt-volume-step",
},
}
_TABLE = TRANSLATIONS.get(LANG, {})
def _(text):
"""Translate a string into the current language, English as fallback."""
return _TABLE.get(text, text)
# ---------------------------------------------------------------- settings
MAC_FILTER = os.environ.get("BT_VOL_MAC", "")
STEP_FIXED = os.environ.get("BT_VOL_STEP")
DEVSTEP_FORCED = os.environ.get("BT_VOL_DEVSTEP")
MAXV = float(os.environ.get("BT_VOL_MAX", "100"))
NORM = 65536.0 # PA_VOLUME_NORM
NOISE = 1.0 # % below this: rounding noise, or our own write
# Higher values make foreign changes harder to tell apart: with a fine device
# grid (32 steps = 3.125 %) an ordinary 10 % jump already reads as three
# button presses.
MAXMULT = 2 # device steps a single event may combine
TOL_REL = 0.35 # share of the device step tolerated when matching
STEP_TTL = 2.0 # s the Plasma step size is cached for
STEP_FALLBACK = 5.0 # % Plasma's own default
# --- measuring the device step ---
DEV_MIN = 1.5 # % below this the device is fine-grained; leave it alone
DEV_MAX = 20.0 # % above this it is not a volume button press
# 0.12 covers the spread seen in practice: on AirPods Pro the jumps ranged
# from 5.9 to 6.7 % around a step of 6.25 %.
CLUSTER_REL = 0.12 # relative tolerance for two samples to count as equal
NEED = 3 # matching samples required before locking the step
SAMPLE_CAP = 12 # older samples expire
STORE = (
Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state"))
/ "bt-volume-step" / "devsteps.json"
)
# C.UTF-8 rather than C: keeps pactl's messages English (we parse them)
# without Qt tools such as kreadconfig6 complaining about a non-UTF-8 locale.
ENV = {**os.environ, "LC_ALL": "C.UTF-8"}
_step_cache = (0.0, None) # (expires_at, value); None = never read
def log(msg):
print(msg, file=sys.stderr, flush=True)
def half_up(x):
"""Round half away from zero instead of Python's round-half-to-even.
Otherwise snap(65, 10) would be 60 while snap(75, 10) is 80 - that is,
unpredictable for values sitting exactly between two grid points.
"""
return math.floor(x + 0.5)
# ---------------------------------------------------------------- Plasma
def current_step():
"""The step size from Plasma, briefly cached.
kreadconfig6 rather than parsing plasmaparc directly, so that KDE's
configuration cascade (/etc/xdg, locked-down settings) applies. On a
desktop without kreadconfig6 the fallback is Plasma's own default.
"""
global _step_cache
if STEP_FIXED:
return float(STEP_FIXED)
expires, value = _step_cache
now = time.monotonic()
if now < expires:
return value
try:
out = subprocess.run(
["kreadconfig6", "--file", "plasmaparc", "--group", "General",
"--key", "VolumeStep", "--default", str(STEP_FALLBACK)],
capture_output=True, text=True, env=ENV, timeout=5,
).stdout.strip()
step = float(out)
if not 0 < step <= 100:
raise ValueError(f"implausible: {step}")
except (subprocess.SubprocessError, ValueError, OSError, FileNotFoundError) as exc:
log(_("cannot read the Plasma step size ({error}), using {fallback:g} %")
.format(error=exc, fallback=STEP_FALLBACK))
step = STEP_FALLBACK
if step != value:
log(_("step size: {step:g} %").format(step=step))
_step_cache = (now + STEP_TTL, step)
return step
# ---------------------------------------------------------------- PipeWire
def bt_sinks():
"""All Bluetooth outputs as (sink_name, mac, percent, description)."""
try:
out = subprocess.run(
["pactl", "-f", "json", "list", "sinks"],
capture_output=True, text=True, env=ENV, timeout=5,
).stdout
sinks = json.loads(out)
except (subprocess.SubprocessError, json.JSONDecodeError, OSError) as exc:
log(_("pactl list sinks failed: {error}").format(error=exc))
return []
found = []
for sink in sinks:
name = sink.get("name", "")
if not name.startswith("bluez_output."):
continue
parts = name.split(".")
if len(parts) < 2:
continue
mac = parts[1]
if MAC_FILTER and mac != MAC_FILTER:
continue
values = [ch["value"] for ch in sink.get("volume", {}).values()]
if not values:
continue
description = (sink.get("description") or "").strip()
found.append((name, mac, max(values) / NORM * 100.0, description))
return found
def set_volume(name, pct):
raw = int(round(pct / 100.0 * NORM))
subprocess.run(["pactl", "set-sink-volume", name, str(raw)], env=ENV, timeout=5)
# ---------------------------------------------------------------- device names
def paired_names():
"""MAC (underscore form) -> name for every paired BlueZ device.
Used by --show, which also lists devices that are not connected right now
and therefore have no sink to read a description from.
"""
try:
out = subprocess.run(
["bluetoothctl", "devices"],
capture_output=True, text=True, env=ENV, timeout=5,
).stdout
except (subprocess.SubprocessError, OSError):
return {}
names = {}
for line in out.splitlines():
parts = line.split(maxsplit=2)
if len(parts) == 3 and parts[0] == "Device":
names[parts[1].replace(":", "_")] = parts[2].strip()
return names
def label(mac, name=None):
"""Human-readable device label: 'Name (MAC)', or just the MAC."""
return f"{name} ({mac})" if name else mac
# ---------------------------------------------------------------- calibration
def load_store():
"""Read the calibration store, tolerating the flat v1 format."""
try:
raw = json.loads(STORE.read_text())
except (OSError, json.JSONDecodeError):
return {}
store = {}
for mac, value in raw.items():
if isinstance(value, (int, float)):
store[mac] = {"step": float(value)} # v1: bare number
elif isinstance(value, dict) and "step" in value:
store[mac] = value
return store
def save_store(store):
try:
STORE.parent.mkdir(parents=True, exist_ok=True)
STORE.write_text(json.dumps(store, indent=2, sort_keys=True) + "\n")
except OSError as exc:
log(_("cannot save the calibration: {error}").format(error=exc))
def calibrate(delta, samples, plasma_step):
"""Record a sample; return the device step once it is certain, else None.
A volume button press on the device always produces the same jump. Deltas
that match the desktop's own step size most likely come from the keyboard
and are skipped - otherwise the daemon would calibrate itself onto them.
"""
mag = abs(delta)
if not DEV_MIN <= mag <= DEV_MAX:
return None
if abs(mag - plasma_step) <= max(0.15, plasma_step * 0.02):
return None
samples.append(mag)
del samples[:-SAMPLE_CAP]
cluster = [s for s in samples if abs(s - mag) <= mag * CLUSTER_REL]
if len(cluster) < NEED:
return None
return sum(cluster) / len(cluster)
# ---------------------------------------------------------------- decision
def snap(pct, step):
"""Round to the nearest multiple of the step size, clamped to 0..MAXV."""
return min(MAXV, max(0.0, half_up(pct / step) * step))
def decide(last, cur, step, devstep):
"""Target volume for an observed change last -> cur, or None.
A button press on the device always moves the volume by a multiple of the
device step. Only then do we intervene and replace the movement with our
own grid; anything else is a foreign, absolute change and is accepted.
"""
delta = cur - last
if abs(delta) < NOISE:
return None
mult = half_up(abs(delta) / devstep)
if 1 <= mult <= MAXMULT and abs(abs(delta) - mult * devstep) <= devstep * TOL_REL:
direction = 1 if delta > 0 else -1
target = snap(last, step) + direction * mult * step
else:
target = snap(cur, step)
return min(MAXV, max(0.0, target))
# ---------------------------------------------------------------- daemon
def run():
store = load_store()
banner = (_("step size fixed at {step:g} %").format(step=float(STEP_FIXED))
if STEP_FIXED else _("step size from Plasma"))
if DEVSTEP_FORCED:
banner += _(", device step fixed at {devstep:g} %").format(
devstep=float(DEVSTEP_FORCED))
log(banner)
current_step()
if store:
log(_("known device steps:") + " " + ", ".join(
f"{label(mac, entry.get('name'))} = {entry['step']:g} %"
for mac, entry in sorted(store.items())))
state = {} # mac -> {"last": float, "samples": [float]}
proc = subprocess.Popen(
["pactl", "subscribe"],
stdout=subprocess.PIPE, text=True, env=ENV, bufsize=1,
)
for line in proc.stdout:
if "on sink #" not in line:
continue
plasma_step = current_step()
seen = set()
for sink_name, mac, cur, description in bt_sinks():
seen.add(mac)
entry = store.setdefault(mac, {})
if description and entry.get("name") != description:
entry["name"] = description
if "step" in entry:
save_store(store)
device = label(mac, entry.get("name"))
st = state.setdefault(mac, {"last": None, "samples": []})
if st["last"] is None:
st["last"] = cur
log(_("watching {device} at {volume:.1f} %")
.format(device=device, volume=cur))
continue
devstep = float(DEVSTEP_FORCED) if DEVSTEP_FORCED else entry.get("step")
if devstep is None:
# Not calibrated yet: let the change through, only measure.
delta = cur - st["last"]
if abs(delta) >= NOISE:
found = calibrate(delta, st["samples"], plasma_step)
if found:
entry["step"] = round(found, 3)
save_store(store)
log(_("{device}: measured device step = {step:.2f} % "
"({count} steps) - active from now on")
.format(device=device, step=found,
count=round(100 / found)))
st["last"] = cur
continue
target = decide(st["last"], cur, plasma_step, devstep)
if target is None:
st["last"] = cur
continue
if abs(target - cur) >= 0.05:
set_volume(sink_name, target)
log(_("{device}: {last:.1f} % -> device reported {cur:.1f} % "
"-> set {target:.1f} %")
.format(device=device, last=st["last"], cur=cur, target=target))
st["last"] = target
# Forget disconnected devices so they are re-read on reconnect.
for mac in set(state) - seen:
del state[mac]
return proc.wait()
# ---------------------------------------------------------------- CLI
def cmd_show():
store = load_store()
names = paired_names()
print(_("file: {path}").format(path=STORE))
if not store:
print(_("no device step measured yet"))
return 0
for mac, entry in sorted(store.items()):
step = entry["step"]
name = entry.get("name") or names.get(mac)
print(f" {label(mac, name)} = "
+ _("{step:g} % ({count} steps)").format(
step=step, count=round(100 / step)))
return 0
def cmd_reset(target):
store = load_store()
if target == "*":
store = {}
print(_("all calibrations discarded"))
elif target in store:
name = store[target].get("name")
del store[target]
print(_("calibration for {device} discarded")
.format(device=label(target, name)))
else:
print(_("no calibration for {mac}").format(mac=target))
return 1
save_store(store)
print(_("restart the service: systemctl --user restart bt-volume-step"))
return 0
def main(argv=None):
ap = argparse.ArgumentParser(
prog="bt-volume-step",
description=_("Fixed volume steps for Bluetooth audio devices."),
)
ap.add_argument("--show", action="store_true",
help=_("show measured device steps and exit"))
ap.add_argument("--reset", metavar="MAC", nargs="?", const="*",
help=_("discard calibration (without MAC: all devices)"))
ap.add_argument("--version", action="version",
version=f"%(prog)s {__version__}")
args = ap.parse_args(argv)
if args.show:
return cmd_show()
if args.reset:
return cmd_reset(args.reset)
return run()
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
pass
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Fixed volume steps for Bluetooth audio devices
Documentation=https://github.com/Felitendo/bt-volume-step
After=pipewire-pulse.service
Wants=pipewire-pulse.service
[Service]
Type=simple
ExecStart=/usr/bin/bt-volume-step
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: BSD-3-Clause
"""Tests for the decision and measurement logic, without touching audio."""
from importlib.machinery import SourceFileLoader
from pathlib import Path
SCRIPT = Path(__file__).resolve().parent.parent / "bt-volume-step"
m = SourceFileLoader("btvs", str(SCRIPT)).load_module()
AIRPODS = 6.25 # 16 steps
BOX32 = 3.125 # 32 steps
BOX8 = 12.5 # 8 steps
fails = 0
def check(ok, text):
global fails
if not ok:
fails += 1
print(f"{'ok ' if ok else 'FAIL'} {text}")
# ---------------------------------------------------------------- decide()
# (description, step size, device step, last, cur, expected target)
CASES = [
# --- AirPods, 5 % steps: deltas taken from a real capture
("swipe up from 60", 5, AIRPODS, 60.0, 66.1, 65.0),
("swipe up from 45", 5, AIRPODS, 45.0, 51.2, 50.0),
("swipe down from 65", 5, AIRPODS, 65.0, 59.1, 60.0),
("swipe down from 35", 5, AIRPODS, 35.0, 28.3, 30.0),
("swipe from an off-grid value", 5, AIRPODS, 65.35, 59.1, 60.0),
# Keyboard: Plasma sets exactly +/-5 %, so target == cur, no double step
("key up 55->60", 5, AIRPODS, 55.0, 60.0, 60.0),
("key down 60->55", 5, AIRPODS, 60.0, 55.0, 55.0),
# Foreign absolute changes must pass through
("foreign set 60->50", 5, AIRPODS, 60.0, 50.0, 50.0),
("foreign set 60->20", 5, AIRPODS, 60.0, 20.0, 20.0),
# Ambiguous: -13 % is indistinguishable from two swipes (-12.5 %)
("foreign jump 60->47 (ambiguous)", 5, AIRPODS, 60.0, 47.0, 50.0),
# Noise / our own echo
("noise 65->65.35", 5, AIRPODS, 65.0, 65.35, None),
("no change", 5, AIRPODS, 60.0, 60.0, None),
# Two fast swipes coalesced into one event
("double swipe up", 5, AIRPODS, 60.0, 72.5, 70.0),
("double swipe down", 5, AIRPODS, 60.0, 47.5, 50.0),
# Limits
("swipe up at maximum", 5, AIRPODS, 100.0, 100.0, None),
("swipe up near maximum", 5, AIRPODS, 96.9, 100.0, 100.0),
("swipe down at minimum", 5, AIRPODS, 0.0, 0.0, None),
("swipe down near zero", 5, AIRPODS, 3.1, 0.0, 0.0),
# --- other desktop step sizes
("10%: swipe up from 60", 10, AIRPODS, 60.0, 66.1, 70.0),
("10%: swipe down from 60", 10, AIRPODS, 60.0, 53.5, 50.0),
("10%: swipe from off-grid", 10, AIRPODS, 65.0, 71.1, 80.0),
("10%: key up 60->70", 10, AIRPODS, 60.0, 70.0, 70.0),
# 61 sits exactly between; half_up locks onto 62, one step down -> 60
("2%: swipe down from 61", 2, AIRPODS, 61.0, 54.8, 60.0),
("2%: swipe up from 60", 2, AIRPODS, 60.0, 66.1, 62.0),
# --- speaker with 32 steps (3.125 %)
("32-step: key up from 60", 5, BOX32, 60.0, 63.1, 65.0),
("32-step: key down from 60", 5, BOX32, 60.0, 56.9, 55.0),
("32-step: double press up", 5, BOX32, 60.0, 66.3, 70.0),
# A 10 % foreign jump is no multiple of 3.125 within MAXMULT -> pass through
("32-step: foreign jump 60->50", 5, BOX32, 60.0, 50.0, 50.0),
# --- speaker with 8 steps (12.5 %)
("8-step: key up from 50", 5, BOX8, 50.0, 62.5, 55.0),
("8-step: key down from 50", 5, BOX8, 50.0, 37.5, 45.0),
]
for desc, step, devstep, last, cur, want in CASES:
got = m.decide(last, cur, float(step), devstep)
ok = (got is None and want is None) or (
got is not None and want is not None and abs(got - want) < 0.01
)
check(ok, f"{desc:34s} {last:6.2f} -> {cur:6.2f} want {want} got {got}")
print()
# ---------------------------------------------------------------- calibrate()
def feed(deltas, plasma_step=5.0):
"""Feed samples one by one; return the first locked-in device step."""
samples = []
for d in deltas:
found = m.calibrate(d, samples, plasma_step)
if found:
return found
return None
got = feed([6.1, -6.4, 6.2])
check(got is not None and abs(got - 6.25) < 0.3,
f"AirPods spread calibrates -> {got}")
got = feed([5.9, 6.7, 6.1, 6.4])
check(got is not None and abs(got - 6.25) < 0.4,
f"wider spread calibrates -> {got}")
got = feed([3.1, 3.2, 3.1])
check(got is not None and abs(got - 3.125) < 0.2,
f"32-step speaker calibrates -> {got}")
got = feed([5.0, 5.0, 5.0, 5.0, 5.0])
check(got is None, f"keyboard deltas (= desktop step) do NOT calibrate -> {got}")
got = feed([10.0, 10.0, 10.0], plasma_step=10.0)
check(got is None, f"keyboard at a 10 % desktop step does NOT calibrate -> {got}")
got = feed([0.8, 0.79, 0.8, 0.8])
check(got is None, f"fine-grained device (<1.5 %) is ignored -> {got}")
got = feed([25.0, 25.0, 25.0])
check(got is None, f"jumps that are too large (>20 %) are ignored -> {got}")
got = feed([6.2, 13.0, 2.0, 6.1, 19.0, 6.3])
check(got is not None and abs(got - 6.25) < 0.4,
f"measurement finds the cluster despite noise -> {got}")
got = feed([6.2, 6.2])
check(got is None, f"two samples are not enough yet -> {got}")
print()
# ---------------------------------------------------------------- misc
check(m.label("AA_BB", "Speaker") == "Speaker (AA_BB)", "label with name")
check(m.label("AA_BB", None) == "AA_BB", "label without name")
check(m.half_up(6.5) == 7 and m.half_up(7.5) == 8, "half_up rounds consistently")
check(abs(m.snap(65, 10) - 70) < 0.01 and abs(m.snap(75, 10) - 80) < 0.01,
"snap is consistent for exact midpoints")
print()
total = len(CASES) + 9 + 4
print(f"{total - fails}/{total} passed")
raise SystemExit(1 if fails else 0)