-
-
Save jaknas/82db1f3814a265b4399c1985394c29c1 to your computer and use it in GitHub Desktop.
Python script for MacOS which changes brightness of Gigabyte M27Q KVM Monitor to match current brightness of builtin MacBook Pro display; this is instead of Lunar app, because DDC/CI commands do not work for me over USB-C DisplayPort. Includes support for M1 Macs using DisplayServices API.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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] |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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: