11import os
2+ import re
3+ import subprocess
24from pathlib import Path
35
46from fabric .core .service import Property
1315
1416
1517def get_device (path : Path ):
18+ if not path .exists ():
19+ return ""
1620 for item in path .iterdir ():
1721 if item .is_dir ():
1822 return item .name
19-
2023 return ""
2124
2225
2326class BrightnessService (Service ):
24- """Service to manage screen brightness levels."""
27+ """Service to manage screen brightness levels.
28+
29+ Supports two backends:
30+ - brightnessctl — for laptops / internal screens (backlight devices in
31+ /sys/class/backlight). Changes are detected via a file monitor so the
32+ UI stays in sync automatically.
33+ - ddcutil — for external / desktop monitors that speak DDC/CI over I2C.
34+ The first detected I2C bus is used. The brightness value (VCP 0x10) is
35+ cached locally so reads are instant; writes are sent asynchronously so
36+ the UI never freezes while waiting for the (slow) monitor response.
37+ """
38+
39+ # ------------------------------------------------------------------
40+ # DDC helpers
41+ # ------------------------------------------------------------------
42+
43+ def _ddc_detect_bus (self ) -> str :
44+ """Return the first I2C bus number (e.g. '6') found by ddcutil, or ''."""
45+ try :
46+ out = subprocess .check_output (
47+ ["ddcutil" , "detect" , "--brief" ],
48+ text = True ,
49+ stderr = subprocess .DEVNULL ,
50+ timeout = 3 ,
51+ )
52+ except Exception :
53+ return ""
54+ m = re .search (r"/dev/i2c-(\d+)" , out )
55+ return m .group (1 ) if m else ""
56+
57+ def _ddc_get_brightness (self , bus : str ) -> tuple [int , int ]:
58+ """Return (current, max) brightness from `ddcutil -b BUS getvcp 10 --brief`.
59+
60+ Typical output: 'VCP 10 C 60 100' => current=60, max=100
61+ """
62+ try :
63+ out = subprocess .check_output (
64+ ["ddcutil" , "-b" , bus , "getvcp" , "10" , "--brief" ],
65+ text = True ,
66+ stderr = subprocess .DEVNULL ,
67+ timeout = 3 ,
68+ ).strip ()
69+ parts = out .split ()
70+ if len (parts ) >= 5 and parts [0 ] == "VCP" and parts [1 ] == "10" :
71+ return int (parts [3 ]), int (parts [4 ])
72+ except Exception : # noqa: S110
73+ pass
74+ return 0 , 100
75+
76+ # ------------------------------------------------------------------
77+ # Initialisation
78+ # ------------------------------------------------------------------
2579
2680 def __init__ (self , ** kwargs ):
2781 super ().__init__ (** kwargs )
2882
83+ self .is_ddc : bool = False
84+ self .ddc_bus : str = ""
85+ self ._ddc_cached_brightness : int = 0
86+ self .max_brightness_level : int = - 1
87+
2988 self .base_blacklight_path = Path ("/sys/class/backlight" )
3089 self .screen_device = get_device (self .base_blacklight_path )
31- self .screen_backlight_path = self .base_blacklight_path / self .screen_device
32- self .max_brightness_level = self .do_read_max_brightness (
33- self .screen_backlight_path
34- )
3590
36- if self .screen_device == "" :
37- logger .warning ("No backlight devices found!" )
91+ # --- Path 1: internal backlight (laptop) ----------------------
92+ if self .screen_device :
93+ self .screen_backlight_path = (
94+ self .base_blacklight_path / self .screen_device
95+ )
96+ self .max_brightness_level = self .do_read_max_brightness (
97+ self .screen_backlight_path
98+ )
99+
100+ self .screen_monitor = monitor_file (
101+ str (self .screen_backlight_path / "brightness" )
102+ )
103+ self .screen_monitor .connect (
104+ "changed" ,
105+ lambda _ , file , * args : self .emit (
106+ "screen" ,
107+ round (int (file .load_bytes ()[0 ].get_data ())),
108+ ),
109+ )
110+
111+ logger .info (
112+ f"Brightness service initialised for backlight device: { self .screen_device } "
113+ )
38114 return
39115
40- self .screen_monitor = monitor_file (
41- str (self .screen_backlight_path / "brightness" )
42- )
43- self .screen_monitor .connect (
44- "changed" ,
45- lambda _ , file , * args : self .emit (
46- "screen" ,
47- round (int (file .load_bytes ()[0 ].get_data ())),
48- ),
116+ # --- Path 2: external monitor via DDC/CI (desktop) -----------
117+ if executable_exists ("ddcutil" ):
118+ bus = self ._ddc_detect_bus ()
119+ if bus :
120+ cur , mx = self ._ddc_get_brightness (bus )
121+ self .is_ddc = True
122+ self .ddc_bus = bus
123+ self ._ddc_cached_brightness = cur
124+ self .max_brightness_level = mx if mx > 0 else 100
125+ logger .info (
126+ f"Brightness service initialised via ddcutil (i2c-{ bus } ), "
127+ f"current={ cur } , max={ self .max_brightness_level } "
128+ )
129+ return
130+ else :
131+ logger .warning (
132+ "ddcutil is installed but no DDC/CI-capable monitors were found."
133+ )
134+ else :
135+ logger .warning ("ddcutil is not installed — DDC/CI brightness unavailable." )
136+
137+ logger .warning (
138+ "No backlight device and no DDC/CI monitor detected. "
139+ "Brightness control will be unavailable."
49140 )
50141
51- logger .info (f"Brightness service initialized for device: { self .screen_device } " )
142+ # ------------------------------------------------------------------
143+ # Helpers
144+ # ------------------------------------------------------------------
52145
53- def do_read_max_brightness (self , path : str ) -> int :
146+ def do_read_max_brightness (self , path : Path ) -> int :
54147 """Reads the maximum brightness value from the specified path."""
55148 max_brightness_path = os .path .join (path , "max_brightness" )
56149 if os .path .exists (max_brightness_path ):
57150 with open (max_brightness_path ) as f :
58151 return int (f .readline ())
59152 return - 1 # Return -1 if file doesn't exist, indicating an error.
60153
154+ # ------------------------------------------------------------------
155+ # Property: screen_brightness
156+ # ------------------------------------------------------------------
157+
61158 @Property (int , "read-write" )
62159 def screen_brightness (self ) -> int :
63160 """Property to get or set the screen brightness."""
161+ if self .is_ddc :
162+ # Reads from DDC are slow (~100 ms), so return the cached value.
163+ return self ._ddc_cached_brightness
164+
64165 brightness_path = self .screen_backlight_path / "brightness"
65166 if brightness_path .exists ():
66167 with open (brightness_path ) as f :
@@ -71,21 +172,38 @@ def screen_brightness(self) -> int:
71172 @screen_brightness .setter
72173 def screen_brightness (self , value : int ):
73174 """Setter for screen brightness property."""
74- if not (0 <= value <= self .max_brightness_level ):
75- value = max (0 , min (value , self .max_brightness_level ))
175+ value = max (0 , min (value , self .max_brightness_level ))
176+
177+ # --- DDC/CI path (external monitor) --------------------------
178+ if self .is_ddc and self .ddc_bus :
179+ self ._ddc_cached_brightness = value
180+ # Update the UI percentage immediately (don't wait for hardware)
181+ if self .max_brightness_level > 0 :
182+ self .emit (
183+ "screen" ,
184+ int ((value / self .max_brightness_level ) * 100 ),
185+ )
186+ try :
187+ exec_shell_command_async (
188+ f"ddcutil -b { self .ddc_bus } setvcp 10 { value } "
189+ )
190+ except GLib .Error as e :
191+ logger .error (f"Error setting ddcutil brightness: { e .message } " )
192+ except Exception as e :
193+ logger .exception (f"Unexpected error setting ddcutil brightness: { e } " )
194+ return
76195
196+ # --- brightnessctl path (laptop) -----------------------------
77197 try :
78198 if not executable_exists ("brightnessctl" ):
79199 logger .error ("Command brightnessctl not found" )
200+ return
80201
81202 exec_shell_command_async (
82203 f"brightnessctl --device '{ self .screen_device } ' set { value } "
83204 )
84205
85206 self .emit ("screen" , int ((value / self .max_brightness_level ) * 100 ))
86- logger .info (
87- f"Set screen brightness to { value } (out of { self .max_brightness_level } )"
88- )
89207 except GLib .Error as e :
90208 logger .error (f"Error setting screen brightness: { e .message } " )
91209 except Exception as e :
0 commit comments