Skip to content

Commit e6d1136

Browse files
authored
Merge pull request #12 from meowrch/feat/combined-controls-improvement
Feat/combined controls improvement
2 parents cbfda90 + e25d1ca commit e6d1136

File tree

12 files changed

+650
-184
lines changed

12 files changed

+650
-184
lines changed

PKGBUILD

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ depends=(
2020
'tesseract-data-eng'
2121
'tesseract-data-rus'
2222
'cliphist'
23+
'brightnessctl'
24+
'ddcutil'
2325
)
2426
makedepends=(
2527
'python-uv'
2628
'git'
2729
'python-virtualenv'
2830
)
31+
install=mewline.install
2932
options=('!debug')
3033
source=("git+$url.git")
3134
sha256sums=('SKIP')

PKGBUILD.stable

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ depends=(
2020
'tesseract-data-eng'
2121
'tesseract-data-rus'
2222
'cliphist'
23+
'brightnessctl'
24+
'ddcutil'
2325
)
2426
makedepends=(
2527
'python-uv'
2628
'git'
2729
'python-virtualenv'
2830
)
31+
install=mewline.install
2932
options=('!debug')
3033
source=("$url/archive/refs/tags/v$pkgver.tar.gz")
3134
sha256sums=('SKIP') # Автоматическая замена в workflow

mewline.install

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
post_install() {
2+
echo "=> Adding user \${SUDO_USER:-\$USER} to i2c group for external monitor brightness control..."
3+
# Добавляем пользователя, который вызвал makepkg (или sudo pacman -U), в группу i2c
4+
usermod -aG i2c "${SUDO_USER:-$USER}" || true
5+
6+
echo "=> You might need to reload the i2c-dev module: sudo modprobe i2c-dev"
7+
echo "=> Please logout and login again for group changes to take effect."
8+
}
9+
10+
post_upgrade() {
11+
post_install
12+
}

src/mewline/services/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
from mewline.services.brightness import BrightnessService
66
from mewline.services.cache_notification import NotificationCacheService
77
from mewline.services.notifications import MyNotifications
8+
from mewline.services.privacy import PrivacyService
89

910
audio_service = Audio()
1011

1112
notification_service = MyNotifications()
1213
cache_notification_service = NotificationCacheService()
1314
brightness_service = BrightnessService()
1415
battery_service = BatteryService()
16+
privacy_service = PrivacyService()
1517

1618
bluetooth_client = BluetoothClient()
1719
# to run notify closures thus display the status

src/mewline/services/brightness.py

Lines changed: 142 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import os
2+
import re
3+
import subprocess
24
from pathlib import Path
35

46
from fabric.core.service import Property
@@ -13,54 +15,153 @@
1315

1416

1517
def 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

2326
class 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

Comments
 (0)