-
-
Save jaknas/82db1f3814a265b4399c1985394c29c1 to your computer and use it in GitHub Desktop.
#!/usr/local/bin/python3 | |
# For Gigabyte M27Q KVM connected over USB-C | |
# | |
# Reads brightness value of builtin display from system, adapts it (Lunar-style) and sends over USB command to external display | |
# | |
# You need PyUSB and PyObjC | |
# | |
# Not much testing was done so far, only on MBP A1990 15" 2019 i7 555X | |
# | |
# Works only on MacOS, but set_brightness_M27Q() can be used on any platform that supports PyUSB. | |
from sys import platform | |
from time import sleep | |
import m27q | |
import builtinbrt | |
# https://github.com/alin23/Lunar/blob/master/Lunar/Data/Util.swift | |
def map_number(number, from_low, from_high, to_low, to_high): | |
if number >= from_high: | |
return to_high | |
elif number <= from_low: | |
return to_low | |
elif to_low < to_high: | |
diff = to_high - to_low | |
from_diff = from_high - from_low | |
return (number - from_low) * diff / from_diff + to_low | |
else: | |
diff = to_high - to_low | |
from_diff = from_high - from_low | |
return (number - from_low) * diff / from_diff + to_low | |
# Algorithm copied over from Lunar | |
# https://www.desmos.com/calculator/zciiqhtnov | |
# https://github.com/alin23/Lunar/blob/master/Lunar/Data/Display.swift | |
def adapt_brightness(old_brightness): | |
# Adjust parameters here | |
int_clip_min = 35 # 0-100 | |
int_clip_max = 95 # 0-100 | |
ext_brt_min = 0 # 0-100 | |
ext_brt_max = 100 # 0-100 | |
offset = -50 # -100-100 | |
# Float division | |
old_brightness = float(old_brightness) | |
# Clipping | |
old_brightness = map_number(old_brightness, int_clip_min, int_clip_max, 0, 100) | |
# Curve factor | |
factor = 1-(float(offset)/100) | |
new_brightness = ((((old_brightness/100)*(ext_brt_max-ext_brt_min)+ext_brt_min)/100)**factor)*100 | |
new_brightness = int(round(new_brightness)) | |
return max(min(100, new_brightness), 0) | |
def main(): | |
if platform != "darwin": | |
raise Exception("This script works only on MacOS.") | |
else: | |
try: | |
builtin = builtinbrt.BuiltinBrightness() | |
stored_brightness = None | |
while True: | |
# Get builtin display brightness | |
builtin_brightness = builtin.get_brightness() | |
# Calculate value for external display | |
new_brightness = adapt_brightness(builtin_brightness) | |
print("Builtin:", builtin_brightness, "M27Q:", new_brightness, "was:", stored_brightness) | |
with m27q.MonitorControl() as external: | |
if new_brightness != stored_brightness: | |
external.transition_brightness(new_brightness) | |
stored_brightness = new_brightness | |
sleep(3) | |
except IOError as e: | |
import sys | |
t, v, tb = sys.exc_info() | |
print(v) | |
# Wait a minute so that launchd won't try to revive process non-stop | |
sleep(60) | |
raise e.with_traceback(tb) | |
if __name__ == "__main__": | |
main() |
# Builtin Brightness for MacOS | |
import objc | |
import CoreFoundation | |
import platform | |
from Foundation import NSBundle | |
IOKIT_FRAMEWORK = "com.apple.framework.IOKit" | |
DISPLAY_CONNECT = b"IODisplayConnect" | |
# Private API path to get display brightness on M1 Macs | |
# https://developer.apple.com/forums/thread/666383?answerId=663154022#663154022 | |
DISPLAY_SERVICES_PATH = "/System/Library/PrivateFrameworks/DisplayServices.framework" | |
class BuiltinBrightness: | |
# Import functions only once | |
_iokit_imported = False | |
_displayservices_imported = False | |
# Get brightness as int, example: 0.6250000596046448 -> 62 | |
def _format_brightness(self, brightness: float) -> int: | |
return int(brightness*100) | |
# Load functions used in fetching brightness | |
@staticmethod | |
def import_iokit(): | |
if not BuiltinBrightness._iokit_imported: | |
iokit = NSBundle.bundleWithIdentifier_(IOKIT_FRAMEWORK) | |
functions = [ | |
("IOServiceGetMatchingService", b"II@"), | |
("IOServiceMatching", b"@*"), | |
("IODisplayGetFloatParameter", b"iII@o^f"), | |
("IOObjectRelease", b"iI") | |
] | |
variables = [ | |
("kIOMasterPortDefault", b"I"), | |
# ("kIODisplayBrightnessKey", b"*") # "brightness", had some trouble loading it so using string literal instead | |
] | |
objc.loadBundleFunctions(iokit, globals(), functions) | |
objc.loadBundleVariables(iokit, globals(), variables) | |
globals()['kIODisplayBrightnessKey'] = "brightness" | |
BuiltinBrightness._iokit_imported = True | |
# Load functions used in fetching brightness on M1 Mac | |
@staticmethod | |
def import_displayservices(): | |
if not BuiltinBrightness._displayservices_imported: | |
displayservices = NSBundle.bundleWithPath_(DISPLAY_SERVICES_PATH) | |
functions = [ | |
("DisplayServicesGetBrightness", b"IIo^f") | |
] | |
objc.loadBundleFunctions(displayservices, globals(), functions) | |
BuiltinBrightness._displayservices_imported = True | |
# Make sure necessary functions were imported | |
def __init__(self): | |
BuiltinBrightness.import_iokit() | |
BuiltinBrightness.import_displayservices() | |
# Read brightness level of builtin (first) display | |
def get_brightness(self) -> int: | |
# Get first available display IOService | |
service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching(DISPLAY_CONNECT)) | |
if not service: | |
if("arm" in platform.platform()): | |
# on M1 Mac, get brightness from DisplayServices | |
(error, brightness) = DisplayServicesGetBrightness(1, None) | |
if error: | |
raise IOError(f"Couldn't get brightness, platform {platform.platform()}, error {error}") | |
return self._format_brightness(brightness) | |
raise IOError("No IODisplayConnect services found") | |
(error, brightness) = IODisplayGetFloatParameter(service, 0, kIODisplayBrightnessKey, None) | |
if error: | |
raise IOError(f"Couldn't get parameter {kIODisplayBrightnessKey} from service {service}, error {error}") | |
error = IOObjectRelease(service) | |
if error: | |
raise IOError(f"Failed to release IOService {service}") | |
return format_brightness(brightness) |
# For Gigabyte M27Q KVM connected over USB-C | |
# | |
# Recreates messages captured with Wireshark from OSD Sidekick on Windows. | |
# Requires PyUSB. | |
# Further testing should be done. | |
# Code based mostly on https://www.linuxvoice.com/drive-it-yourself-usb-car-6/ | |
# and https://github.com/pyusb/pyusb/blob/master/docs/tutorial.rst | |
# | |
# Can be used on any platform that supports PyUSB. | |
import usb.core | |
import usb.util | |
from time import sleep | |
class MonitorControl: | |
def __init__(self): | |
self._VID=0x2109 # (VIA Labs, Inc.) | |
self._PID=0x8883 # USB Billboard Device | |
self._dev=None | |
self._usb_delay = 50/1000 # 50 ms sleep after every usb op | |
self._min_brightness = 0 | |
self._max_brightness = 100 | |
self._min_volume = 0 | |
self._max_volume = 100 | |
# Find USB device, set config | |
def __enter__(self): | |
# Find device | |
self._dev=usb.core.find(idVendor=self._VID, idProduct=self._PID) | |
if self._dev is None: | |
raise IOError(f"Device VID_{self._VID}&PID_{self._PID} not found") | |
# Deach kernel driver | |
self._had_driver = False | |
try: | |
if self._dev.is_kernel_driver_active(0): | |
self._dev.detach_kernel_driver(0) | |
self._had_driver = True | |
except Exception as e: | |
pass | |
# Set config (1 as discovered with Wireshark) | |
self._dev.set_configuration(1) | |
# Claim device | |
# As per https://github.com/pyusb/pyusb/blob/master/docs/tutorial.rst | |
# this is not necessary | |
#usb.util.claim_interface(0) | |
return self | |
# Optionally reattach kernel driver | |
def __exit__(self, exc_type, exc_val, exc_tb): | |
# Release device | |
# As per https://github.com/pyusb/pyusb/blob/master/docs/tutorial.rst | |
# this is not necessary | |
#usb.util.release_interface(dev, 0) | |
# Reattach kernel driver | |
if self._had_driver: | |
self._dev.attach_kernel_driver(0) | |
def usb_write(self, b_request: int, w_value: int, w_index: int, message: bytes): | |
bm_request_type = 0x40 | |
if not self._dev.ctrl_transfer(bm_request_type, b_request, w_value, w_index, message) == len(message): | |
raise IOError("Transferred message length mismatch") | |
sleep(self._usb_delay) | |
def usb_read(self, b_request: int, w_value: int, w_index: int, msg_length: int): | |
bm_request_type = 0xC0 | |
data = self._dev.ctrl_transfer(bm_request_type, b_request, w_value, w_index, msg_length) | |
sleep(self._usb_delay) | |
return data | |
def set_brightness(self, brightness: int): | |
if not (isinstance(brightness, int)): | |
raise TypeError("brightness must be an int") | |
if not (self._min_brightness <= brightness <= self._max_brightness): | |
raise ValueError(f"brightness out of bounds ({self._min_brightness}-{self._max_brightness}), got {brightness}") | |
# Send brightness message | |
# Params were collected by sniffing USB traffic with Wireshark | |
self.usb_write(b_request = 178, w_value = 0, w_index = 0, message = bytearray([0x6e, 0x51, 0x84, 0x03, 0x10, 0x00, brightness])) | |
def get_brightness(self): | |
# Params were collected by sniffing USB traffic with Wireshark | |
# Request brightness | |
self.usb_write(b_request = 178, w_value = 0, w_index = 0, message = bytearray([0x6e, 0x51, 0x82, 0x01, 0x10])) | |
# Read data | |
data = self.usb_read(b_request = 162, w_value = 0, w_index = 111, msg_length = 12) | |
# 11-th byte seems to correspond directly to current brightness | |
return data[10] | |
def transition_brightness(self, to_brightness: int, step: int = 3): | |
current_brightness = self.get_brightness() | |
diff = abs(to_brightness - current_brightness) | |
if current_brightness <= to_brightness: | |
step = 1 * step # increase | |
else: | |
step = -1 * step # decrease | |
while diff >= abs(step): | |
current_brightness += step | |
self.set_brightness(current_brightness) | |
diff -= abs(step) | |
# Set one last time | |
if current_brightness != to_brightness: | |
self.set_brightness(to_brightness) | |
def set_volume(self, volume: int): | |
if not (isinstance(volume, int)): | |
raise TypeError("volume must be an int") | |
if not (self._min_volume <= volume <= self._max_volume): | |
raise ValueError(f"volume out of bounds ({self._min_volume}-{self._max_volume})") | |
# Send volume message | |
# Params were collected by sniffing USB traffic with Wireshark | |
self.usb_write(b_request = 178, w_value = 0, w_index = 0, message = bytearray([0x6e, 0x51, 0x84, 0x03, 0x62, 0x00, volume])) | |
def get_volume(self): | |
# Params were collected by sniffing USB traffic with Wireshark | |
# Request volume | |
self.usb_write(b_request = 178, w_value = 0, w_index = 0, message = bytearray([0x6e, 0x51, 0x82, 0x01, 0x62])) | |
# Read data | |
data = self.usb_read(b_request = 162, w_value = 0, w_index = 111, msg_length = 12) | |
# 11-th byte seems to correspond directly to current volume | |
return data[10] |
Halo, i'm struggling with this to work. I did install: hidapi, PyUSB and PyObjC, for this to work but still whenever i try to RUN in this Order: 1.m27q 2.builtinbrt 3.adaptMonitorBrightness-M27Q
and i get this:
Python 3.12.2 (v3.12.2:6abddd9f6a, Feb 6 2024, 17:02:06) [Clang 13.0.0 (clang-1300.0.29.30)] on darwin Type "help", "copyright", "credits" or "license()" for more information.
= RESTART: /Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/builtinbrt.py
= RESTART: /Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/m27q.py
= RESTART: /Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/adaptMonitorBrightness-M27Q.py Builtin: 59 M27Q: 25 was: None Traceback (most recent call last): File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/adaptMonitorBrightness-M27Q.py", line 88, in main() File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/adaptMonitorBrightness-M27Q.py", line 73, in main with m27q.MonitorControl() as external: File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/m27q.py", line 30, in enter self._dev=usb.core.find(idVendor=self._VID, idProduct=self._PID) File "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/site-packages/usb/core.py", line 1309, in find raise NoBackendError('No backend available') usb.core.NoBackendError: No backend available
What do you think?
Hi,
I found the solution, it seems that pyusb doesn't have complete support for Mac M1 something related to pyusb searching for a lib in the wrong location and a collision with homebrew libraries, more details https://github.com/pyusb/pyusb/issues/355
To resume the discussion you have to execute this command (and make sure libusb it's installed through homebrew):
ln -s /opt/homebrew/lib/libusb-1.0.0.dylib /usr/local/lib/libusb.dylib
My setup:
- python 3.12.2 installed through the official installer in python.org
- Mac OS Sonoma 14.5
Halo, i'm struggling with this to work. I did install: hidapi, PyUSB and PyObjC, for this to work but still whenever i try to RUN in this Order:
1.m27q
2.builtinbrt
3.adaptMonitorBrightness-M27Q
and i get this:
Python 3.12.2 (v3.12.2:6abddd9f6a, Feb 6 2024, 17:02:06) [Clang 13.0.0 (clang-1300.0.29.30)] on darwin
Type "help", "copyright", "credits" or "license()" for more information.
= RESTART: /Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/builtinbrt.py
= RESTART: /Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/m27q.py
= RESTART: /Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/adaptMonitorBrightness-M27Q.py
Builtin: 59 M27Q: 25 was: None
Traceback (most recent call last):
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/adaptMonitorBrightness-M27Q.py", line 88, in
main()
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/adaptMonitorBrightness-M27Q.py", line 73, in main
with m27q.MonitorControl() as external:
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/m27q.py", line 30, in enter
self._dev=usb.core.find(idVendor=self._VID, idProduct=self._PID)
File "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/site-packages/usb/core.py", line 1309, in find
raise NoBackendError('No backend available')
usb.core.NoBackendError: No backend available
What do you think?