-
-
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?