Skip to content

Instantly share code, notes, and snippets.

@wadimw
Last active September 29, 2024 00:04
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
#!/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())
@P403n1x87
Copy link

This is very cool. Would you happen to know what byte sequence is required to emulate the KVM switch button?

@P403n1x87
Copy link

P403n1x87 commented Aug 2, 2021

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 🙂

@wadimw
Copy link
Author

wadimw commented Aug 2, 2021

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!

@P403n1x87
Copy link

BTW I've stolen some code and put it here ➡️ https://github.com/P403n1x87/m27q. Hope you don't mind 🙂

@kelvie
Copy link

kelvie commented Oct 5, 2021

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

@kelvie
Copy link

kelvie commented Oct 5, 2021

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.

@P403n1x87
Copy link

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.

@kelvie
Copy link

kelvie commented Oct 5, 2021

Oh that makes a lot more sense. Thanks!

@kPepis
Copy link

kPepis commented Mar 2, 2022

Haven't tested this yet, but I have the same issue (DDC/CI commands not working over USB-C DisplayPort). Thanks for sharing this.

@jaknas
Copy link

jaknas commented Mar 29, 2022

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)

@wadimw
Copy link
Author

wadimw commented Mar 29, 2022

@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
Copy link

jaknas commented Mar 29, 2022

@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.

@s4ndm4ns
Copy link

@kelvie did you make it work on M32U? Looks like my M32Q works the same.

@kelvie
Copy link

kelvie commented Feb 12, 2023

@s4ndm4ns
Copy link

@s4ndm4ns https://github.com/kelvie/gbmonctl works on the M32U yes.

no windows :(

@WildFireFlum
Copy link

You can try using my script on windows:
https://github.com/WildFireFlum/gbmonitor/tree/main

@casshern6
Copy link

casshern6 commented Feb 22, 2024

@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.

@casshern6
Copy link

casshern6 commented Feb 22, 2024

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

@WildFireFlum
Copy link

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

@casshern6
Copy link

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

@WildFireFlum
Copy link

WildFireFlum commented Feb 23, 2024

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.

@mithwick93
Copy link

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

sample

@brianliyang
Copy link

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

sample

@mithwick93 this is awesome! Would be cool if you could add multi-monitor support, I run a pair of M27Qs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment