working state

This commit is contained in:
Dawson Matthews
2026-03-08 18:13:21 -06:00
parent 41047adac3
commit 6257e63357
2 changed files with 151 additions and 179 deletions
+146 -175
View File
@@ -2,87 +2,50 @@
import argparse import argparse
import json import json
import logging
import os
import subprocess import subprocess
import sys import sys
from dataclasses import dataclass import os
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
# Configuration VERBOSE = True
LOG_FORMAT = "%(message)s"
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
logger = logging.getLogger(__name__)
# Constants for attribute mappings def log(*args):
BOOL_ENABLE_MAP = {"true": "enable", "false": "disable"} if VERBOSE:
BOOL_ALLOW_MAP = {"true": "allow", "false": "disallow"} print(*args)
RGB_RANGE_MAP = {
"0": "automatic", "1": "full", "2": "limited",
"automatic": "automatic", "full": "full", "limited": "limited"
}
ROTATION_MAP = {"1": "normal", "2": "left", "4": "inverted", "8": "right"}
VRR_POLICY_MAP = {
"0": "never", "1": "always", "2": "automatic",
"never": "never", "always": "always", "automatic": "automatic"
}
@dataclass
class OutputConfig:
"""Represents the configuration for a single display output."""
data: Dict[str, Any]
@property
def name(self) -> str:
return self.data.get("name", "Unknown")
@property
def is_enabled(self) -> bool:
return bool(self.data.get("enabled", False))
def get(self, key: str, default: Any = None) -> Any:
return self.data.get(key, default)
class KDEProfileManager:
"""Manages KDE display profiles using kscreen-doctor."""
def __init__(self, dry_run: bool = False):
self.dry_run = dry_run
def _run_command(self, cmd: Union[str, List[str]]) -> None:
"""Executes a shell command."""
cmd_str = cmd if isinstance(cmd, str) else " ".join(cmd)
logger.info(f"[CMD] {cmd_str}")
if self.dry_run:
return
def run_command(cmd):
log(f"[CMD] {cmd}")
try: try:
subprocess.run(cmd, shell=isinstance(cmd, str), check=True, capture_output=False) subprocess.run(cmd, shell=True, check=True)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"Error executing command: {e}") log(f"Error executing command: {e}")
def save_profile(self, profile_path: Path) -> None: def get_mode_string(output, mode_id):
"""Saves the current display configuration to a JSON file.""" for mode in output.get('modes', []):
logger.info(f"Saving current display profile to {profile_path}...") if mode['id'] == mode_id:
width = mode['size']['width']
height = mode['size']['height']
refresh_rate = round(mode['refreshRate'])
return f"{width}x{height}@{refresh_rate}"
return None
def save_profile(profile_path):
log(f"Saving current display profile to {profile_path}...")
try: try:
profile_path.parent.mkdir(parents=True, exist_ok=True) with open(profile_path, 'w') as f:
with open(profile_path, "w") as f: subprocess.run(['kscreen-doctor', '--json'], stdout=f, check=True)
subprocess.run(["kscreen-doctor", "--json"], stdout=f, check=True) log("Profile saved successfully.")
logger.info("Profile saved successfully.")
except Exception as e: except Exception as e:
logger.error(f"Failed to save profile: {e}") print(f"Error saving profile: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
def _apply_attribute(self, output_name: str, attribute: str, value: Any, value_map: Optional[Dict[str, str]] = None) -> None: def apply_attribute(output_name, attribute, value, value_map=None):
"""Applies a single attribute to an output."""
if value is None: if value is None:
logger.debug(f"Output {output_name} has no attribute {attribute}, skipping...") log(f"Output {output_name} has no attribute {attribute}, skipping...")
return return
# If it's a list or dict, something might be wrong or it's a complex structure we don't handle this way
if isinstance(value, (list, dict)): if isinstance(value, (list, dict)):
logger.warning(f"Output {output_name} attribute {attribute} has complex value {value}, skipping...") log(f"Output {output_name} attribute {attribute} has complex value {value}, skipping...")
return return
str_value = str(value).lower() str_value = str(value).lower()
@@ -90,142 +53,150 @@ class KDEProfileManager:
if str_value in value_map: if str_value in value_map:
value = value_map[str_value] value = value_map[str_value]
else: else:
logger.warning(f"Value '{str_value}' not found in map for '{attribute}', using as is.") log(f"Warning: Value {str_value} not found in map for {attribute}, using as is.")
self._run_command(f"kscreen-doctor output.{output_name}.{attribute}.{value}") cmd = f"kscreen-doctor output.{output_name}.{attribute}.{value}"
run_command(cmd)
def _get_mode_string(self, output: OutputConfig, mode_id: str) -> Optional[str]: def load_profile(profile_path):
"""Finds the mode string (WxH@R) for a given mode ID.""" log(f"Loading display profile from {profile_path}...")
for mode in output.get("modes", []): if not os.path.exists(profile_path):
if str(mode.get("id")) == str(mode_id): print(f"Profile file not found: {profile_path}", file=sys.stderr)
size = mode.get("size", {})
width = size.get("width")
height = size.get("height")
refresh = round(mode.get("refreshRate", 0))
if width and height:
return f"{width}x{height}@{refresh}"
return None
def _configure_output(self, output: OutputConfig, all_outputs: List[OutputConfig]) -> None:
"""Configures all attributes for a single output."""
name = output.name
logger.info(f"Configuring output: {name}")
# Direct mappings
self._apply_attribute(name, "wcg", output.get("wcg"), BOOL_ENABLE_MAP)
self._apply_attribute(name, "sdr-brightness", output.get("sdr-brightness"))
self._apply_attribute(name, "vrrpolicy", output.get("vrrPolicy"), VRR_POLICY_MAP)
self._apply_attribute(name, "rgbrange", output.get("rgbRange"), RGB_RANGE_MAP)
self._apply_attribute(name, "overscan", output.get("overscan"))
self._apply_attribute(name, "hdr", output.get("hdr"), BOOL_ENABLE_MAP)
# Brightness (normalized 0.0-1.0 to 0-100)
brightness = output.get("brightness")
if brightness is not None:
self._apply_attribute(name, "brightness", int(float(brightness) * 100))
# Max BPC
max_bpc = output.get("maxBpc")
if max_bpc == 0:
max_bpc = "automatic"
self._apply_attribute(name, "maxbpc", max_bpc)
# DDC/CI
self._apply_attribute(name, "ddcCi", output.get("ddcCiAllowed"), BOOL_ALLOW_MAP)
# Mirroring
mirror_source_id = output.get("replicationSource", 0)
if mirror_source_id != 0:
source_name = next((o.name for o in all_outputs if o.get("id") == mirror_source_id), "none")
self._apply_attribute(name, "mirror", source_name)
else:
self._apply_attribute(name, "mirror", "none")
# ICC Profile
self._apply_attribute(name, "iccProfilePath", output.get("iccProfilePath"))
# Mode
mode_id = output.get("currentModeId")
if mode_id:
mode_str = self._get_mode_string(output, mode_id)
if mode_str:
self._apply_attribute(name, "mode", mode_str)
# Position
pos = output.get("pos")
if pos:
self._apply_attribute(name, "position", f"{pos.get('x', 0)},{pos.get('y', 0)}")
# Scale and Rotation
self._apply_attribute(name, "scale", output.get("scale"))
self._apply_attribute(name, "rotation", output.get("rotation"), ROTATION_MAP)
# Priority and Primary status
priority = output.get("priority")
if priority is not None:
if priority == 1:
self._run_command(f"kscreen-doctor output.{name}.primary")
self._run_command(f"kscreen-doctor output.{name}.priority.{priority}")
def load_profile(self, profile_path: Path) -> None:
"""Loads and applies a display configuration from a JSON file."""
logger.info(f"Loading display profile from {profile_path}...")
if not profile_path.exists():
logger.error(f"Profile file not found: {profile_path}")
sys.exit(1) sys.exit(1)
try: try:
with open(profile_path, "r") as f: with open(profile_path, 'r') as f:
data = json.load(f) profile = json.load(f)
except Exception as e: except Exception as e:
logger.error(f"Error reading profile JSON: {e}") print(f"Error reading profile JSON: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
outputs = [OutputConfig(out) for out in data.get("outputs", [])] outputs = profile.get('outputs', [])
# 1. Handle Enable/Disable (Enable first to ensure we always have an active display) # Maps from JSON values to kscreen-doctor options
logger.info("Enabling requested outputs...") bool_enable_map = {"true": "enable", "false": "disable"}
bool_allow_map = {"true": "allow", "false": "disallow"}
rgb_range_map = {
"0": "automatic", "1": "full", "2": "limited",
"automatic": "automatic", "full": "full", "limited": "limited"
}
rotation_map = {"1": "normal", "2": "left", "4": "inverted", "8": "right"}
vrr_policy_map = {
"0": "never", "1": "always", "2": "automatic",
"never": "never", "always": "always", "automatic": "automatic"
}
# 1. Handle Enable/Disable (Enable first to avoid no-output state)
log("Enabling enabled outputs...")
for out in outputs: for out in outputs:
if out.is_enabled: if out.get('enabled'):
self._run_command(f"kscreen-doctor output.{out.name}.enable") run_command(f"kscreen-doctor output.{out['name']}.enable")
logger.info("Disabling requested outputs...") log("Disabling disabled outputs...")
for out in outputs: for out in outputs:
if not out.is_enabled: if not out.get('enabled'):
self._run_command(f"kscreen-doctor output.{out.name}.disable") run_command(f"kscreen-doctor output.{out['name']}.disable")
# 2. Configure each output # 2. Apply other attributes
for out in outputs: for out in outputs:
if out.is_enabled: name = out['name']
self._configure_output(out, outputs) log(f"Configuring output: {name}")
logger.info("Display configuration restored.") # WCG
apply_attribute(name, "wcg", out.get('wcg'), bool_enable_map)
# SDR Brightness
apply_attribute(name, "sdr-brightness", out.get('sdr-brightness'))
# VRR Policy
apply_attribute(name, "vrrpolicy", out.get('vrrPolicy'), vrr_policy_map)
# RGB Range
apply_attribute(name, "rgbrange", out.get('rgbRange'), rgb_range_map)
# Overscan
apply_attribute(name, "overscan", out.get('overscan'))
# HDR
apply_attribute(name, "hdr", out.get('hdr'), bool_enable_map)
# Brightness (0.0-1.0 to 0-100)
brightness = out.get('brightness')
if brightness is not None:
apply_attribute(name, "brightness", int(float(brightness) * 100))
# Max BPC
max_bpc = out.get('maxBpc')
if max_bpc == 0:
max_bpc = "automatic"
apply_attribute(name, "maxbpc", max_bpc)
# DDC/CI
apply_attribute(name, "ddcCi", out.get('ddcCiAllowed'), bool_allow_map)
# Mirroring
replication_source_id = out.get('replicationSource', 0)
if replication_source_id != 0:
# Find the name of the replication source
source_name = "none"
for other_out in outputs:
if other_out.get('id') == replication_source_id:
source_name = other_out['name']
break
apply_attribute(name, "mirror", source_name)
else:
apply_attribute(name, "mirror", "none")
# ICC Profile
icc_path = out.get('iccProfilePath')
if icc_path:
apply_attribute(name, "iccProfilePath", icc_path)
# Mode
mode_id = out.get('currentModeId')
if mode_id:
mode_str = get_mode_string(out, mode_id)
if mode_str:
apply_attribute(name, "mode", mode_str)
# Position
pos = out.get('pos')
if pos:
apply_attribute(name, "position", f"{pos['x']},{pos['y']}")
# Scale
apply_attribute(name, "scale", out.get('scale'))
# Rotation
apply_attribute(name, "rotation", out.get('rotation'), rotation_map)
# Priority and Primary
priority = out.get('priority')
if priority is not None:
if priority == 1:
run_command(f"kscreen-doctor output.{name}.primary")
run_command(f"kscreen-doctor output.{name}.priority.{priority}")
log("Display configuration restored.")
def main(): def main():
parser = argparse.ArgumentParser(description="KDE Display Profile Manager") parser = argparse.ArgumentParser(description="KDE Display Profile Manager")
parser.add_argument("--dry-run", action="store_true", help="Print commands without executing them")
parser.add_argument("--verbose", action="store_true", help="Enable verbose logging")
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
save_parser = subparsers.add_parser("save", help="Save current configuration") # Save command
save_parser.add_argument("profile", type=Path, help="Path to save the profile") save_parser = subparsers.add_parser("save", help="Save current display configuration to a profile")
save_parser.add_argument("profile", help="Path to the profile file (e.g., profiles/myprofile.json)")
load_parser = subparsers.add_parser("load", help="Load a configuration") # Load command
load_parser.add_argument("profile", type=Path, help="Path to the profile to load") load_parser = subparsers.add_parser("load", help="Load a display configuration from a profile")
load_parser.add_argument("profile", help="Path to the profile file")
args = parser.parse_args() args = parser.parse_args()
if args.verbose:
logger.setLevel(logging.DEBUG)
manager = KDEProfileManager(dry_run=args.dry_run)
if args.command == "save": if args.command == "save":
manager.save_profile(args.profile) save_profile(args.profile)
elif args.command == "load": elif args.command == "load":
manager.load_profile(args.profile) load_profile(args.profile)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+1
View File
@@ -2,6 +2,7 @@
VERBOSE=1 VERBOSE=1
function log { function log {
if [ $VERBOSE -eq 1 ]; then if [ $VERBOSE -eq 1 ]; then
echo "$@" echo "$@"