Files
fluffy-pancake/scripts/kanshi-autoconfig
T

238 lines
8.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""kanshi-autoconfig - generiert kanshi-Profile aus live erkannten Outputs.
Liest die angeschlossenen Monitore via `wlr-randr --json` aus (EDID-Namen,
bevorzugte Modi, aktuelle Skalierung) und schreibt daraus eine
funktionsfaehige ~/.config/kanshi/config mit den Profilen
docked / undocked / fallback.
Verhalten:
--print Vorschlag auf stdout, schreibt nichts.
--apply Schreibt (mit Backup *.bak), fragt nicht.
--apply-if-managed Schreibt nur, wenn die Config fehlt oder den Marker
'# kanshi-autoconfig: managed' enthaelt. Bei
unveraendertem Output-Set bleibt eine verwaltete Config
unangetastet (Modus-Tweaks inklusive). Wird vom
labwc-Autostart bei jedem Login aufgerufen.
Moduswahl: Auf echter Hardware der EDID-preferred-Modus (meist nativ =
richtig). In VMs meldet die Firmware oft einen Kleinstmodus als preferred
(QEMU: 1280x800) - dort wird 1920x1080@60 genommen, wenn angeboten, sonst
preferred. Einmal geschrieben bleibt die Config bei gleichem Output-Set
erhalten (Modus-Tweaks inklusive); neu generiert wird nur bei geaendertem
Output-Set (Docking) oder per --apply.
Faellt die Erkennung fehl (kein Wayland, kein wlr-randr), wird eine
reine Fallback-Config geschrieben, damit nie ein schwarzer Bildschirm
ohne Layout dasteht.
"""
import json
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
MARKER = "# kanshi-autoconfig: managed"
CONFIG_PATH = Path.home() / ".config" / "kanshi" / "config"
def detect_vm():
"""True, wenn wir virtualisiert laufen (QEMU/Boxes-Test)."""
try:
proc = subprocess.run(
["systemd-detect-virt", "--vm"], capture_output=True, timeout=10
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
return proc.returncode == 0
def norm_rate(value):
if isinstance(value, (int, float)) and value > 1000:
return value / 1000 # mHz -> Hz
return value
def choose_mode(modes, current, in_vm):
"""Modus waehlen: VM-Heuristik, sonst preferred, sonst current/erster."""
if in_vm:
for m in modes:
rate = norm_rate(m.get("refresh") or m.get("rate")) or 0
if m.get("width") == 1920 and m.get("height") == 1080 and round(rate) == 60:
return m
for m in modes:
if m.get("preferred"):
return m
return current or (modes[0] if modes else None)
def detect_outputs():
"""Liste von Dicts: name, internal, width, height, rate, scale."""
in_vm = detect_vm()
try:
proc = subprocess.run(
["wlr-randr", "--json"], capture_output=True, text=True, timeout=10
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
if proc.returncode != 0:
return []
try:
data = json.loads(proc.stdout)
except json.JSONDecodeError:
return []
outputs = []
for out in data:
name = out.get("name", "?")
# Nur verbundene Outputs betrachten (kanshi matcht nur angesteckte)
if out.get("connected") is False:
continue
modes = out.get("modes", []) or []
current = out.get("current_mode") or out.get("mode")
chosen = choose_mode(modes, current, in_vm)
if not chosen:
continue
outputs.append(
{
"name": name,
"internal": name.startswith(("eDP-", "LVDS-", "DSI-")),
"width": chosen.get("width"),
"height": chosen.get("height"),
"rate": norm_rate(chosen.get("refresh") or chosen.get("rate")),
"scale": out.get("scale", 1) or 1,
}
)
return outputs
def mode_str(o):
s = f"{o['width']}x{o['height']}"
if o["rate"]:
s += f"@{o['rate']:.2f}" if isinstance(o["rate"], float) else f"@{o['rate']}"
return s
def render(outputs):
lines = [
MARKER,
f"# generiert am {datetime.now().isoformat(timespec='seconds')}",
"# Quelle: wlr-randr (live). Feintuning: wdisplays oder Modus direkt",
"# in dieser Datei anpassen - bei unveraendertem Output-Set bleibt",
"# die Datei (inkl. deiner Modus-Tweaks) bei jedem Login erhalten.",
"# Neu generiert wird nur bei geaendertem Output-Set (Docking) oder",
"# per --apply. Marker-Zeile loeschen = ganz hand-gepflegt.",
"",
]
externals = [o for o in outputs if not o["internal"]]
internals = [o for o in outputs if o["internal"]]
if externals:
lines.append("profile docked {")
for i in internals:
lines.append(f" output {i['name']} disable")
x = 0
for o in externals:
lines.append(
f" output {o['name']} mode {mode_str(o)} position {x},0 scale {o['scale']}"
)
x += o["width"] or 0
lines.append("}")
lines.append("")
if internals:
lines.append("profile undocked {")
for o in internals:
lines.append(
f" output {o['name']} mode {mode_str(o)} position 0,0 scale {o['scale']}"
)
lines.append("}")
lines.append("")
elif not externals:
# Gar nichts erkannt -> reiner Fallback, nie leer lassen
lines.append("# Keine Outputs erkannt (ausserhalb einer Wayland-Session")
lines.append("# aufgerufen?) - nur Fallback geschrieben.")
lines.append("")
lines.append("profile fallback {")
lines.append(" output * enable")
lines.append("}")
lines.append("")
return "\n".join(lines)
def is_managed(path: Path):
if not path.exists():
return True
try:
return MARKER in path.read_text()
except OSError:
return False
def apply(text):
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
if CONFIG_PATH.exists():
backup = CONFIG_PATH.with_suffix(".bak")
shutil.copy2(CONFIG_PATH, backup)
print(f"Backup: {backup}")
CONFIG_PATH.write_text(text)
print(f"Geschrieben: {CONFIG_PATH}")
def config_output_names(path: Path):
"""Output-Namen aus einer bestehenden kanshi-Config (ohne '*' Fallback)."""
names = set()
try:
for line in path.read_text().splitlines():
parts = line.strip().split()
if len(parts) >= 2 and parts[0] == "output" and parts[1] != "*":
names.add(parts[1].strip("'\""))
except OSError:
pass
return names
def main(argv):
outputs = detect_outputs()
if not outputs:
print("WARN: keine Outputs erkannt", file=sys.stderr)
text = render(outputs)
# Default ohne Schreib-Flag: nur anzeigen, nichts schreiben (sicher).
if "--print" in argv or ("--apply" not in argv and "--apply-if-managed" not in argv):
if not outputs:
print("# (Fallback-Anteil, da keine Outputs erkannt)", file=sys.stderr)
print(text)
if outputs:
print(
f"# Erkannt: {', '.join(o['name'] for o in outputs)}",
file=sys.stderr,
)
return 0
if "--apply-if-managed" in argv:
if not CONFIG_PATH.exists():
apply(text) # Erst-Login: anlegen
return 0
if not is_managed(CONFIG_PATH):
print(f"{CONFIG_PATH} ist hand-gepflegt - lasse sie unangetastet.")
return 0
if not outputs:
# Erkennung fehlgeschlagen -> niemals eine gute Config clobbern.
print("WARN: Erkennung fehlgeschlagen - lasse verwaltete Config unangetastet.",
file=sys.stderr)
return 0
if {o["name"] for o in outputs} == config_output_names(CONFIG_PATH):
# Gleiches Output-Set (z.B. jeden Boot dieselbe VM-Anzeige):
# Modus-Tweaks des Users bleiben erhalten, kein Rewrite.
print(f"Outputs unveraendert ({', '.join(sorted(config_output_names(CONFIG_PATH)))})"
" - behalte Config (inkl. Modus-Tweaks) bei.")
return 0
# Output-Set geaendert (angedockt/abgedockt): neu generieren.
apply(text)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))