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
+138 -167
View File
@@ -2,230 +2,201 @@
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 def run_command(cmd):
class OutputConfig: log(f"[CMD] {cmd}")
"""Represents the configuration for a single display output.""" try:
data: Dict[str, Any] subprocess.run(cmd, shell=True, check=True)
except subprocess.CalledProcessError as e:
log(f"Error executing command: {e}")
@property def get_mode_string(output, mode_id):
def name(self) -> str: for mode in output.get('modes', []):
return self.data.get("name", "Unknown") 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
@property def save_profile(profile_path):
def is_enabled(self) -> bool: log(f"Saving current display profile to {profile_path}...")
return bool(self.data.get("enabled", False)) try:
with open(profile_path, 'w') as f:
subprocess.run(['kscreen-doctor', '--json'], stdout=f, check=True)
log("Profile saved successfully.")
except Exception as e:
print(f"Error saving profile: {e}", file=sys.stderr)
sys.exit(1)
def get(self, key: str, default: Any = None) -> Any: def apply_attribute(output_name, attribute, value, value_map=None):
return self.data.get(key, default) if value is None:
log(f"Output {output_name} has no attribute {attribute}, skipping...")
return
class KDEProfileManager: # If it's a list or dict, something might be wrong or it's a complex structure we don't handle this way
"""Manages KDE display profiles using kscreen-doctor.""" if isinstance(value, (list, dict)):
log(f"Output {output_name} attribute {attribute} has complex value {value}, skipping...")
return
def __init__(self, dry_run: bool = False): str_value = str(value).lower()
self.dry_run = dry_run if value_map:
if str_value in value_map:
value = value_map[str_value]
else:
log(f"Warning: Value {str_value} not found in map for {attribute}, using as is.")
def _run_command(self, cmd: Union[str, List[str]]) -> None: cmd = f"kscreen-doctor output.{output_name}.{attribute}.{value}"
"""Executes a shell command.""" run_command(cmd)
cmd_str = cmd if isinstance(cmd, str) else " ".join(cmd)
logger.info(f"[CMD] {cmd_str}")
if self.dry_run: def load_profile(profile_path):
return log(f"Loading display profile from {profile_path}...")
if not os.path.exists(profile_path):
print(f"Profile file not found: {profile_path}", file=sys.stderr)
sys.exit(1)
try: try:
subprocess.run(cmd, shell=isinstance(cmd, str), check=True, capture_output=False) with open(profile_path, 'r') as f:
except subprocess.CalledProcessError as e: profile = json.load(f)
logger.error(f"Error executing command: {e}") except Exception as e:
print(f"Error reading profile JSON: {e}", file=sys.stderr)
sys.exit(1)
def save_profile(self, profile_path: Path) -> None: outputs = profile.get('outputs', [])
"""Saves the current display configuration to a JSON file."""
logger.info(f"Saving current display profile to {profile_path}...")
try:
profile_path.parent.mkdir(parents=True, exist_ok=True)
with open(profile_path, "w") as f:
subprocess.run(["kscreen-doctor", "--json"], stdout=f, check=True)
logger.info("Profile saved successfully.")
except Exception as e:
logger.error(f"Failed to save profile: {e}")
sys.exit(1)
def _apply_attribute(self, output_name: str, attribute: str, value: Any, value_map: Optional[Dict[str, str]] = None) -> None: # Maps from JSON values to kscreen-doctor options
"""Applies a single attribute to an output.""" bool_enable_map = {"true": "enable", "false": "disable"}
if value is None: bool_allow_map = {"true": "allow", "false": "disallow"}
logger.debug(f"Output {output_name} has no attribute {attribute}, skipping...") rgb_range_map = {
return "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"
}
if isinstance(value, (list, dict)): # 1. Handle Enable/Disable (Enable first to avoid no-output state)
logger.warning(f"Output {output_name} attribute {attribute} has complex value {value}, skipping...") log("Enabling enabled outputs...")
return for out in outputs:
if out.get('enabled'):
run_command(f"kscreen-doctor output.{out['name']}.enable")
str_value = str(value).lower() log("Disabling disabled outputs...")
if value_map: for out in outputs:
if str_value in value_map: if not out.get('enabled'):
value = value_map[str_value] run_command(f"kscreen-doctor output.{out['name']}.disable")
else:
logger.warning(f"Value '{str_value}' not found in map for '{attribute}', using as is.")
self._run_command(f"kscreen-doctor output.{output_name}.{attribute}.{value}") # 2. Apply other attributes
for out in outputs:
name = out['name']
log(f"Configuring output: {name}")
def _get_mode_string(self, output: OutputConfig, mode_id: str) -> Optional[str]: # WCG
"""Finds the mode string (WxH@R) for a given mode ID.""" apply_attribute(name, "wcg", out.get('wcg'), bool_enable_map)
for mode in output.get("modes", []):
if str(mode.get("id")) == str(mode_id):
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: # SDR Brightness
"""Configures all attributes for a single output.""" apply_attribute(name, "sdr-brightness", out.get('sdr-brightness'))
name = output.name
logger.info(f"Configuring output: {name}")
# Direct mappings # VRR Policy
self._apply_attribute(name, "wcg", output.get("wcg"), BOOL_ENABLE_MAP) apply_attribute(name, "vrrpolicy", out.get('vrrPolicy'), vrr_policy_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) # RGB Range
brightness = output.get("brightness") 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: if brightness is not None:
self._apply_attribute(name, "brightness", int(float(brightness) * 100)) apply_attribute(name, "brightness", int(float(brightness) * 100))
# Max BPC # Max BPC
max_bpc = output.get("maxBpc") max_bpc = out.get('maxBpc')
if max_bpc == 0: if max_bpc == 0:
max_bpc = "automatic" max_bpc = "automatic"
self._apply_attribute(name, "maxbpc", max_bpc) apply_attribute(name, "maxbpc", max_bpc)
# DDC/CI # DDC/CI
self._apply_attribute(name, "ddcCi", output.get("ddcCiAllowed"), BOOL_ALLOW_MAP) apply_attribute(name, "ddcCi", out.get('ddcCiAllowed'), bool_allow_map)
# Mirroring # Mirroring
mirror_source_id = output.get("replicationSource", 0) replication_source_id = out.get('replicationSource', 0)
if mirror_source_id != 0: if replication_source_id != 0:
source_name = next((o.name for o in all_outputs if o.get("id") == mirror_source_id), "none") # Find the name of the replication source
self._apply_attribute(name, "mirror", source_name) 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: else:
self._apply_attribute(name, "mirror", "none") apply_attribute(name, "mirror", "none")
# ICC Profile # ICC Profile
self._apply_attribute(name, "iccProfilePath", output.get("iccProfilePath")) icc_path = out.get('iccProfilePath')
if icc_path:
apply_attribute(name, "iccProfilePath", icc_path)
# Mode # Mode
mode_id = output.get("currentModeId") mode_id = out.get('currentModeId')
if mode_id: if mode_id:
mode_str = self._get_mode_string(output, mode_id) mode_str = get_mode_string(out, mode_id)
if mode_str: if mode_str:
self._apply_attribute(name, "mode", mode_str) apply_attribute(name, "mode", mode_str)
# Position # Position
pos = output.get("pos") pos = out.get('pos')
if pos: if pos:
self._apply_attribute(name, "position", f"{pos.get('x', 0)},{pos.get('y', 0)}") apply_attribute(name, "position", f"{pos['x']},{pos['y']}")
# Scale and Rotation # Scale
self._apply_attribute(name, "scale", output.get("scale")) apply_attribute(name, "scale", out.get('scale'))
self._apply_attribute(name, "rotation", output.get("rotation"), ROTATION_MAP)
# Priority and Primary status # Rotation
priority = output.get("priority") apply_attribute(name, "rotation", out.get('rotation'), rotation_map)
# Priority and Primary
priority = out.get('priority')
if priority is not None: if priority is not None:
if priority == 1: if priority == 1:
self._run_command(f"kscreen-doctor output.{name}.primary") run_command(f"kscreen-doctor output.{name}.primary")
self._run_command(f"kscreen-doctor output.{name}.priority.{priority}") run_command(f"kscreen-doctor output.{name}.priority.{priority}")
def load_profile(self, profile_path: Path) -> None: log("Display configuration restored.")
"""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)
try:
with open(profile_path, "r") as f:
data = json.load(f)
except Exception as e:
logger.error(f"Error reading profile JSON: {e}")
sys.exit(1)
outputs = [OutputConfig(out) for out in data.get("outputs", [])]
# 1. Handle Enable/Disable (Enable first to ensure we always have an active display)
logger.info("Enabling requested outputs...")
for out in outputs:
if out.is_enabled:
self._run_command(f"kscreen-doctor output.{out.name}.enable")
logger.info("Disabling requested outputs...")
for out in outputs:
if not out.is_enabled:
self._run_command(f"kscreen-doctor output.{out.name}.disable")
# 2. Configure each output
for out in outputs:
if out.is_enabled:
self._configure_output(out, outputs)
logger.info("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 "$@"