├── .gitignore ├── LICENSE ├── README.md ├── assets ├── brightness.png └── contrast.png ├── ddcci.py ├── ddccli.py ├── qddccigui.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | 38 | # vim 39 | *~ 40 | *.sw[op] 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Piotr ∞ Dobrowolski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | python-ddcci 2 | ============ 3 | 4 | Control DDC/CI-capable display using `python-smbus`. 5 | 6 | Small POC evolved into a full-blown interface now! Code is pretty much 7 | self-explanatory, especially in conjunction with example included in libraries 8 | `main`. 9 | 10 | `qddccigui.py` is an example app, a PyQt4-based brightness/contrast controller. 11 | Bus ID can be provider as the first (and only) commandline argument, bus 8 is 12 | chosen otherwise. (Most probably because my primary display is accessible on 13 | that one, but not really sure ;)) Icons used in this example are courtesy of 14 | [Glyphish](http://www.glyphish.com/). 15 | 16 | You may also find `ddccontrol-db` and `ddccontrol -p` helpful when looking for 17 | control addresses. 18 | -------------------------------------------------------------------------------- /assets/brightness.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Informatic/python-ddcci/87ce569183f2fb76885fcb5c10e0b75529f485f1/assets/brightness.png -------------------------------------------------------------------------------- /assets/contrast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Informatic/python-ddcci/87ce569183f2fb76885fcb5c10e0b75529f485f1/assets/contrast.png -------------------------------------------------------------------------------- /ddcci.py: -------------------------------------------------------------------------------- 1 | import smbus 2 | import time 3 | from functools import wraps 4 | 5 | HOST_SLAVE_ADDRESS = 0x51 6 | PROTOCOL_FLAG = 0x80 7 | 8 | DDCCI_COMMAND_READ = 0x01 9 | DDCCI_REPLY_READ = 0x02 10 | DDCCI_COMMAND_WRITE = 0x03 11 | 12 | DEFAULT_DDCCI_ADDR = 0x37 13 | 14 | READ_DELAY = WRITE_DELAY = 0.06 15 | 16 | 17 | def throttle(delay): 18 | """usage: 19 | @throttle 20 | def func( ... ): ... (defaults to WRITE_DELAY) 21 | 22 | @throttle(3) 23 | def func( ... ): ... (delay provided explicitly)""" 24 | 25 | def throttle_deco(func): 26 | @wraps(func) 27 | def wrapped(*args, **kwargs): 28 | if hasattr(func, 'last_execution') and \ 29 | time.time() - func.last_execution < delay: 30 | time.sleep(delay - (time.time() - func.last_execution)) 31 | 32 | r = func(*args, **kwargs) 33 | func.last_execution = time.time() 34 | return r 35 | 36 | return wrapped 37 | 38 | if callable(delay): # @throttle invocation 39 | func, delay = delay, WRITE_DELAY 40 | return throttle_deco(func) 41 | else: # @throttle(...) invocation 42 | return throttle_deco 43 | 44 | 45 | class ReadException(Exception): 46 | pass 47 | 48 | 49 | class DDCCIDevice(object): 50 | def __init__(self, bus, address=DEFAULT_DDCCI_ADDR): 51 | if isinstance(bus, smbus.SMBus): 52 | self.bus = bus 53 | else: 54 | self.bus = smbus.SMBus(bus) 55 | 56 | self.address = address 57 | 58 | def write(self, ctrl, value): 59 | payload = self.prepare_payload( 60 | self.address, 61 | [DDCCI_COMMAND_WRITE, ctrl, (value >> 8) & 255, value & 255] 62 | ) 63 | 64 | self.write_payload(payload) 65 | 66 | def read(self, ctrl, extended=False): 67 | payload = self.prepare_payload( 68 | self.address, 69 | [DDCCI_COMMAND_READ, ctrl] 70 | ) 71 | 72 | self.write_payload(payload) 73 | 74 | time.sleep(READ_DELAY) 75 | 76 | if self.bus.read_byte(self.address) != self.address << 1: 77 | raise ReadException("ACK invalid") 78 | 79 | data_length = self.bus.read_byte(self.address) & ~PROTOCOL_FLAG 80 | data = [self.bus.read_byte(self.address) for n in xrange(data_length)] 81 | checksum = self.bus.read_byte(self.address) 82 | 83 | xor = (self.address << 1 | 1) ^ HOST_SLAVE_ADDRESS ^ (PROTOCOL_FLAG | len(data)) 84 | 85 | for n in data: 86 | xor ^= n 87 | 88 | if xor != checksum: 89 | raise ReadException("Invalid checksum") 90 | 91 | if data[0] != DDCCI_REPLY_READ: 92 | raise ReadException("Invalid response type") 93 | 94 | if data[2] != ctrl: 95 | raise ReadException("Received data for unrequested control") 96 | 97 | max_value = data[4] << 8 | data[5] 98 | value = data[6] << 8 | data[7] 99 | 100 | if extended: 101 | return value, max_value 102 | else: 103 | return value 104 | 105 | def control_property(ctrl): 106 | """helper for adding control properties (see demo)""" 107 | return property(lambda s: s.read(ctrl), 108 | lambda s, v: s.write(ctrl, v)) 109 | 110 | brightness = control_property(0x10) 111 | contrast = control_property(0x12) 112 | 113 | @throttle 114 | def write_payload(self, payload): 115 | self.bus.write_i2c_block_data(self.address, payload[0], payload[1:]) 116 | 117 | def prepare_payload(self, addr, data): 118 | payload = [HOST_SLAVE_ADDRESS, PROTOCOL_FLAG | len(data)] 119 | 120 | if data[0] == DDCCI_COMMAND_READ: 121 | xor = addr << 1 | 1 122 | else: 123 | xor = addr << 1 124 | 125 | payload.extend(data) 126 | 127 | for x in payload: 128 | xor ^= x 129 | 130 | payload.append(xor) 131 | 132 | return payload 133 | 134 | if __name__ == '__main__': 135 | # You can obtain your bus id using `i2cdetect -l` or `ddccontrol -p` 136 | d = DDCCIDevice(8) 137 | 138 | print('Demo 1 ...') 139 | d.write(0x10, 42) 140 | 141 | time.sleep(1) 142 | 143 | print('Demo 2 ...') 144 | d.brightness = 12 145 | d.contrast = 34 146 | 147 | time.sleep(1) 148 | 149 | print('Demo 3 ...') 150 | d.write(0x12, 69) 151 | 152 | print('Brightness: %d, Contrast: %d' % (d.brightness, d.contrast)) 153 | print('Max brightness: %d, Max contrast: %d' % ( 154 | d.read(0x10, True)[1], d.read(0x12, True)[1])) 155 | -------------------------------------------------------------------------------- /ddccli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Simple CLI interface to python-ddcci 6 | """ 7 | 8 | import ddcci 9 | import sys 10 | import argparse 11 | 12 | #@TODO: store global controls table in ddcci module 13 | controls = {'brightness': 0x10, 'contrast': 0x12} 14 | 15 | class Actions: GET, SET, SET_RELATIVE = range(3) 16 | 17 | def attr_change(value): 18 | """Special argparse type used for relative value store""" 19 | v = value.strip() 20 | 21 | if v.startswith('+'): 22 | return [Actions.SET_RELATIVE, int(v[1:])] 23 | if v.strip().startswith('-'): 24 | return [Actions.SET_RELATIVE, -int(v[1:])] 25 | 26 | return [Actions.SET, int(v)] 27 | 28 | def main(argv): 29 | parser = argparse.ArgumentParser(description=__doc__, epilog="""example usage: 30 | increase brightness by 10: 31 | %(prog)s --brightness +10 32 | 33 | set contrast to 50: 34 | %(prog)s --contrast 50 35 | 36 | get brightness and process it in shell script: 37 | echo "Your current brightness is: $(%(prog)s --raw --brightness)" """, 38 | formatter_class=argparse.RawDescriptionHelpFormatter) 39 | 40 | parser.add_argument('-b', '--bus', type=int, default=7, help='bus number') 41 | parser.add_argument('-d', '--device', metavar='DEV', type=int, default=ddcci.DEFAULT_DDCCI_ADDR, help='device address') 42 | 43 | for p in controls.keys(): 44 | parser.add_argument('--%s' % p, metavar='VALUE', nargs='?', type=attr_change, const=[Actions.GET], help='fetch or change "%s" control' % p) 45 | 46 | parser.add_argument('--raw', action='store_true', default=False, help='return raw values to stdout when fetching controls (default: disabled)') 47 | args = parser.parse_args(argv[1:]) 48 | 49 | props = dict((p, getattr(args, p)) for p in controls.keys() if getattr(args, p)) 50 | 51 | if not props: 52 | parser.print_usage() 53 | return 54 | 55 | device = ddcci.DDCCIDevice(args.bus, args.device) 56 | 57 | for p, v in props.items(): 58 | control_id = controls[p] 59 | 60 | if v[0] is Actions.GET: 61 | if args.raw: 62 | print device.read(control_id) 63 | else: 64 | print '%s=%d' % (p, device.read(control_id)) 65 | elif v[0] is Actions.SET: 66 | device.write(control_id, v[1]) 67 | elif v[0] is Actions.SET_RELATIVE: 68 | device.write(control_id, device.read(control_id) + v[1]) 69 | 70 | if __name__ == '__main__': 71 | main(sys.argv) 72 | -------------------------------------------------------------------------------- /qddccigui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | PyQt4 DDC/CI GUI, python-ddcci example 6 | """ 7 | 8 | import sys 9 | import ddcci 10 | import os 11 | from PyQt4 import QtGui, QtCore 12 | from PyKDE4.kdeui import KStatusNotifierItem 13 | 14 | script_path = os.path.dirname(os.path.realpath(os.path.abspath(__file__))) 15 | assets_path = os.path.join(script_path, 'assets') 16 | 17 | 18 | def asset(name): 19 | return os.path.join(assets_path, name) 20 | 21 | 22 | class QDDCCIGui(QtGui.QWidget): 23 | controls = [{ 24 | 'tag': 'brightness', 25 | 'name': 'Brightness', 26 | 'id': 0x10, 27 | }, { 28 | 'tag': 'contrast', 29 | 'name': 'Constrast', 30 | 'id': 0x12, 31 | }] 32 | 33 | scroll_control = controls[1] 34 | 35 | def __init__(self, busid): 36 | super(QDDCCIGui, self).__init__() 37 | 38 | self.device = ddcci.DDCCIDevice(busid) 39 | self.init_ui() 40 | 41 | def init_ui(self): 42 | grid = QtGui.QGridLayout() 43 | grid.setSpacing(2) 44 | 45 | for i, control in enumerate(self.controls): 46 | icon = QtGui.QLabel(self) 47 | icon.setPixmap(QtGui.QPixmap(asset('%s.png' % control['tag']))) 48 | icon.setToolTip(control['name']) 49 | grid.addWidget(icon, i+1, 0) 50 | 51 | label = QtGui.QLabel(self) 52 | label.setMinimumWidth(32) 53 | label.setAlignment(QtCore.Qt.AlignVCenter | QtCore.Qt.AlignRight) 54 | grid.addWidget(label, i+1, 1) 55 | 56 | sld = QtGui.QSlider(QtCore.Qt.Horizontal, self) 57 | 58 | sld.label = label 59 | sld.control = control 60 | 61 | value, max_value = self.device.read(control['id'], True) 62 | 63 | sld.setMinimum(0) 64 | sld.setMaximum(max_value) 65 | sld.setValue(value) 66 | self.update_label(sld) 67 | 68 | sld.setMinimumWidth(150) 69 | sld.setFocusPolicy(QtCore.Qt.NoFocus) 70 | sld.valueChanged[int].connect(self.change_value) 71 | 72 | control['slider'] = sld # FIXME circular reference 73 | 74 | grid.addWidget(sld, i+1, 2) 75 | 76 | self.setLayout(grid) 77 | self.setGeometry(300, 300, 280, 70) 78 | self.setWindowTitle('Qt DDC/CI Gui') 79 | self.show() 80 | 81 | if self.scroll_control: 82 | self.tray_icon = KStatusNotifierItem("qddccigui", self) 83 | self.tray_icon.setIconByPixmap(QtGui.QIcon(QtGui.QPixmap( 84 | asset('%s.png' % self.scroll_control['tag'])))) 85 | self.tray_icon.scrollRequested[int, QtCore.Qt.Orientation].\ 86 | connect(self.scroll_requested) 87 | 88 | def change_value(self, value, update=True): 89 | self.update_label(self.sender()) 90 | 91 | if update: 92 | self.device.write(self.sender().control['id'], value) 93 | 94 | def scroll_requested(self, delta, orientation): 95 | new_value = self.scroll_control['slider'].value() + delta/24 96 | self.scroll_control['slider'].setValue(new_value) 97 | 98 | def update_label(self, sld): 99 | sld.label.setText('%d%%' % sld.value()) 100 | 101 | 102 | def main(): 103 | app = QtGui.QApplication(sys.argv) 104 | argv = app.arguments() 105 | ex = QDDCCIGui(int(argv[1]) if len(argv) > 1 else 8) 106 | sys.exit(app.exec_()) 107 | 108 | if __name__ == '__main__': 109 | main() 110 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | smbus 2 | --------------------------------------------------------------------------------