|
| 1 | +""" |
| 2 | +Module to read and set monitor brightness via I2C/DDC and sysfs. |
| 3 | +Based on the work from: https://github.com/Crozzers/screen_brightness_control |
| 4 | +Copyright (c) 2025 Crozzers (https://github.com/Crozzers) |
| 5 | +""" |
| 6 | + |
| 7 | +import os |
| 8 | +import glob |
| 9 | +import time |
| 10 | +import fcntl |
| 11 | +import struct |
| 12 | +import functools |
| 13 | +import operator |
| 14 | +from typing import Optional, List, Set |
| 15 | + |
| 16 | + |
| 17 | +class MonitorBrightness: |
| 18 | + """Base class for all monitor types.""" |
| 19 | + |
| 20 | + def __init__(self, identifier: str, manufacturer: str, name: str): |
| 21 | + self.identifier = identifier # Bus path or sysfs path |
| 22 | + self.manufacturer = manufacturer |
| 23 | + self.name = name |
| 24 | + self.unique_name = f"{manufacturer} {name}" |
| 25 | + self.last_successful_read = None |
| 26 | + |
| 27 | + def get_brightness(self, timeout: float = 2.0) -> Optional[int]: |
| 28 | + """Polls for brightness until timeout. Returns None if max_val is 0.""" |
| 29 | + raise NotImplementedError |
| 30 | + |
| 31 | + def set_brightness(self, value: int) -> None: |
| 32 | + """Sets brightness level. Returns True on success.""" |
| 33 | + raise NotImplementedError |
| 34 | + |
| 35 | + @staticmethod |
| 36 | + def list_displays() -> tuple[List["MonitorBrightness"], Set[str]]: |
| 37 | + """Scans I2C buses and /sys/class/backlight to return all monitors.""" |
| 38 | + found_displays = [] |
| 39 | + issues = set() |
| 40 | + |
| 41 | + # 1. Internal Laptop Displays (Sysfs) |
| 42 | + for path in glob.glob("/sys/class/backlight/*"): |
| 43 | + found_displays.append(SysfsMonitor(path)) |
| 44 | + if not os.access(os.path.join(path, "brightness"), os.W_OK): |
| 45 | + issues.add( |
| 46 | + "Backlight Permission Error: Create a udev rule.\n" |
| 47 | + 'Run: echo \'SUBSYSTEM=="backlight",RUN+="/bin/chmod 666" ' |
| 48 | + "/sys/class/backlight/%k/brightness /sys/class/backlight/%k/bl_power\"'" |
| 49 | + " | sudo tee -a /etc/udev/rules.d/backlight-permissions.rules\n" |
| 50 | + "Then run: sudo udevadm control --reload-rules && udevadm trigger" |
| 51 | + ) |
| 52 | + found_displays.append(SysfsMonitor(path)) |
| 53 | + |
| 54 | + # 2. External Monitors (I2C/DDC) |
| 55 | + for i2c_path in sorted(glob.glob("/dev/i2c-*")): |
| 56 | + try: |
| 57 | + fd = os.open(i2c_path, os.O_RDWR) |
| 58 | + # Use EDID address to identify the monitor |
| 59 | + fcntl.ioctl(fd, 0x0703, 0x50) |
| 60 | + data = os.read(fd, 256) |
| 61 | + os.close(fd) |
| 62 | + |
| 63 | + if data.startswith(bytes.fromhex("00 FF FF FF FF FF FF 00")): |
| 64 | + mfg, name, _ = DDCIPMonitor.parse_edid(data) |
| 65 | + found_displays.append(DDCIPMonitor(i2c_path, mfg, name)) |
| 66 | + except PermissionError: |
| 67 | + issues.add( |
| 68 | + f"I2C Permission Error: User '{os.getlogin()}' needs 'i2c' group access.\n" |
| 69 | + f"Run: sudo usermod -aG i2c {os.getlogin()} && reboot" |
| 70 | + ) |
| 71 | + except Exception: |
| 72 | + continue |
| 73 | + |
| 74 | + return found_displays, issues |
| 75 | + |
| 76 | + def __repr__(self): |
| 77 | + return ( |
| 78 | + f"<MonitorBrightness {self.manufacturer} {self.name} on {self.identifier}>" |
| 79 | + ) |
| 80 | + |
| 81 | + |
| 82 | +class SysfsMonitor(MonitorBrightness): |
| 83 | + """Handles laptop backlights via /sys/class/backlight.""" |
| 84 | + |
| 85 | + def __init__(self, path: str): |
| 86 | + name = os.path.basename(path) |
| 87 | + super().__init__(path, "Internal", name) |
| 88 | + |
| 89 | + def get_brightness(self, timeout: float = 1.0) -> Optional[int]: |
| 90 | + try: |
| 91 | + with open( |
| 92 | + os.path.join(self.identifier, "brightness"), "r", encoding="UTF-8" |
| 93 | + ) as f: |
| 94 | + cur_val = int(f.read().strip()) |
| 95 | + with open( |
| 96 | + os.path.join(self.identifier, "max_brightness"), "r", encoding="UTF-8" |
| 97 | + ) as f: |
| 98 | + max_val = int(f.read().strip()) |
| 99 | + |
| 100 | + if max_val == 0: |
| 101 | + return None |
| 102 | + self.last_successful_read = int((cur_val / max_val) * 100) |
| 103 | + return self.last_successful_read |
| 104 | + except (OSError, ValueError): |
| 105 | + return None |
| 106 | + |
| 107 | + def set_brightness(self, value: int) -> None: |
| 108 | + try: |
| 109 | + target_pct = max(0, min(100, int(value))) |
| 110 | + with open( |
| 111 | + os.path.join(self.identifier, "max_brightness"), "r", encoding="UTF-8" |
| 112 | + ) as f: |
| 113 | + max_val = int(f.read().strip()) |
| 114 | + |
| 115 | + actual_value = int((target_pct / 100) * max_val) |
| 116 | + with open( |
| 117 | + os.path.join(self.identifier, "brightness"), "w", encoding="UTF-8" |
| 118 | + ) as f: |
| 119 | + f.write(str(actual_value)) |
| 120 | + except (OSError, PermissionError): |
| 121 | + print("Error setting brightness for", self) |
| 122 | + |
| 123 | + |
| 124 | +class DDCIPMonitor(MonitorBrightness): |
| 125 | + """Handles external monitors via I2C/DDC logic.""" |
| 126 | + |
| 127 | + I2C_SLAVE = 0x0703 |
| 128 | + DDC_ADDR = 0x37 |
| 129 | + HOST_ADDR_W = 0x51 |
| 130 | + DEST_ADDR_W = 0x6E |
| 131 | + GET_VCP_CMD = 0x01 |
| 132 | + SET_VCP_CMD = 0x03 |
| 133 | + VCP_BRIGHTNESS = 0x10 |
| 134 | + |
| 135 | + @staticmethod |
| 136 | + def parse_edid(data: bytes): |
| 137 | + """Parses manufacturer and name from EDID block.""" |
| 138 | + try: |
| 139 | + m_bytes = struct.unpack(">H", data[8:10])[0] |
| 140 | + manufacturer = "".join( |
| 141 | + [ |
| 142 | + chr(((m_bytes >> 10) & 31) + 64), |
| 143 | + chr(((m_bytes >> 5) & 31) + 64), |
| 144 | + chr((m_bytes & 31) + 64), |
| 145 | + ] |
| 146 | + ) |
| 147 | + name, serial = "Unknown", "Unknown" |
| 148 | + for position_from in range(54, 109, 18): |
| 149 | + position_to = position_from + 18 |
| 150 | + block = data[position_from:position_to] |
| 151 | + if block[0:4] == b"\x00\x00\x00\xfc": |
| 152 | + name = ( |
| 153 | + block[5:] |
| 154 | + .split(b"\x0a")[0] |
| 155 | + .decode("ascii", errors="ignore") |
| 156 | + .strip() |
| 157 | + ) |
| 158 | + elif block[0:4] == b"\x00\x00\x00\xff": |
| 159 | + serial = ( |
| 160 | + block[5:] |
| 161 | + .split(b"\x0a")[0] |
| 162 | + .decode("ascii", errors="ignore") |
| 163 | + .strip() |
| 164 | + ) |
| 165 | + return manufacturer, name, serial |
| 166 | + except Exception: |
| 167 | + return "Unknown", "Unknown", "Unknown" |
| 168 | + |
| 169 | + def _ddc_command(self, payload: list, read_length: int = 0) -> Optional[bytes]: |
| 170 | + """Low-level I2C write/read transaction.""" |
| 171 | + fd = None |
| 172 | + try: |
| 173 | + fd = os.open(self.identifier, os.O_RDWR) |
| 174 | + fcntl.ioctl(fd, self.I2C_SLAVE, self.DDC_ADDR) |
| 175 | + |
| 176 | + # Construct DDC packet with checksum |
| 177 | + packet = bytearray([self.HOST_ADDR_W, len(payload) | 0x80] + payload) |
| 178 | + checksum = functools.reduce(operator.xor, packet, self.DEST_ADDR_W) |
| 179 | + packet.append(checksum) |
| 180 | + |
| 181 | + os.write(fd, packet) |
| 182 | + time.sleep(0.05) |
| 183 | + |
| 184 | + if read_length > 0: |
| 185 | + return os.read(fd, read_length) |
| 186 | + return None |
| 187 | + except (OSError, IOError): |
| 188 | + return None |
| 189 | + finally: |
| 190 | + if fd is not None: |
| 191 | + os.close(fd) |
| 192 | + |
| 193 | + def get_brightness(self, timeout: float = 2.0) -> Optional[int]: |
| 194 | + """Polls for external brightness via VCP.""" |
| 195 | + start_time = time.monotonic() |
| 196 | + while (time.monotonic() - start_time) < timeout: |
| 197 | + data = self._ddc_command([self.GET_VCP_CMD, self.VCP_BRIGHTNESS], 11) |
| 198 | + # DDC reply: [source, length, result, code, max_h, max_l, cur_h, cur_l, checksum] |
| 199 | + if data and len(data) >= 10: |
| 200 | + max_val = int.from_bytes(data[6:8], "big") |
| 201 | + cur_val = int.from_bytes(data[8:10], "big") |
| 202 | + |
| 203 | + if max_val != 0: |
| 204 | + self.last_successful_read = int((cur_val / max_val) * 100) |
| 205 | + return self.last_successful_read |
| 206 | + |
| 207 | + time.sleep(0.1) # Retry interval |
| 208 | + return None |
| 209 | + |
| 210 | + def set_brightness(self, value: int, retries: int = 2) -> None: |
| 211 | + """Sets brightness and verifies the hardware change.""" |
| 212 | + target = max(0, min(100, int(value))) |
| 213 | + # Send set command |
| 214 | + for _attempt in range(retries): |
| 215 | + self._ddc_command([self.SET_VCP_CMD, self.VCP_BRIGHTNESS, 0x00, target]) |
| 216 | + |
| 217 | + |
| 218 | +if __name__ == "__main__": |
| 219 | + monitors, myissues = MonitorBrightness.list_displays() |
| 220 | + for issue in myissues: |
| 221 | + print("Issue:", issue) |
| 222 | + print("---------") |
| 223 | + for mon in monitors: |
| 224 | + print(f"Checking {mon}...") |
| 225 | + current = mon.get_brightness() |
| 226 | + print(f"Current Brightness: {current}%") |
| 227 | + mon.set_brightness(20) |
0 commit comments