Last active
September 29, 2024 00:04
-
-
Save wadimw/4ac972d07ed1f3b6f22a101375ecac41 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
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 | |
from Foundation import NSBundle | |
IOKIT_FRAMEWORK = "com.apple.framework.IOKit" | |
DISPLAY_CONNECT = b"IODisplayConnect" | |
class BuiltinBrightness: | |
# Import IOKit functions only once | |
_iokit_imported = False | |
# 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 | |
# Make sure necessary functions were imported | |
def __init__(self): | |
BuiltinBrightness.import_iokit() | |
# 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: | |
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 int(brightness*100) |
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. | |
# | |
# OSD getter, setter and KVM toggle based on @P403n1x87 findings (https://github.com/P403n1x87/m27q) | |
import usb.core | |
import usb.util | |
import typing as t | |
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) | |
return self | |
# Optionally reattach kernel driver | |
def __exit__(self, exc_type, exc_val, exc_tb): | |
# Reattach kernel driver | |
if self._had_driver: | |
self._dev.attach_kernel_driver(0) | |
# Release device | |
usb.util.dispose_resources(self._dev) | |
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 get_osd(self, data: t.List[int]): | |
self.usb_write( | |
b_request=178, | |
w_value=0, | |
w_index=0, | |
message=bytearray([0x6E, 0x51, 0x81 + len(data), 0x01]) + bytearray(data), | |
) | |
data = self.usb_read(b_request=162, w_value=0, w_index=111, msg_length=12) | |
return data[10] | |
def set_osd(self, data: bytearray): | |
self.usb_write( | |
b_request=178, | |
w_value=0, | |
w_index=0, | |
message=bytearray([0x6E, 0x51, 0x81 + len(data), 0x03] + data), | |
) | |
def set_brightness(self, brightness: int): | |
self.set_osd( | |
[ | |
0x10, | |
0x00, | |
max(self._min_brightness, min(self._max_brightness, brightness)), | |
] | |
) | |
def get_brightness(self): | |
return self.get_osd([0x10]) | |
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 get_kvm_status(self): | |
return self.get_osd([224, 105]) | |
def set_kvm_status(self, status): | |
self.set_osd([224, 105, status]) | |
def toggle_kvm(self): | |
self.set_kvm_status(1 - self.get_kvm_status()) | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@mithwick93 this is awesome! Would be cool if you could add multi-monitor support, I run a pair of M27Qs.