Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save jaknas/82db1f3814a265b4399c1985394c29c1 to your computer and use it in GitHub Desktop.
Save jaknas/82db1f3814a265b4399c1985394c29c1 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. Includes support for M1 Macs using DisplayServices API.
#!/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
import platform
from Foundation import NSBundle
IOKIT_FRAMEWORK = "com.apple.framework.IOKit"
DISPLAY_CONNECT = b"IODisplayConnect"
# Private API path to get display brightness on M1 Macs
# https://developer.apple.com/forums/thread/666383?answerId=663154022#663154022
DISPLAY_SERVICES_PATH = "/System/Library/PrivateFrameworks/DisplayServices.framework"
class BuiltinBrightness:
# Import functions only once
_iokit_imported = False
_displayservices_imported = False
# Get brightness as int, example: 0.6250000596046448 -> 62
def _format_brightness(self, brightness: float) -> int:
return int(brightness*100)
# 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
# Load functions used in fetching brightness on M1 Mac
@staticmethod
def import_displayservices():
if not BuiltinBrightness._displayservices_imported:
displayservices = NSBundle.bundleWithPath_(DISPLAY_SERVICES_PATH)
functions = [
("DisplayServicesGetBrightness", b"IIo^f")
]
objc.loadBundleFunctions(displayservices, globals(), functions)
BuiltinBrightness._displayservices_imported = True
# Make sure necessary functions were imported
def __init__(self):
BuiltinBrightness.import_iokit()
BuiltinBrightness.import_displayservices()
# 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:
if("arm" in platform.platform()):
# on M1 Mac, get brightness from DisplayServices
(error, brightness) = DisplayServicesGetBrightness(1, None)
if error:
raise IOError(f"Couldn't get brightness, platform {platform.platform()}, error {error}")
return self._format_brightness(brightness)
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 format_brightness(brightness)
# 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.
import usb.core
import usb.util
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)
# Claim device
# As per https://github.com/pyusb/pyusb/blob/master/docs/tutorial.rst
# this is not necessary
#usb.util.claim_interface(0)
return self
# Optionally reattach kernel driver
def __exit__(self, exc_type, exc_val, exc_tb):
# Release device
# As per https://github.com/pyusb/pyusb/blob/master/docs/tutorial.rst
# this is not necessary
#usb.util.release_interface(dev, 0)
# Reattach kernel driver
if self._had_driver:
self._dev.attach_kernel_driver(0)
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 set_brightness(self, brightness: int):
if not (isinstance(brightness, int)):
raise TypeError("brightness must be an int")
if not (self._min_brightness <= brightness <= self._max_brightness):
raise ValueError(f"brightness out of bounds ({self._min_brightness}-{self._max_brightness}), got {brightness}")
# Send brightness message
# Params were collected by sniffing USB traffic with Wireshark
self.usb_write(b_request = 178, w_value = 0, w_index = 0, message = bytearray([0x6e, 0x51, 0x84, 0x03, 0x10, 0x00, brightness]))
def get_brightness(self):
# Params were collected by sniffing USB traffic with Wireshark
# Request brightness
self.usb_write(b_request = 178, w_value = 0, w_index = 0, message = bytearray([0x6e, 0x51, 0x82, 0x01, 0x10]))
# Read data
data = self.usb_read(b_request = 162, w_value = 0, w_index = 111, msg_length = 12)
# 11-th byte seems to correspond directly to current brightness
return data[10]
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 set_volume(self, volume: int):
if not (isinstance(volume, int)):
raise TypeError("volume must be an int")
if not (self._min_volume <= volume <= self._max_volume):
raise ValueError(f"volume out of bounds ({self._min_volume}-{self._max_volume})")
# Send volume message
# Params were collected by sniffing USB traffic with Wireshark
self.usb_write(b_request = 178, w_value = 0, w_index = 0, message = bytearray([0x6e, 0x51, 0x84, 0x03, 0x62, 0x00, volume]))
def get_volume(self):
# Params were collected by sniffing USB traffic with Wireshark
# Request volume
self.usb_write(b_request = 178, w_value = 0, w_index = 0, message = bytearray([0x6e, 0x51, 0x82, 0x01, 0x62]))
# Read data
data = self.usb_read(b_request = 162, w_value = 0, w_index = 111, msg_length = 12)
# 11-th byte seems to correspond directly to current volume
return data[10]
@casshern6
Copy link

Halo, i'm struggling with this to work. I did install: hidapi, PyUSB and PyObjC, for this to work but still whenever i try to RUN in this Order:
1.m27q
2.builtinbrt
3.adaptMonitorBrightness-M27Q

and i get this:

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-2/builtinbrt.py

= RESTART: /Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/m27q.py

= RESTART: /Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/adaptMonitorBrightness-M27Q.py
Builtin: 59 M27Q: 25 was: None
Traceback (most recent call last):
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/adaptMonitorBrightness-M27Q.py", line 88, in
main()
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/adaptMonitorBrightness-M27Q.py", line 73, in main
with m27q.MonitorControl() as external:
File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/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

What do you think?

@Heyner128
Copy link

Heyner128 commented Jun 1, 2024

Halo, i'm struggling with this to work. I did install: hidapi, PyUSB and PyObjC, for this to work but still whenever i try to RUN in this Order: 1.m27q 2.builtinbrt 3.adaptMonitorBrightness-M27Q

and i get this:

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-2/builtinbrt.py

= RESTART: /Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/m27q.py

= RESTART: /Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/adaptMonitorBrightness-M27Q.py Builtin: 59 M27Q: 25 was: None Traceback (most recent call last): File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/adaptMonitorBrightness-M27Q.py", line 88, in main() File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/adaptMonitorBrightness-M27Q.py", line 73, in main with m27q.MonitorControl() as external: File "/Users/adammichalski/Downloads/82db1f3814a265b4399c1985394c29c1-daca4d8bc40bb62bacbbfe59f2f570ff443eee14-2/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

What do you think?

Hi,

I found the solution, it seems that pyusb doesn't have complete support for Mac M1 something related to pyusb searching for a lib in the wrong location and a collision with homebrew libraries, more details https://github.com/pyusb/pyusb/issues/355

To resume the discussion you have to execute this command (and make sure libusb it's installed through homebrew):

ln -s /opt/homebrew/lib/libusb-1.0.0.dylib /usr/local/lib/libusb.dylib

My setup:

  • python 3.12.2 installed through the official installer in python.org
  • Mac OS Sonoma 14.5

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