-
-
Save wadimw/4ac972d07ed1f3b6f22a101375ecac41 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 | |
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) |
# 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()) | |
I think the 03
is code for the setter; see e.g.
https://github.com/P403n1x87/m27q/blob/e5d549a6c5f8fd13a01b8a2bc9863a3590ab2088/m27q.py#L60-L76
The length seems to be encoded in the 8x
part of the payload.
Oh that makes a lot more sense. Thanks!
Haven't tested this yet, but I have the same issue (DDC/CI commands not working over USB-C DisplayPort). Thanks for sharing this.
If you're on M1 Mac, this script won't work: IODisplayConnect is not supported on M1 Macs. I managed to make it work by executing shell command here though: - https://developer.apple.com/forums/thread/666383?answerId=663154022#663154022 (it's although it's buggy and not ideal solution)
@jaknas I guess You could try to use the private CoreDisplay API that's mentioned in the topic You linked - that's what Lunar seems to use https://github.com/alin23/Lunar/search?q=DisplayServicesGetBrightness
@jaknas I guess You could try to use the private CoreDisplay API that's mentioned in the topic You linked - that's what Lunar seems to use github.com/alin23/Lunar/search?q=DisplayServicesGetBrightness
@wadimw Thanks, I managed to get it working - I forked the gist here: https://gist.github.com/jaknas/82db1f3814a265b4399c1985394c29c1 with changes to support M1 mac. I'm a python noob so main goal was to make it working.
Although I noticed that I often get ValueError: brightness out of bounds (0-100), got 107
error, probably still need some debugging and then tweaking.
@kelvie did you make it work on M32U? Looks like my M32Q works the same.
@s4ndm4ns https://github.com/kelvie/gbmonctl works on the M32U yes.
@s4ndm4ns https://github.com/kelvie/gbmonctl works on the M32U yes.
no windows :(
You can try using my script on windows:
https://github.com/WildFireFlum/gbmonitor/tree/main
@jaknas I guess You could try to use the private CoreDisplay API that's mentioned in the topic You linked - that's what Lunar seems to use github.com/alin23/Lunar/search?q=DisplayServicesGetBrightness
@wadimw Thanks, I managed to get it working - I forked the gist here: https://gist.github.com/jaknas/82db1f3814a265b4399c1985394c29c1 with changes to support M1 mac. I'm a python noob so main goal was to make it working.
Although I noticed that I often get
ValueError: brightness out of bounds (0-100), got 107
error, probably still need some debugging and then tweaking.
Guys, i will be brutally honest i don't know a shit about using Python Scripts, so can i ask for your help? I've installed Python 3 alreadym i'm on M1 Pro with Sonoma.
I downloaded the scripts, but don't really know if I should run all of them or just one is enough? I did try to run each but didnt work like that - "Run Module". Got errors only...I would like to make this autostarting in the background.
Thats what i got:
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/builtinbrt.py
= RESTART: /Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14/adaptMonitorBrightness-M27Q.py
Builtin: 43 M27Q: 5 was: None
Traceback (most recent call last):
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14/adaptMonitorBrightness-M27Q.py", line 88, in
main()
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14/adaptMonitorBrightness-M27Q.py", line 73, in main
with m27q.MonitorControl() as external:
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14/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
========== RESTART: /Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14/m27q.py =========
= RESTART: /Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14/adaptMonitorBrightness-M27Q.py
Builtin: 43 M27Q: 5 was: None
Traceback (most recent call last):
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14/adaptMonitorBrightness-M27Q.py", line 88, in
main()
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14/adaptMonitorBrightness-M27Q.py", line 73, in main
with m27q.MonitorControl() as external:
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14/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
You need to install the hidapi library for this to work.
While I'm not a mac user, there are instructions in the repository I linked,
you can either install it using howebrew via
brew install hidapi
or build it yourself (which will probably take you longer)
https://github.com/libusb/hidapi#mac
If you are looking for installation instruction for homebrew, I think this post might help you:
https://stackoverflow.com/a/67271753
Thanks but didn't help. I used pip3 cmd for that: "pip3 install hidapi"
Also using PIP3 i installed: PyUSB and PyObjC - which is required for this to work?
Either way it still gives the same error:
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/adaptMonitorBrightness-M27Q.py
Builtin: 58 M27Q: 24 was: None
Traceback (most recent call last):
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14/adaptMonitorBrightness-M27Q.py", line 88, in
main()
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14/adaptMonitorBrightness-M27Q.py", line 73, in main
with m27q.MonitorControl() as external:
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14/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
I was referring to using this script (my version):
https://github.com/WildFireFlum/gbmonitor/tree/main
which relies on using hidapi.
It is not enough to install the python bindings for this library, you'll need to install the library itself as well.
I built a small menu bar UI app to control basic M27Q settings based on this script if anyone is interested.
https://github.com/mithwick93/Gigabyte-M27Q-Settings-Controller
I built a small menu bar UI app to control basic M27Q settings based on this script if anyone is interested.
https://github.com/mithwick93/Gigabyte-M27Q-Settings-Controller
@mithwick93 this is awesome! Would be cool if you could add multi-monitor support, I run a pair of M27Qs.
On further experimentation, it looks like the command structure on the M32U for the KVM commands (and some other commands) are actually:
51 85 03 e0 <property> 00 <value>
, but plugging in 105 for the KVM switch, and 0/1 for USB B/C works! Thank you for dissembling it @P403n1x87 as I could not get the sidekick to actually trigger KVM to get a USB capture to figure it out.