-
-
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()) | |
Just disassembled the OSD Sidekick tool and found this
public ushort GetCurrentBC() => this.GetOSD(2, new byte[3]
{
(byte) 224,
(byte) 105,
(byte) 0
});
. . .
public void SetB() => this.SetOSD(3, new byte[3]
{
(byte) 224,
(byte) 105,
(byte) 0
});
. . .
public void SetC() => this.SetOSD(3, new byte[3]
{
(byte) 224,
(byte) 105,
(byte) 1
});
I think this means that the message will look like this for setting
message = bytearray([0x6e, 0x51, 0x84, 0x03, 224, 105, 0 if USB_B else 1]
WDYT?
EDIT I can confirm that it works 🙂
I didn't think of disassembling the app, good idea :D (the values I got were flat out sniffed from USB with Wireshark). Glad that it works!
BTW I've stolen some code and put it here ➡️ https://github.com/P403n1x87/m27q. Hope you don't mind 🙂
FWIW it looks like the same protocol is used a realtek HID device for the newer M32U monitor (with a bunch of other bytes before it):
https://github.com/kelvie/gbmonctl/blob/main/main.go
The 51 84 03 <property> <property> <value>
sequence looks like it holds, the 03
is probably a length, and it looks like there is a checksum byte after that.
I'll also check if the KVM
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.
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.
This is very cool. Would you happen to know what byte sequence is required to emulate the KVM switch button?