mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 16:33:48 +01:00
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint were still grading against 3.10/3.11 -- three tools judging an interpreter nothing runs any more, and ruff withholding the pyupgrade fixes that only become safe at the newer target. `pin_ci_python.py` rewrites these three knobs. What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC` is now correct (UP017), `socket.timeout` where `TimeoutError` is (UP041), and ruff's stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`. That makes the source 3.14-only at the syntax level, which is what `requires-python = ">=3.14"` already promised; kuhy chose it deliberately over holding ruff a release back (2026-08-29). ruff, ruff-format, mypy --strict and pylint all clean at the new target. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
250 lines
8.8 KiB
Python
250 lines
8.8 KiB
Python
"""PipeWire sink/card enumeration, activation, and restore for the wake alarm.
|
|
|
|
At wake time the Bluetooth speaker is gone and WirePlumber leaves most card
|
|
profiles ``off``, so the machine is silent. This module forces every card's
|
|
output on, unmutes and maxes every sink, and captures the prior state so the
|
|
alarm can restore it on dismiss. The alarm plays on *all* sinks every beep
|
|
cycle (see :func:`wake_alarm._audio._play_on_all_sinks`) rather than betting on
|
|
a single chosen output.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import logging
|
|
import shutil
|
|
import subprocess
|
|
|
|
from wake_alarm._constants import (
|
|
ALARM_AUDIO_SINK,
|
|
)
|
|
from wake_alarm._sink_parsing import (
|
|
_CardAccum,
|
|
_CardInfo,
|
|
_parse_cards,
|
|
_parse_sinks,
|
|
)
|
|
|
|
# Imported under their original names because the suite patches
|
|
# `wake_alarm._sinks._parse_cards` and friends: a moved symbol makes patch()
|
|
# stop intercepting *silently*. __all__ stops ruff deleting them as unused.
|
|
__all__ = ["_CardAccum", "_CardInfo", "_parse_cards", "_parse_sinks"]
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _pactl_path() -> str | None:
|
|
"""Return the absolute path to pactl, or None when not installed."""
|
|
return shutil.which("pactl")
|
|
|
|
|
|
def _pactl_text(pactl: str, *args: str, timeout: float = 5.0) -> str | None:
|
|
"""Run ``pactl <args>`` and return decoded stdout, or None on failure."""
|
|
try:
|
|
result = subprocess.run(
|
|
[pactl, *args],
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
except OSError, subprocess.TimeoutExpired:
|
|
_logger.warning("pactl %s failed", " ".join(args), exc_info=True)
|
|
return None
|
|
return result.stdout.decode(errors="replace")
|
|
|
|
|
|
def _list_real_sinks(pactl: str) -> list[str]:
|
|
"""Return every non-``auto_null`` sink name from ``pactl list short sinks``."""
|
|
text = _pactl_text(pactl, "list", "short", "sinks")
|
|
if text is None:
|
|
return []
|
|
names = [line.split("\t")[1] for line in text.splitlines() if "\t" in line]
|
|
return [name for name in names if name != "auto_null"]
|
|
|
|
|
|
def _list_cards(pactl: str) -> list[str]:
|
|
"""Return every card name from ``pactl list short cards``."""
|
|
text = _pactl_text(pactl, "list", "short", "cards")
|
|
if text is None:
|
|
return []
|
|
return [line.split("\t")[1] for line in text.splitlines() if "\t" in line]
|
|
|
|
|
|
def _current_default_sink(pactl: str) -> str | None:
|
|
"""Return the current default sink name, or None on failure / empty."""
|
|
try:
|
|
result = subprocess.run(
|
|
[pactl, "get-default-sink"],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
except OSError, subprocess.TimeoutExpired:
|
|
_logger.warning("pactl get-default-sink failed", exc_info=True)
|
|
return None
|
|
name = result.stdout.decode(errors="replace").strip()
|
|
return name or None
|
|
|
|
|
|
def _ensure_sink_audible(pactl: str, sink: str) -> None:
|
|
"""Unmute *sink* and raise it to 100%, so playback on it is actually heard."""
|
|
for args in (
|
|
["set-sink-mute", sink, "0"],
|
|
["set-sink-volume", sink, "100%"],
|
|
):
|
|
subprocess.run(
|
|
[pactl, *args],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _AudioState:
|
|
"""Snapshot of audio state to restore after the alarm is dismissed."""
|
|
|
|
default_sink: str | None
|
|
card_profiles: dict[str, str] # card name -> active profile
|
|
sink_volumes: dict[str, str] # sink name -> raw first-channel volume
|
|
sink_mutes: dict[str, bool] # sink name -> muted?
|
|
|
|
|
|
def _capture_audio_state(pactl: str) -> _AudioState:
|
|
"""Capture every card profile, sink volume/mute, and the default sink."""
|
|
cards = _parse_cards(_pactl_text(pactl, "list", "cards") or "")
|
|
card_profiles = {
|
|
name: info.active_profile
|
|
for name, info in cards.items()
|
|
if info.active_profile is not None
|
|
}
|
|
sink_volumes, sink_mutes = _parse_sinks(_pactl_text(pactl, "list", "sinks") or "")
|
|
return _AudioState(
|
|
default_sink=_current_default_sink(pactl),
|
|
card_profiles=card_profiles,
|
|
sink_volumes=sink_volumes,
|
|
sink_mutes=sink_mutes,
|
|
)
|
|
|
|
|
|
def _activate_all_outputs() -> _AudioState | None:
|
|
"""Turn on every card's output and make every sink audible for the alarm.
|
|
|
|
At wake time the Bluetooth speaker is gone and WirePlumber leaves most card
|
|
profiles ``off``, so the alarm is silent. This forces every card's best
|
|
output profile on, then unmutes and raises every sink to 100%. The alarm
|
|
then plays on *all* sinks every beep cycle
|
|
(:func:`wake_alarm._audio._play_on_all_sinks`) — it does not pick one output
|
|
and hope: a switched-off external speaker cannot be revived by software, so
|
|
every *other* output is tried in parallel instead.
|
|
|
|
It does not block waiting for the known-slow ``ALARM_AUDIO_SINK`` (the G27Q
|
|
monitor's HDMI speaker, ~15 s to enumerate); it activates whatever is present
|
|
now and lets the per-cycle presence check in ``_play_on_all_sinks`` sweep in
|
|
the rest. The default sink, when set, is only a convenience — per-cycle
|
|
all-sink playback is what guarantees audio.
|
|
|
|
Returns:
|
|
The prior audio state (to restore on close), or ``None`` only when
|
|
pactl is unavailable (nothing was changed, so nothing to restore).
|
|
"""
|
|
pactl = _pactl_path()
|
|
if pactl is None:
|
|
_logger.warning("pactl not on PATH; cannot activate alarm audio")
|
|
return None
|
|
state = _capture_audio_state(pactl)
|
|
for card, info in _parse_cards(_pactl_text(pactl, "list", "cards") or "").items():
|
|
if info.output_profiles:
|
|
subprocess.run(
|
|
[pactl, "set-card-profile", card, info.output_profiles[0]],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
# No sleep-and-hope wait. Forcing the profiles on above makes sinks appear
|
|
# asynchronously (the monitor's HDMI sink can take ~15 s), but we don't block
|
|
# on a timer guessing when it's "ready". We activate whatever is present
|
|
# right now; the beep loop's _play_on_all_sinks re-checks _list_real_sinks
|
|
# every cycle, so each output is made audible and played on the moment it
|
|
# actually exists — a real presence check, never a timed guess.
|
|
sinks = _list_real_sinks(pactl)
|
|
if not sinks:
|
|
_logger.warning(
|
|
"No real sink present yet; the beep loop will pick sinks up as they "
|
|
"enumerate (PC speaker covers the gap).",
|
|
)
|
|
return state
|
|
for sink in sinks:
|
|
_ensure_sink_audible(pactl, sink)
|
|
default = ALARM_AUDIO_SINK if ALARM_AUDIO_SINK in sinks else sinks[0]
|
|
subprocess.run(
|
|
[pactl, "set-default-sink", default],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
_logger.warning(
|
|
"Alarm audio activated on %d present sink(s) at 100%%, default=%s; more "
|
|
"are added by the beep loop as they enumerate.",
|
|
len(sinks),
|
|
default,
|
|
)
|
|
return state
|
|
|
|
|
|
def _restore_audio_state(state: _AudioState | None) -> None:
|
|
"""Restore card profiles, sink volumes/mutes, and default captured earlier."""
|
|
if state is None:
|
|
return
|
|
pactl = _pactl_path()
|
|
if pactl is None:
|
|
return
|
|
for card, profile in state.card_profiles.items():
|
|
subprocess.run(
|
|
[pactl, "set-card-profile", card, profile],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
for sink, volume in state.sink_volumes.items():
|
|
subprocess.run(
|
|
[pactl, "set-sink-volume", sink, volume],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
for sink, muted in state.sink_mutes.items():
|
|
subprocess.run(
|
|
[pactl, "set-sink-mute", sink, "1" if muted else "0"],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
if state.default_sink is not None:
|
|
subprocess.run(
|
|
[pactl, "set-default-sink", state.default_sink],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def _warn_if_no_real_sink() -> None:
|
|
"""Log a warning when PipeWire has no real (non-auto_null) sink yet.
|
|
|
|
Informational only: :func:`_activate_all_outputs` runs afterwards and forces
|
|
card profiles on, so a missing sink here is not necessarily fatal.
|
|
"""
|
|
pactl = _pactl_path()
|
|
if pactl is None:
|
|
_logger.warning("pactl not on PATH; cannot verify audio sinks")
|
|
return
|
|
real_sinks = _list_real_sinks(pactl)
|
|
if not real_sinks:
|
|
_logger.warning(
|
|
"No real PipeWire sink yet — alarm will force card profiles on and "
|
|
"fall back to the PC speaker.",
|
|
)
|
|
else:
|
|
_logger.info("Audio sinks available: %s", real_sinks)
|