mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 19:34:03 +01:00
Monitors: best mode automatically on hotplug
systemd user service watches RandR output events and puts every connected monitor at native resolution + max refresh, DP-0 primary on the left, unplugged outputs switched off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWXa4ytX9adN7qpMAop
This commit is contained in:
parent
1b5a040b31
commit
53da9b5a12
@ -0,0 +1,11 @@
|
||||
{
|
||||
"title": "Monitors: best mode automatically on hotplug",
|
||||
"objective": "A systemd user service keeps every connected monitor at its native resolution and highest refresh rate, DP-0 primary and leftmost, others to its right, re-applied whenever a monitor is connected or disconnected.",
|
||||
"acceptance_criteria": [
|
||||
"plan() turns the 2026-11-11 DP-0-off state into DP-0 3840x2160@144 at 0x0 plus HDMI-0 2560x1440@143.98 at 3840x0, and returns [] when already there",
|
||||
"The watcher handles one burst of output events with one apply, survives a failed xrandr, and waits for late EDID modes",
|
||||
"setup_monitor_autoconfig.sh leaves monitor-autoconfig.service enabled and active"
|
||||
],
|
||||
"out_of_scope": ["Undoing deliberate mode changes by games or manual xrandr", "Wayland"],
|
||||
"verifier": "python3 -m pytest linux_configuration/tests/test_monitor_layout.py linux_configuration/tests/test_monitor_autoconfig.py; pre-commit run --files <changed-files>"
|
||||
}
|
||||
12
docs/superpowers/evidence/monitor-autoconfig-2026-11-11.json
Normal file
12
docs/superpowers/evidence/monitor-autoconfig-2026-11-11.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"intent": "Every monitor kuhy connects runs at its native resolution and highest refresh rate automatically, with DP-0 primary on the left, without running xrandr by hand.",
|
||||
"scope": ["linux_configuration/features/monitor_layout.py, monitor_autoconfig.py, setup_monitor_autoconfig.sh, systemd/monitor-autoconfig.service", "linux_configuration/tests/test_monitor_layout.py, test_monitor_autoconfig.py, conftest.py; meta/pyproject.toml pylint init-hook path", "Non-goal: reverting deliberate mode changes (games, manual xrandr); only connect/disconnect triggers a re-layout"],
|
||||
"changes": ["monitor_layout.py: parses xrandr --query and plans one row: native size at max refresh, preferred primary leftmost, disconnected outputs holding a CRTC switched off; returns [] when already there", "monitor_autoconfig.py: systemd user service; RandR version handshake, listens for RROutputChangeNotify, settles 1 s, retries while a connected monitor lists no modes, logs and survives a failed xrandr", "setup_monitor_autoconfig.sh installs deps, the user unit (WantedBy graphical-session.target) and starts it"],
|
||||
"verification": [
|
||||
{"command": "python3 -m pytest linux_configuration/tests/test_monitor_layout.py linux_configuration/tests/test_monitor_autoconfig.py --cov=monitor_layout --cov=monitor_autoconfig --cov-branch", "result": "pass", "evidence": "23 passed, 100% line+branch coverage on both modules"},
|
||||
{"command": "linux_configuration/features/setup_monitor_autoconfig.sh", "result": "pass", "evidence": "unit enabled and active; layout DP-0 3840x2160@144 + HDMI-0 2560x1440@143.98 left unchanged (dry-run plan empty)"},
|
||||
{"command": "RandR probe with/without xrandr_query_version", "result": "pass", "evidence": "no events before the handshake, CrtcChange/ScreenChange events after it"}
|
||||
],
|
||||
"risks": ["Physical hotplug not exercised on the real machine (kuhy declined the replug check); relies on the NVIDIA driver emitting RROutputChangeNotify on connect"],
|
||||
"rollback": ["systemctl --user disable --now monitor-autoconfig.service; git revert", "xrandr layout stays as last applied"]
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
{
|
||||
"intent": "Pin that `phone_vd.sh record start|stop` (new in ~/.claude: real MP4 video of a session's own virtual display via a read-only scrcpy) passes the phone_guard PreToolUse hook as a lease-free read, while the adb input that drives the recorded app still takes that app's lease.",
|
||||
"scope": [
|
||||
"python_pkg/phone_guard/tests/test_hook.py",
|
||||
"Non-goal: changing phone_guard classification -- the hook only inspects commands containing 'adb', so `phone_vd.sh record` never reached it and needed no change"
|
||||
],
|
||||
"changes": [
|
||||
"TestLeases.test_recording_own_display_is_a_read: record start/stop take no lease and are not blocked; `phone_vd.sh record start a.mp4 && adb shell input -d 9 swipe ...` on the session's own display takes exactly pkg:<its app>"
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "capped.sh python3 -m pytest python_pkg/phone_guard -q --cov-branch --cov-report=term-missing",
|
||||
"result": "pass",
|
||||
"evidence": "68 passed; every python_pkg/phone_guard module 100% statements and branches"
|
||||
},
|
||||
{
|
||||
"command": "phone_vd.sh record start ~/data/claude-scratch/phone-record/vd37_final.mp4; 5 x adb shell input -d 37 swipe; phone_vd.sh record stop (Pixel 6a 23181JEGR08034, VD 37 running com.android.settings)",
|
||||
"result": "pass",
|
||||
"evidence": "ffprobe: h264 1080x2400, duration 10.511 s, 307 frames (~29 fps avg, ~35 fps while scrolling); the hook let every call through (the unit test pins that the swipe maps to pkg:<own app>; not observed live)"
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"A raw `scrcpy --display-id=<other VD>` still bypasses the guard (no 'adb' in the command); phone_vd.sh only ever records the caller's own display"
|
||||
],
|
||||
"rollback": [
|
||||
"git revert this commit; it only adds a test and this evidence file",
|
||||
"Run python3 -m pytest python_pkg/phone_guard afterwards"
|
||||
]
|
||||
}
|
||||
169
linux_configuration/features/monitor_autoconfig.py
Executable file
169
linux_configuration/features/monitor_autoconfig.py
Executable file
@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Keep every connected monitor at its best mode, re-applied on hotplug.
|
||||
|
||||
Runs as the ``monitor-autoconfig`` systemd user service (installed by
|
||||
``setup_monitor_autoconfig.sh``). It listens for X RandR output-change
|
||||
events instead of a udev rule: no root, nothing in /etc, and a systemd
|
||||
unit cannot be (re)started from a udev ``change`` event reliably.
|
||||
|
||||
Usage:
|
||||
monitor_autoconfig.py # apply now, then watch for hotplug
|
||||
monitor_autoconfig.py --once # apply now and exit
|
||||
monitor_autoconfig.py --dry-run # print the xrandr command only
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
from monitor_layout import parse_xrandr, plan
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from monitor_layout import Output
|
||||
|
||||
# Xlib.ext.randr.RROutputChangeNotifyMask; fires on connect and disconnect.
|
||||
# CRTC events are left out on purpose: they fire on every mode switch, so
|
||||
# listening to them would undo a game's or kuhy's own deliberate xrandr change.
|
||||
OUTPUT_CHANGE_MASK = 4
|
||||
# A freshly plugged monitor can take a moment before its EDID modes show up.
|
||||
SETTLE_SECONDS = 1.0
|
||||
# How many settle periods to wait for a connected monitor to list any mode.
|
||||
MODE_TRIES = 5
|
||||
|
||||
|
||||
class _Root(Protocol):
|
||||
"""Root window: takes the RandR event mask."""
|
||||
|
||||
def xrandr_select_input(self, mask: int) -> object:
|
||||
"""Subscribe to RandR events."""
|
||||
|
||||
|
||||
class _Screen(Protocol):
|
||||
"""Screen: exposes its root window."""
|
||||
|
||||
root: _Root
|
||||
|
||||
|
||||
class Display(Protocol):
|
||||
"""The slice of ``Xlib.display.Display`` the watcher uses."""
|
||||
|
||||
def screen(self) -> _Screen:
|
||||
"""Default screen; its ``root`` window takes the event mask."""
|
||||
|
||||
def next_event(self) -> object:
|
||||
"""Block until the next X event arrives."""
|
||||
|
||||
def pending_events(self) -> int:
|
||||
"""Number of events already queued."""
|
||||
|
||||
def xrandr_query_version(self) -> object:
|
||||
"""RandR version handshake."""
|
||||
|
||||
|
||||
def _run(argv: list[str]) -> str:
|
||||
"""Run a command, return stdout; raise on non-zero exit."""
|
||||
return subprocess.run(argv, check=True, capture_output=True, text=True).stdout
|
||||
|
||||
|
||||
def _settle() -> None:
|
||||
"""Give a just-plugged monitor time to publish its EDID modes."""
|
||||
time.sleep(SETTLE_SECONDS)
|
||||
|
||||
|
||||
def _query(
|
||||
run: Callable[[list[str]], str], settle: Callable[[], None], tries: int
|
||||
) -> list[Output]:
|
||||
"""Query xrandr, re-asking while a connected monitor still lists no mode."""
|
||||
outputs = parse_xrandr(run(["xrandr", "--query"]))
|
||||
for _ in range(tries - 1):
|
||||
if all(o.modes for o in outputs if o.connected):
|
||||
break
|
||||
settle()
|
||||
outputs = parse_xrandr(run(["xrandr", "--query"]))
|
||||
return outputs
|
||||
|
||||
|
||||
def apply_once(
|
||||
primary: str,
|
||||
run: Callable[[list[str]], str] = _run,
|
||||
settle: Callable[[], None] = _settle,
|
||||
tries: int = MODE_TRIES,
|
||||
) -> bool:
|
||||
"""Apply the best layout; return whether xrandr had to change anything."""
|
||||
args = plan(_query(run, settle, tries), primary)
|
||||
if not args:
|
||||
return False
|
||||
run(["xrandr", *args])
|
||||
sys.stdout.write(" ".join(["applied: xrandr", *args]) + "\n")
|
||||
sys.stdout.flush()
|
||||
return True
|
||||
|
||||
|
||||
def _safe_apply(
|
||||
primary: str, run: Callable[[list[str]], str], settle: Callable[[], None]
|
||||
) -> None:
|
||||
"""Apply, but log a failed xrandr instead of dying.
|
||||
|
||||
One bad monitor must not stop every later hotplug from being handled.
|
||||
"""
|
||||
try:
|
||||
apply_once(primary, run, settle)
|
||||
except subprocess.CalledProcessError as error:
|
||||
sys.stderr.write(f"xrandr failed ({error.returncode}): {error.stderr}\n")
|
||||
|
||||
|
||||
def _open_display() -> Display:
|
||||
"""Connect to ``$DISPLAY`` (Xlib imported lazily; tests never need it)."""
|
||||
display: Display = importlib.import_module("Xlib.display").Display()
|
||||
return display
|
||||
|
||||
|
||||
def watch(
|
||||
primary: str,
|
||||
display: Display | None = None,
|
||||
run: Callable[[list[str]], str] = _run,
|
||||
settle: Callable[[], None] = _settle,
|
||||
) -> None:
|
||||
"""Apply now, then once per burst of RandR output events, forever."""
|
||||
display = display or _open_display()
|
||||
# Without announcing RandR >= 1.2 the server never sends output events.
|
||||
display.xrandr_query_version()
|
||||
display.screen().root.xrandr_select_input(OUTPUT_CHANGE_MASK)
|
||||
_safe_apply(primary, run, settle)
|
||||
while True:
|
||||
display.next_event()
|
||||
settle()
|
||||
# Our own xrandr call and multi-output plugs emit several events;
|
||||
# drain them so one burst costs one re-plan.
|
||||
while display.pending_events():
|
||||
display.next_event()
|
||||
_safe_apply(primary, run, settle)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None, run: Callable[[list[str]], str] = _run) -> int:
|
||||
"""CLI entry point."""
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--primary", default="DP-0", help="leftmost primary output")
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument("--once", action="store_true", help="apply and exit")
|
||||
mode.add_argument("--dry-run", action="store_true", help="print, change nothing")
|
||||
args = parser.parse_args(argv)
|
||||
if args.dry_run:
|
||||
planned = plan(_query(run, _settle, MODE_TRIES), args.primary)
|
||||
sys.stdout.write(" ".join(["xrandr", *planned]) + "\n")
|
||||
elif args.once:
|
||||
apply_once(args.primary, run)
|
||||
else:
|
||||
watch(args.primary)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
142
linux_configuration/features/monitor_layout.py
Executable file
142
linux_configuration/features/monitor_layout.py
Executable file
@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plan an xrandr layout: every monitor at its best mode, side by side.
|
||||
|
||||
Pure functions only (text in, argument list out) so the policy is testable
|
||||
without an X server; ``monitor_autoconfig.py`` does the I/O. Run directly,
|
||||
``xrandr --query | monitor_layout.py`` prints the plan for any xrandr dump.
|
||||
|
||||
Policy, agreed 2026-11-11:
|
||||
- best mode = the monitor's native (EDID-preferred, ``+``) size at the
|
||||
highest refresh rate offered for that size. A bigger listed size is a
|
||||
downscale, not "better". Without a preferred mode: largest area, then
|
||||
fastest, interlaced modes skipped.
|
||||
- layout = one row at y=0; the preferred primary (default DP-0) leftmost,
|
||||
the rest in xrandr order to its right. If it is absent, the first
|
||||
connected output is primary.
|
||||
- disconnected outputs that still hold a CRTC are switched off, so no gap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
import sys
|
||||
|
||||
_HEADER = re.compile(
|
||||
r"^(?P<name>\S+) (?P<state>connected|disconnected)(?P<primary> primary)?"
|
||||
r"(?: (?P<geometry>\d+x\d+\+\d+\+\d+))?"
|
||||
)
|
||||
_MODE_LINE = re.compile(r"^\s+(?P<w>\d+)x(?P<h>\d+)(?P<suffix>\S*)\s+(?P<rates>.*)$")
|
||||
# "59.95 +", "143.98*", "60.00*+": the + may sit one space after the rate.
|
||||
_RATE = re.compile(r"(?P<rate>\d+\.\d+)(?P<current>\*?) ?(?P<preferred>\+?)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Mode:
|
||||
"""One resolution + refresh rate, rate kept as xrandr prints it."""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
rate: str
|
||||
|
||||
@property
|
||||
def size(self) -> str:
|
||||
"""``WxH`` as xrandr's ``--mode`` expects."""
|
||||
return f"{self.width}x{self.height}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Output:
|
||||
"""One xrandr output and the modes its monitor offers."""
|
||||
|
||||
name: str
|
||||
connected: bool
|
||||
primary: bool
|
||||
geometry: str | None
|
||||
modes: list[Mode] = field(default_factory=list)
|
||||
current: Mode | None = None
|
||||
preferred_size: tuple[int, int] | None = None
|
||||
|
||||
|
||||
def _add_mode_line(output: Output, match: re.Match[str]) -> None:
|
||||
"""Record every rate of one mode line, plus its ``*``/``+`` flags."""
|
||||
width, height = int(match["w"]), int(match["h"])
|
||||
interlaced = match["suffix"].startswith("i")
|
||||
for rate in _RATE.finditer(match["rates"]):
|
||||
mode = Mode(width, height, rate["rate"])
|
||||
if not interlaced:
|
||||
output.modes.append(mode)
|
||||
if rate["current"]:
|
||||
output.current = mode
|
||||
if rate["preferred"]:
|
||||
output.preferred_size = (width, height)
|
||||
|
||||
|
||||
def parse_xrandr(text: str) -> list[Output]:
|
||||
"""Parse ``xrandr --query`` output into outputs, in xrandr order."""
|
||||
outputs: list[Output] = []
|
||||
for line in text.splitlines():
|
||||
header = _HEADER.match(line)
|
||||
if header:
|
||||
outputs.append(
|
||||
Output(
|
||||
name=header["name"],
|
||||
connected=header["state"] == "connected",
|
||||
primary=bool(header["primary"]),
|
||||
geometry=header["geometry"],
|
||||
)
|
||||
)
|
||||
continue
|
||||
mode_line = _MODE_LINE.match(line)
|
||||
if mode_line and outputs:
|
||||
_add_mode_line(outputs[-1], mode_line)
|
||||
return outputs
|
||||
|
||||
|
||||
def pick_mode(output: Output) -> Mode | None:
|
||||
"""Native size at max refresh; else largest area at max refresh."""
|
||||
candidates = output.modes
|
||||
if output.preferred_size:
|
||||
native = [m for m in candidates if (m.width, m.height) == output.preferred_size]
|
||||
candidates = native or candidates
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda m: (m.width * m.height, float(m.rate)))
|
||||
|
||||
|
||||
def _ordered(outputs: list[Output], primary: str) -> list[tuple[Output, Mode]]:
|
||||
"""Connected outputs with a usable mode, preferred primary first."""
|
||||
usable = [(o, m) for o in outputs if o.connected and (m := pick_mode(o))]
|
||||
usable.sort(key=lambda pair: pair[0].name != primary)
|
||||
return usable
|
||||
|
||||
|
||||
def plan(outputs: list[Output], primary: str) -> list[str]:
|
||||
"""Return the xrandr arguments to reach the policy, ``[]`` if already there."""
|
||||
row = _ordered(outputs, primary)
|
||||
args: list[str] = []
|
||||
changed = False
|
||||
x = 0
|
||||
for index, (output, mode) in enumerate(row):
|
||||
is_primary = index == 0
|
||||
args += ["--output", output.name, "--mode", mode.size, "--rate", mode.rate]
|
||||
args += ["--pos", f"{x}x0"] + (["--primary"] if is_primary else [])
|
||||
wanted_geometry = f"{mode.size}+{x}+0"
|
||||
changed |= output.current != mode or output.geometry != wanted_geometry
|
||||
changed |= output.primary != is_primary
|
||||
x += mode.width
|
||||
for output in outputs:
|
||||
if not output.connected and output.geometry:
|
||||
args += ["--output", output.name, "--off"]
|
||||
changed = True
|
||||
return args if changed else []
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Print the plan for ``xrandr --query`` text read from stdin."""
|
||||
sys.stdout.write(" ".join(plan(parse_xrandr(sys.stdin.read()), "DP-0")) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
54
linux_configuration/features/setup_monitor_autoconfig.sh
Executable file
54
linux_configuration/features/setup_monitor_autoconfig.sh
Executable file
@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# setup_monitor_autoconfig.sh — every monitor at its best mode, automatically.
|
||||
#
|
||||
# Installs the monitor-autoconfig systemd user service. It applies the layout
|
||||
# at login and again whenever a monitor is plugged or unplugged:
|
||||
# - each monitor runs its native resolution at the highest refresh rate;
|
||||
# - DP-0 is primary and leftmost, the others sit to its right;
|
||||
# - an unplugged monitor is switched off so no gap is left.
|
||||
# Policy lives in monitor_layout.py; the watcher in monitor_autoconfig.py.
|
||||
#
|
||||
# Run as your normal user (never with sudo). Idempotent: rerun after edits.
|
||||
# Uninstall: systemctl --user disable --now monitor-autoconfig.service
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(dirname "$(readlink -f "$0")")"
|
||||
readonly HERE
|
||||
readonly UNIT=monitor-autoconfig.service
|
||||
readonly UNIT_DIR="$HOME/.config/systemd/user"
|
||||
|
||||
log() { printf '[monitor-autoconfig] %s\n' "$*"; }
|
||||
die() {
|
||||
printf '[monitor-autoconfig] ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
install_deps() {
|
||||
local pkg missing=()
|
||||
for pkg in python-xlib xorg-xrandr; do
|
||||
pacman -Q "$pkg" >/dev/null 2>&1 || missing+=("$pkg")
|
||||
done
|
||||
if ((${#missing[@]})); then
|
||||
log "installing ${missing[*]}"
|
||||
sudo pacman -S --needed --noconfirm "${missing[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
install_unit() {
|
||||
mkdir -p "$UNIT_DIR"
|
||||
sed "s|@FEATURES_DIR@|$HERE|" "$HERE/systemd/$UNIT" >"$UNIT_DIR/$UNIT"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable "$UNIT" >/dev/null
|
||||
systemctl --user restart "$UNIT"
|
||||
}
|
||||
|
||||
main() {
|
||||
((EUID != 0)) || die "run as your normal user, not root"
|
||||
install_deps
|
||||
install_unit
|
||||
systemctl --user is-active --quiet "$UNIT" || die "$UNIT failed to start"
|
||||
log "active; layout now: $(xrandr --listmonitors | tail -n +2 | awk '{print $4}' | paste -sd' ')"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Every monitor at its best resolution and refresh rate, on hotplug
|
||||
After=graphical-session.target
|
||||
# An X restart kills the watcher (exit 1) and Restart= brings it back; a
|
||||
# genuinely broken setup trips this limit and shows as a failed unit.
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=DISPLAY=:0
|
||||
ExecStart=/usr/bin/python3 @FEATURES_DIR@/monitor_autoconfig.py
|
||||
Restart=on-failure
|
||||
RestartSec=3s
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
@ -24,6 +24,7 @@ _REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SCRIPT_DIRS = (
|
||||
_REPO_ROOT / "meta" / "scripts", # validate_evidence, validate_contract
|
||||
_REPO_ROOT / "linux_configuration" / "utils", # fast_count
|
||||
_REPO_ROOT / "linux_configuration" / "features", # monitor_layout
|
||||
_REPO_ROOT / "linux_configuration" / "fixes", # g29_shifter_capture
|
||||
_REPO_ROOT
|
||||
/ "linux_configuration"
|
||||
|
||||
189
linux_configuration/tests/test_monitor_autoconfig.py
Normal file
189
linux_configuration/tests/test_monitor_autoconfig.py
Normal file
@ -0,0 +1,189 @@
|
||||
"""Tests for linux_configuration/features/monitor_autoconfig.py (I/O shell)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import monitor_autoconfig
|
||||
from monitor_autoconfig import apply_once, main, watch
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
OFF = """\
|
||||
DP-0 connected primary (normal left inverted right x axis y axis)
|
||||
3840x2160 60.00 + 144.00
|
||||
HDMI-0 connected 2560x1440+3840+0 (normal left inverted right x axis y axis)
|
||||
2560x1440 59.95 + 143.98*
|
||||
"""
|
||||
BEST = """\
|
||||
DP-0 connected primary 3840x2160+0+0 (normal left inverted right x axis y axis)
|
||||
3840x2160 60.00 + 144.00*
|
||||
HDMI-0 connected 2560x1440+3840+0 (normal left inverted right x axis y axis)
|
||||
2560x1440 59.95 + 143.98*
|
||||
"""
|
||||
WANT = [
|
||||
"xrandr", "--output", "DP-0", "--mode", "3840x2160", "--rate", "144.00",
|
||||
"--pos", "0x0", "--primary", "--output", "HDMI-0", "--mode", "2560x1440",
|
||||
"--rate", "143.98", "--pos", "3840x0",
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
NO_MODES_YET = """\
|
||||
DP-0 connected primary (normal left inverted right x axis y axis)
|
||||
HDMI-0 connected 2560x1440+3840+0 (normal left inverted right x axis y axis)
|
||||
2560x1440 59.95 + 143.98*
|
||||
"""
|
||||
|
||||
|
||||
class FakeXrandr:
|
||||
"""Serves queued ``xrandr --query`` texts and records apply calls."""
|
||||
|
||||
def __init__(self, *queries: str, fail_apply: bool = False) -> None:
|
||||
self.queries = list(queries)
|
||||
self.applied: list[list[str]] = []
|
||||
self.fail_apply = fail_apply
|
||||
|
||||
def __call__(self, argv: list[str]) -> str:
|
||||
if argv == ["xrandr", "--query"]:
|
||||
return self.queries.pop(0)
|
||||
self.applied.append(argv)
|
||||
if self.fail_apply:
|
||||
raise subprocess.CalledProcessError(1, argv, stderr="crtc failed")
|
||||
return ""
|
||||
|
||||
|
||||
class FakeDisplay:
|
||||
"""Xlib display double: ``events`` batches, then the X server goes away."""
|
||||
|
||||
def __init__(self, batches: list[int]) -> None:
|
||||
self.batches = batches
|
||||
self.pending = 0
|
||||
self.selected: list[int] = []
|
||||
self.handshakes = 0
|
||||
root = type("Root", (), {"xrandr_select_input": self.selected.append})()
|
||||
self.screen: Callable[[], object] = type("S", (), {"root": root})
|
||||
|
||||
def next_event(self) -> None:
|
||||
if self.pending:
|
||||
self.pending -= 1
|
||||
return
|
||||
if not self.batches:
|
||||
raise ConnectionError
|
||||
self.pending = self.batches.pop(0) - 1
|
||||
|
||||
def pending_events(self) -> int:
|
||||
return self.pending
|
||||
|
||||
def xrandr_query_version(self) -> None:
|
||||
"""X only sends RandR 1.2+ output events after this handshake."""
|
||||
self.handshakes += 1
|
||||
|
||||
|
||||
def test_apply_once_runs_planned_xrandr() -> None:
|
||||
"""An off monitor gets switched on with one xrandr call."""
|
||||
fake = FakeXrandr(OFF)
|
||||
assert apply_once("DP-0", run=fake) is True
|
||||
assert fake.applied == [WANT]
|
||||
|
||||
|
||||
def test_apply_once_noop_when_best() -> None:
|
||||
"""Already-best layout means no xrandr call (no flicker on stray events)."""
|
||||
fake = FakeXrandr(BEST)
|
||||
assert apply_once("DP-0", run=fake) is False
|
||||
assert fake.applied == []
|
||||
|
||||
|
||||
def test_watch_applies_at_start_and_once_per_burst() -> None:
|
||||
"""A burst of 3 hotplug events is drained and handled by one apply."""
|
||||
fake = FakeXrandr(OFF, BEST)
|
||||
display = FakeDisplay([3])
|
||||
with pytest.raises(ConnectionError):
|
||||
watch("DP-0", display=display, run=fake, settle=lambda: None)
|
||||
assert display.handshakes == 1
|
||||
assert display.selected == [monitor_autoconfig.OUTPUT_CHANGE_MASK]
|
||||
assert fake.applied == [WANT]
|
||||
assert fake.queries == []
|
||||
|
||||
|
||||
def test_main_dry_run_prints_command(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""``--dry-run`` prints the xrandr command and changes nothing."""
|
||||
fake = FakeXrandr(OFF)
|
||||
assert main(["--dry-run"], run=fake) == 0
|
||||
assert capsys.readouterr().out.split() == WANT
|
||||
assert fake.applied == []
|
||||
|
||||
|
||||
def test_main_once_applies(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``--once`` applies the layout and exits without watching."""
|
||||
fake = FakeXrandr(OFF)
|
||||
monkeypatch.setattr(monitor_autoconfig, "watch", pytest.fail)
|
||||
assert main(["--once", "--primary", "DP-0"], run=fake) == 0
|
||||
assert fake.applied == [WANT]
|
||||
|
||||
|
||||
def test_main_default_watches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""With no mode flag, ``main`` hands off to ``watch``."""
|
||||
seen: list[str] = []
|
||||
monkeypatch.setattr(monitor_autoconfig, "watch", seen.append)
|
||||
assert main([]) == 0
|
||||
assert seen == ["DP-0"]
|
||||
|
||||
|
||||
def test_apply_once_waits_for_late_edid_modes() -> None:
|
||||
"""A just-plugged monitor with no modes yet is re-queried, then applied."""
|
||||
fake = FakeXrandr(NO_MODES_YET, OFF)
|
||||
settles: list[None] = []
|
||||
assert apply_once("DP-0", run=fake, settle=lambda: settles.append(None))
|
||||
assert settles == [None]
|
||||
assert fake.applied == [WANT]
|
||||
|
||||
|
||||
def test_apply_once_gives_up_waiting_after_tries() -> None:
|
||||
"""Modes that never appear stop the retry; the rest is still laid out."""
|
||||
fake = FakeXrandr(NO_MODES_YET, NO_MODES_YET)
|
||||
assert apply_once("DP-0", run=fake, settle=lambda: None, tries=2) is True
|
||||
assert fake.applied[0][:3] == ["xrandr", "--output", "HDMI-0"]
|
||||
|
||||
|
||||
def test_watch_survives_failed_xrandr(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A failing apply is logged and the watcher keeps handling events."""
|
||||
fake = FakeXrandr(OFF, OFF, fail_apply=True)
|
||||
with pytest.raises(ConnectionError):
|
||||
watch("DP-0", display=FakeDisplay([1]), run=fake, settle=lambda: None)
|
||||
assert fake.applied == [WANT, WANT]
|
||||
assert "crtc failed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_run_returns_stdout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``_run`` is a checked subprocess call returning stdout."""
|
||||
calls: list[tuple[list[str], dict[str, object]]] = []
|
||||
|
||||
def fake_run(argv: list[str], **kwargs: object) -> SimpleNamespace:
|
||||
calls.append((argv, kwargs))
|
||||
return SimpleNamespace(stdout="out")
|
||||
|
||||
monkeypatch.setattr(monitor_autoconfig.subprocess, "run", fake_run)
|
||||
assert monitor_autoconfig._run(["xrandr"]) == "out"
|
||||
assert calls[0][1]["check"] is True
|
||||
|
||||
|
||||
def test_open_display_uses_xlib(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The real display comes from ``Xlib.display.Display()``."""
|
||||
sentinel = object()
|
||||
fake_module = SimpleNamespace(Display=lambda: sentinel)
|
||||
monkeypatch.setattr(
|
||||
monitor_autoconfig.importlib, "import_module", lambda name: fake_module
|
||||
)
|
||||
assert monitor_autoconfig._open_display() is sentinel
|
||||
|
||||
|
||||
def test_settle_sleeps_settle_seconds(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The real settle waits ``SETTLE_SECONDS`` for EDID to arrive."""
|
||||
slept: list[float] = []
|
||||
monkeypatch.setattr(monitor_autoconfig.time, "sleep", slept.append)
|
||||
monitor_autoconfig._settle()
|
||||
assert slept == [monitor_autoconfig.SETTLE_SECONDS]
|
||||
144
linux_configuration/tests/test_monitor_layout.py
Normal file
144
linux_configuration/tests/test_monitor_layout.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""Tests for linux_configuration/features/monitor_layout.py (xrandr planner)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import monitor_layout
|
||||
from monitor_layout import Mode, parse_xrandr, pick_mode, plan
|
||||
import pytest
|
||||
|
||||
AXES = "(normal left inverted right x axis y axis)"
|
||||
|
||||
# Real `xrandr --query` from 2026-11-11: DP-0 connected but switched off, the
|
||||
# state that made kuhy's manual `xrandr --size` attempt fail.
|
||||
DP0_OFF = f"""\
|
||||
Screen 0: minimum 8 x 8, current 6400 x 1440, maximum 32767 x 32767
|
||||
HDMI-0 connected 2560x1440+3840+0 {AXES} 698mm x 393mm
|
||||
2560x1440 59.95 + 143.98* 120.00
|
||||
3840x2160 59.94 50.00
|
||||
1920x1080 120.00 119.88 100.00 60.00 59.94 50.00
|
||||
DP-0 connected primary {AXES}
|
||||
3840x2160 60.00 + 144.00 119.88 100.00 59.94 50.00
|
||||
2560x1440 144.00 120.00
|
||||
1920x1080 119.88 60.00 59.94 50.00
|
||||
DP-1 disconnected {AXES}
|
||||
DP-3 disconnected {AXES}
|
||||
"""
|
||||
|
||||
# The same machine after the fix: both outputs at native size, max refresh.
|
||||
BOTH_BEST = f"""\
|
||||
Screen 0: minimum 8 x 8, current 6400 x 2160, maximum 32767 x 32767
|
||||
HDMI-0 connected 2560x1440+3840+0 {AXES} 698mm x 393mm
|
||||
2560x1440 59.95 + 143.98* 120.00
|
||||
3840x2160 59.94 50.00
|
||||
DP-0 connected primary 3840x2160+0+0 {AXES} 620mm x 340mm
|
||||
3840x2160 60.00 + 144.00* 119.88
|
||||
DP-1 disconnected {AXES}
|
||||
"""
|
||||
|
||||
# DP-0 unplugged while still holding a CRTC; HDMI-0 sits at +3840.
|
||||
DP0_UNPLUGGED = f"""\
|
||||
Screen 0: minimum 8 x 8, current 6400 x 2160, maximum 32767 x 32767
|
||||
HDMI-0 connected 2560x1440+3840+0 {AXES} 698mm x 393mm
|
||||
2560x1440 59.95 + 143.98* 120.00
|
||||
DP-0 disconnected primary 3840x2160+0+0 {AXES} 0mm x 0mm
|
||||
"""
|
||||
|
||||
# A monitor whose EDID marks no preferred mode, with an interlaced mode too.
|
||||
NO_PREFERRED = f"""\
|
||||
Screen 0: minimum 8 x 8, current 3840 x 2160, maximum 32767 x 32767
|
||||
DP-0 connected primary 3840x2160+0+0 {AXES} 620mm x 340mm
|
||||
3840x2160 60.00*+
|
||||
DP-1 connected {AXES}
|
||||
1920x1080 60.00 75.00
|
||||
1920x1080i 120.00
|
||||
1280x1024 85.00
|
||||
"""
|
||||
|
||||
HDMI_BEST = ["--output", "HDMI-0", "--mode", "2560x1440", "--rate", "143.98"]
|
||||
|
||||
|
||||
class TestParse:
|
||||
"""``parse_xrandr`` reads connection, primary, geometry and modes."""
|
||||
|
||||
def test_connection_primary_and_geometry(self) -> None:
|
||||
"""Off outputs have no geometry; on outputs report size and position."""
|
||||
outputs = {o.name: o for o in parse_xrandr(DP0_OFF)}
|
||||
assert outputs["DP-0"].connected
|
||||
assert outputs["DP-0"].primary
|
||||
assert outputs["DP-0"].geometry is None
|
||||
assert outputs["HDMI-0"].geometry == "2560x1440+3840+0"
|
||||
assert not outputs["DP-1"].connected
|
||||
|
||||
def test_flags_separated_by_space(self) -> None:
|
||||
"""``59.95 +`` marks 59.95 preferred; ``143.98*`` marks it current."""
|
||||
hdmi = next(o for o in parse_xrandr(DP0_OFF) if o.name == "HDMI-0")
|
||||
assert hdmi.preferred_size == (2560, 1440)
|
||||
assert hdmi.current == Mode(2560, 1440, "143.98")
|
||||
|
||||
def test_combined_flags(self) -> None:
|
||||
"""``60.00*+`` is both current and preferred."""
|
||||
dp0 = parse_xrandr(NO_PREFERRED)[0]
|
||||
assert dp0.current == Mode(3840, 2160, "60.00")
|
||||
assert dp0.preferred_size == (3840, 2160)
|
||||
|
||||
|
||||
class TestPickMode:
|
||||
"""Best mode = native (preferred) size at its highest refresh rate."""
|
||||
|
||||
def test_native_size_beats_bigger_listed_size(self) -> None:
|
||||
"""HDMI-0 lists 3840x2160@60 but its native 1440p@144 wins."""
|
||||
hdmi = next(o for o in parse_xrandr(DP0_OFF) if o.name == "HDMI-0")
|
||||
assert pick_mode(hdmi) == Mode(2560, 1440, "143.98")
|
||||
|
||||
def test_highest_rate_at_preferred_size(self) -> None:
|
||||
"""DP-0's preferred is 4K@60; 144 Hz at the same size is picked."""
|
||||
dp0 = next(o for o in parse_xrandr(DP0_OFF) if o.name == "DP-0")
|
||||
assert pick_mode(dp0) == Mode(3840, 2160, "144.00")
|
||||
|
||||
def test_no_preferred_falls_back_to_largest_progressive(self) -> None:
|
||||
"""Without ``+``: largest area, then fastest; interlaced skipped."""
|
||||
dp1 = parse_xrandr(NO_PREFERRED)[1]
|
||||
assert pick_mode(dp1) == Mode(1920, 1080, "75.00")
|
||||
|
||||
|
||||
class TestPlan:
|
||||
"""``plan`` returns xrandr args, or ``[]`` when nothing needs changing."""
|
||||
|
||||
def test_turns_on_off_output_left_of_others(self) -> None:
|
||||
"""The failing 2026-11-11 state: DP-0 on at 0x0, HDMI-0 to its right."""
|
||||
assert plan(parse_xrandr(DP0_OFF), "DP-0") == [
|
||||
"--output", "DP-0", "--mode", "3840x2160", "--rate", "144.00",
|
||||
"--pos", "0x0", "--primary",
|
||||
*HDMI_BEST, "--pos", "3840x0",
|
||||
] # fmt: skip
|
||||
|
||||
def test_already_best_is_noop(self) -> None:
|
||||
"""No xrandr call when every output already runs its best mode."""
|
||||
assert plan(parse_xrandr(BOTH_BEST), "DP-0") == []
|
||||
|
||||
def test_unplugged_primary_is_turned_off_and_gap_closed(self) -> None:
|
||||
"""HDMI-0 slides to 0x0 and becomes primary; DP-0 releases its CRTC."""
|
||||
assert plan(parse_xrandr(DP0_UNPLUGGED), "DP-0") == [
|
||||
*HDMI_BEST, "--pos", "0x0", "--primary",
|
||||
"--output", "DP-0", "--off",
|
||||
] # fmt: skip
|
||||
|
||||
def test_unknown_primary_uses_first_connected(self) -> None:
|
||||
"""A missing preferred primary falls back to xrandr order."""
|
||||
args = plan(parse_xrandr(DP0_OFF), "DP-9")
|
||||
assert args[:9] == [*HDMI_BEST, "--pos", "0x0", "--primary"]
|
||||
|
||||
def test_nothing_connected_is_noop(self) -> None:
|
||||
"""No connected output means no xrandr call at all."""
|
||||
assert plan(parse_xrandr("Screen 0: minimum 8 x 8\n"), "DP-0") == []
|
||||
|
||||
|
||||
def test_main_prints_plan_for_stdin(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""``xrandr | monitor_layout.py`` prints the plan without touching X."""
|
||||
monkeypatch.setattr(monitor_layout.sys, "stdin", io.StringIO(DP0_OFF))
|
||||
assert monitor_layout.main() == 0
|
||||
assert capsys.readouterr().out.split()[:3] == ["--output", "DP-0", "--mode"]
|
||||
@ -173,7 +173,7 @@ disallow_untyped_decorators = false
|
||||
# bare name (the same dirs linux_configuration/tests/conftest.py adds to
|
||||
# sys.path at runtime) resolve under static analysis instead of raising E0401.
|
||||
# Paths are relative to the repo root, which is pre-commit's working directory.
|
||||
init-hook = "import sys; sys.path[:0] = ['meta/scripts', 'python_pkg', 'linux_configuration/utils', 'linux_configuration/fixes', 'linux_configuration/misc/tools']"
|
||||
init-hook = "import sys; sys.path[:0] = ['meta/scripts', 'python_pkg', 'linux_configuration/utils', 'linux_configuration/features', 'linux_configuration/fixes', 'linux_configuration/misc/tools']"
|
||||
# Analyse import fallback blocks
|
||||
analyse-fallback-blocks = true
|
||||
# Pickle collected data for later comparisons
|
||||
|
||||
Loading…
Reference in New Issue
Block a user