Skip to content

Instantly share code, notes, and snippets.

@wadimw
Last active February 23, 2024 15:45
Show Gist options
  • Save wadimw/4ac972d07ed1f3b6f22a101375ecac41 to your computer and use it in GitHub Desktop.
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
#!/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.

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