Skip to content

Commit 58b41c5

Browse files
authored
Merge pull request #224 from bkbilly/Brightness
Wayland support for Brightness
2 parents d51a5a5 + d1f9795 commit 58b41c5

File tree

2 files changed

+260
-69
lines changed

2 files changed

+260
-69
lines changed

lnxlink/modules/brightness.py

Lines changed: 33 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
"""Adjust display luminance globally or for individual monitors via number entities"""
2-
import re
32
import logging
4-
from shutil import which
5-
from lnxlink.modules.scripts.helpers import syscommand, get_display_variable
3+
from lnxlink.modules.scripts.monitor_brightness import MonitorBrightness
64

75
logger = logging.getLogger("lnxlink")
86

@@ -14,88 +12,54 @@ def __init__(self, lnxlink=None):
1412
"""Setup addon"""
1513
self.name = "Brightness"
1614
self.lnxlink = lnxlink
17-
self.display_variable = None
18-
if which("xrandr") is None:
19-
raise SystemError("System command 'xrandr' not found")
20-
self.displays = self._get_displays()
15+
self.monitors, issues = MonitorBrightness.list_displays()
16+
for issue in issues:
17+
logger.warning("Brightness Monitor Issue: %s", issue)
2118

2219
def get_info(self):
2320
"""Gather information from the system"""
24-
displays = self._get_displays()
25-
if displays != self.displays:
26-
self.displays = displays
21+
monitors, _ = MonitorBrightness.list_displays()
22+
if monitors != self.monitors:
23+
self.monitors = monitors
2724
self.lnxlink.setup_discovery("brightness")
28-
values = [disp["brightness"] for disp in displays.values()]
29-
avg_brightness = sum(values) / max(1, len(values))
30-
avg_brightness = max(0.1, avg_brightness)
3125

32-
info = {"status": avg_brightness}
33-
for display, values in displays.items():
34-
info[display] = max(0.1, values["brightness"])
26+
info = {}
27+
for monitor in self.monitors:
28+
monitor.get_brightness()
29+
info[monitor.unique_name] = monitor.last_successful_read
30+
3531
return info
3632

3733
def exposed_controls(self):
3834
"""Exposes to home assistant"""
3935
controls = {}
40-
if len(self.displays) > 0:
41-
controls = {
42-
"Brightness": {
43-
"type": "number",
44-
"icon": "mdi:brightness-7",
45-
"min": 0.1,
46-
"max": 1,
47-
"step": 0.1,
48-
"value_template": "{{ value_json.status }}",
49-
}
50-
}
51-
for display, values in self.displays.items():
52-
controls[f"Brightness {values['name']}"] = {
36+
for monitor in self.monitors:
37+
controls[f"Brightness {monitor.unique_name}"] = {
5338
"type": "number",
5439
"icon": "mdi:brightness-7",
55-
"min": 0.1,
56-
"max": 1,
57-
"step": 0.1,
58-
"value_template": f"{{{{ value_json.{display} }}}}",
59-
"enabled": False,
40+
"min": 0,
41+
"max": 100,
42+
"step": 1,
43+
"value_template": f"{{{{ value_json.get('{monitor.unique_name}') }}}}",
6044
}
6145
return controls
6246

6347
def start_control(self, topic, data):
6448
"""Control system"""
65-
disp_env_cmd = ""
66-
if self.display_variable is not None:
67-
disp_env_cmd = f" --display {self.display_variable}"
68-
6949
if topic[1] == "brightness":
70-
for values in self.displays.values():
71-
syscommand(
72-
f"xrandr --output {values['name']} --brightness {data} {disp_env_cmd}"
73-
)
50+
for monitor in self.monitors:
51+
monitor.set_brightness(int(data))
7452
else:
75-
display = self.displays[
76-
topic[1].replace("brightness_", "").replace("-", "_")
77-
]["name"]
78-
syscommand(f"xrandr --output {display} --brightness {data} {disp_env_cmd}")
79-
80-
def _get_displays(self):
81-
"""Get all the displays"""
82-
self.display_variable = get_display_variable()
83-
displays = {}
84-
disp_env_cmd = ""
85-
if self.display_variable is not None:
86-
disp_env_cmd = f" --display {self.display_variable}"
87-
88-
stdout, _, _ = syscommand(
89-
f"xrandr --verbose --current {disp_env_cmd}",
90-
)
91-
pattern = re.compile(
92-
r"(\S+) \bconnected\b.*[\s\S]*?(?=Brightness)Brightness: ([\d\.\d]+)"
93-
)
94-
95-
for match in pattern.findall(stdout):
96-
displays[match[0].replace("-", "_").lower()] = {
97-
"name": match[0],
98-
"brightness": float(match[1]),
99-
}
100-
101-
return displays
53+
for monitor in self.monitors:
54+
name_query = topic[1].replace("brightness_", "").replace("-", "_")
55+
monitor_unique_name = (
56+
monitor.unique_name.lower().replace("-", "_").replace(" ", "_")
57+
)
58+
if monitor_unique_name == name_query:
59+
logger.info(
60+
"Changing Brightness to %d for %s",
61+
int(data),
62+
monitor.unique_name,
63+
)
64+
monitor.set_brightness(int(data))
65+
break
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
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

Comments
 (0)