├── .gitignore ├── LICENSE ├── doc └── img │ └── Capture.JPG ├── monitor_ctrl.py ├── readme.md ├── tkui.py ├── vcp.py └── vcp_code.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | .idea/ 104 | Thumbs.db 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Miguel X 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /doc/img/Capture.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dot-osk/monitor_ctrl/5e5a6d151478e5dd5b0361ff401a96b609b98c84/doc/img/Capture.JPG -------------------------------------------------------------------------------- /monitor_ctrl.py: -------------------------------------------------------------------------------- 1 | #!python3 2 | # coding = utf-8 3 | 4 | import os 5 | import sys 6 | import logging 7 | import argparse 8 | import vcp 9 | 10 | try: 11 | import tkinter 12 | from tkinter import ttk 13 | TK_IMPORTED = True 14 | except ImportError: 15 | TK_IMPORTED = False 16 | 17 | 18 | __VERSION__ = '1.0' 19 | __APP_NAME__ = 'monitor_ctrl' 20 | __LOGGING_FORMAT = "%(levelname)s:[%(filename)s:%(lineno)s-%(funcName)s()] %(message)s" 21 | 22 | DEFAULT_LOGFILE_PATH = os.path.join(os.environ.get('TEMP', './'), __APP_NAME__, 'log.txt') 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | # -s 参数的分隔符,设置RGB_GAIN的地方需要 eval() 用户输入,使用 [](),等作为分割符会出错 26 | ARG_SPLITTER = ':' 27 | 28 | # Application config 29 | APP_OPTIONS = { 30 | 'console': False, 31 | 'setting_values': {}, 32 | 'log_file': DEFAULT_LOGFILE_PATH 33 | } 34 | 35 | # Win32 _PhysicalMonitorStructure 36 | ALL_MONITORS = [] 37 | # vcp.PhyMonitor() instance(s) 38 | ALL_PHY_MONITORS = [] 39 | 40 | 41 | def parse_arg(): 42 | """ 43 | Parse command line arguments. 44 | :return: 45 | """ 46 | parser = argparse.ArgumentParser(description='通过DDC/CI设置显示器参数.') 47 | parser.add_argument('-m', action='store', type=str, default='*', help='指定要应用到的Monitor Model,' 48 | '不指定则应用到所有可操作的显示器') 49 | parser.add_argument('-s', action='store', type=str, help='property1=value1{}property2="value 2" 应用多项设置' 50 | .format(ARG_SPLITTER)) 51 | parser.add_argument('-r', action='store_true', default=False, help='将显示器恢复出厂设置') 52 | parser.add_argument('-t', action='store_true', default=False, help='对输入执行自动调整(仅VGA输入需要)') 53 | parser.add_argument('-c', action='store_true', default=False, help='不启用GUI') 54 | parser.add_argument('-l', action='store_true', help='显示可操作的显示器model') 55 | parser.add_argument('-v', action='store_true', help='Verbose logging') 56 | opts = parser.parse_args() 57 | 58 | global APP_OPTIONS 59 | APP_OPTIONS['apply_to_model'] = opts.m 60 | APP_OPTIONS['list_monitors'] = opts.l 61 | APP_OPTIONS['setting_value_string'] = opts.s 62 | APP_OPTIONS['restore_factory'] = opts.r 63 | APP_OPTIONS['perform_auto_setup'] = opts.t 64 | 65 | # if specified -c argument or tkinter not imported 66 | if opts.c or (not TK_IMPORTED): 67 | APP_OPTIONS['console'] = True 68 | # log to console 69 | APP_OPTIONS['log_file'] = None 70 | else: 71 | APP_OPTIONS['console'] = False 72 | # log to file 73 | APP_OPTIONS['log_file'] = DEFAULT_LOGFILE_PATH 74 | 75 | # logging level 76 | if opts.v: 77 | APP_OPTIONS['log_level'] = logging.DEBUG 78 | else: 79 | APP_OPTIONS['log_level'] = logging.INFO 80 | 81 | 82 | def set_monitor_attr(object_, attr_name, value) -> bool: 83 | """ 84 | set attribute of an instance. 85 | :param object_: 86 | :param attr_name: 87 | :param value: 88 | :return: 89 | """ 90 | try: 91 | # convert value type. 92 | value_type = type(getattr(object_, attr_name)) 93 | if value_type in (list, tuple): 94 | _LOGGER.debug('eval(): ' + value) 95 | value = eval(value) 96 | if value_type in (str, int): 97 | value = value_type(value) 98 | 99 | setattr(object_, attr_name, value) 100 | _LOGGER.info('OK: {}={}'.format(attr_name, value)) 101 | return True 102 | except Exception as err: 103 | _LOGGER.error('Failed: {}={}'.format(attr_name, value)) 104 | _LOGGER.error(err) 105 | return False 106 | 107 | 108 | def enum_monitors(): 109 | """ 110 | enumerate all monitor. append to ALL_PHY_MONITORS list 111 | :return: 112 | """ 113 | global ALL_PHY_MONITORS 114 | global ALL_MONITORS 115 | ALL_MONITORS = vcp.enumerate_monitors() 116 | for i in ALL_MONITORS: 117 | try: 118 | monitor = vcp.PhyMonitor(i) 119 | except OSError as err: 120 | _LOGGER.error(err) 121 | # ignore this monitor 122 | continue 123 | _LOGGER.info('Found monitor: ' + monitor.model) 124 | ALL_PHY_MONITORS.append(monitor) 125 | 126 | 127 | def parse_settings(): 128 | """ 129 | parse argument passed to "-s" 130 | format: property=value:property2=value2 131 | :return: 132 | """ 133 | settings_str = APP_OPTIONS.get('setting_value_string', '') 134 | 135 | settings_dict = {} 136 | for setting in settings_str.split(ARG_SPLITTER): 137 | try: 138 | property_, value = setting.strip().split('=') 139 | settings_dict[property_] = value 140 | except ValueError: 141 | _LOGGER.error('Failed to parse setting: ' + setting) 142 | continue 143 | APP_OPTIONS['setting_values'] = settings_dict 144 | _LOGGER.debug('setting properties: {}'.format(APP_OPTIONS.get('setting_values'))) 145 | 146 | 147 | def apply_all_settings(): 148 | """ 149 | 应用命令行指定的操作. 150 | :return: 151 | """ 152 | # 过滤不需要操作的显示器 153 | target_monitor = [] 154 | target_model = APP_OPTIONS.get('apply_to_model', '*').upper() 155 | 156 | if target_model == '*': 157 | target_monitor = ALL_PHY_MONITORS 158 | else: 159 | for i in ALL_PHY_MONITORS: 160 | if i.model.upper() == target_model: 161 | target_monitor.append(i) 162 | else: 163 | _LOGGER.debug('Will NOT apply settings to model: ' + i.model) 164 | 165 | for monitor in target_monitor: 166 | if APP_OPTIONS.get('restore_factory'): 167 | _LOGGER.info('{}: Reset monitor to factory settings.'.format(monitor.model)) 168 | monitor.reset_factory() 169 | 170 | if APP_OPTIONS.get('perform_auto_setup'): 171 | _LOGGER.info('{}: Perform video auto-setup.'.format(monitor.model)) 172 | monitor.auto_setup_perform() 173 | 174 | _LOGGER.info('apply settings to: ' + monitor.model) 175 | settings = APP_OPTIONS.get('setting_values') 176 | for i in settings.keys(): 177 | set_monitor_attr(monitor, i, settings.get(i)) 178 | 179 | 180 | def start_gui(): 181 | import tkui 182 | import threading 183 | 184 | app = tkui.TkApp() 185 | app.title(__APP_NAME__) 186 | app.status_text_var.set('正在检测显示器...') 187 | app.add_logfile_button(APP_OPTIONS.get('log_file')) 188 | 189 | def background_task(): 190 | enum_monitors() 191 | _LOGGER.info('start GUI, ignore command line actions.') 192 | app.status_text_var.set('') 193 | app.add_monitors_to_tab(ALL_PHY_MONITORS) 194 | 195 | threading.Thread(target=background_task, daemon=True).start() 196 | app.mainloop() 197 | 198 | 199 | def start_cli(): 200 | enum_monitors() 201 | 202 | if APP_OPTIONS.get('list_monitors'): 203 | for i in ALL_PHY_MONITORS: 204 | print(i.model) 205 | sys.exit(0) 206 | 207 | apply_all_settings() 208 | 209 | 210 | if __name__ == '__main__': 211 | # 解析命令行参数 212 | parse_arg() 213 | 214 | if APP_OPTIONS.get('log_file'): 215 | os.makedirs(os.path.dirname(APP_OPTIONS.get('log_file')), exist_ok=True) 216 | 217 | logging.basicConfig(filename=APP_OPTIONS['log_file'], 218 | level=APP_OPTIONS['log_level'], 219 | format=__LOGGING_FORMAT) 220 | 221 | if not TK_IMPORTED: 222 | _LOGGER.warning('Failed to import tkinter, force console mode.') 223 | 224 | _LOGGER.debug('parse args done. current config:') 225 | _LOGGER.debug(APP_OPTIONS) 226 | 227 | if APP_OPTIONS.get('console') and \ 228 | (APP_OPTIONS.get('setting_value_string') is None) \ 229 | and (not APP_OPTIONS.get('restore_factory')) \ 230 | and (not APP_OPTIONS.get('list_monitors')) \ 231 | and (not APP_OPTIONS.get('perform_auto_setup')): 232 | # Nothing to do. 233 | _LOGGER.warning('Nothing todo. exit.') 234 | sys.exit(0) 235 | 236 | # 解析 -s 参数的值 237 | if APP_OPTIONS.get('setting_value_string'): 238 | parse_settings() 239 | 240 | if APP_OPTIONS.get('console'): 241 | start_cli() 242 | else: 243 | start_gui() 244 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | 通过显示器的 DDC/CI 来直接操作显示器,拯救可怜的显示器按键。 3 | 4 | 支持的操作: 5 | 6 | - 调整亮度 7 | 8 | - 调整对比度 9 | 10 | - 设置色温 / 颜色预设 11 | 12 | - 设置RGB颜色的比例 13 | 14 | - OSD语言 15 | 16 | - 开关机 17 | 18 | - 切换输入源 19 | 20 | - 自动调整图像 (VGA输入需要) 21 | 22 | - 恢复出厂设置 23 | 24 | 25 | 注意:我只在自己平时使用的几个辣鸡显示器上测试全部OK,不一定对所有显示器支持良好。 26 | 27 | HDMI/DP音量调整等更多功能由于显示器不支持没法测试就没加进来,有需要的可以自行添加进去。 28 | 29 | 由于我对 VESA 的 MCCS 文档只是粗浅的读了一下然后复制指令到代码中,可能有些指令使用方式不正确,欢迎指正。 30 | 31 | 32 | ![GUI](https://github.com/dot-osk/monitor_ctrl/raw/master/doc/img/Capture.JPG) 33 | 34 | 35 | 36 | # 系统需求 37 | 38 | ```text 39 | Windows Vista + 40 | Python3 (建议安装时选上Python Launcher) 41 | 支持DDC/CI的外接显示器,不支持笔记本内置显示器 42 | ``` 43 | 44 | 45 | # 使用参考 46 | 47 | ## GUI 模式 48 | 49 | 不附加参数启动 `monitor_ctrl.py` 即可启动GUI,直接拖动滑条设置显示器的参数。 50 | 51 | 由于显示器应用VCP指令可能需要一定时间,为避免出错,GUI模式将忽略命令行指定的操作。 52 | 53 | GUI中显示的配置不会自动刷新,要查看新的配置目前需要重启应用程序。 54 | 55 | *将文件后缀修改为 .pyw, 直接双击打开,可以避免显示conhost黑窗口* 56 | 57 | ## 命令行模式 58 | 59 | 当指定 `-c` 选项或者 tkinter import失败就会使用CLI模式。 60 | 61 | ``` 62 | py monitor_ctrl.py [-h] [-m Model_string] [-s Settings_string] [-r] [-t] [-c] [-l] [-v] 63 | -h 显示帮助 64 | -m 指定要应用到的Monitor Model,不指定则应用到所有可操作的显示器 65 | -s property1=value1:property2="value 2" 应用多项设置 66 | -r 将显示器恢复出厂设置 67 | -t 对输入执行自动调整(仅VGA输入需要) 68 | -c 不启用GUI 69 | -l 显示可操作的显示器model 70 | -v Verbose logging 71 | ``` 72 | 73 | 74 | example: 75 | 76 | - 列出可操作的显示器 77 | 78 | `monitor_ctrl.py -c -l` 79 | 80 | - 降低显示器的亮度和蓝色亮度: 81 | 82 | `monitor_ctrl.py -c -s brightness=10:rgb_gain="(100, 100, 80)"` 83 | 84 | - 设置显示器颜色预设为 sRGB : 85 | 86 | `monitor_ctrl.py -c -s color_preset=sRGB` 87 | 88 | - 恢复出厂设置: 89 | 90 | `monitor_ctrl.py -c -r` 91 | 92 | - VGA输入自动调整 93 | 94 | `monitor_ctrl.py -c -t` 95 | 96 | - 仅设置某个特定型号的显示器: 97 | 98 | `monitor_ctrl.py -c -m p2401 -s power_mode=on` 99 | 100 | ### -s 接受的属性 101 | 102 | 参见后面 PhyMonitor() 类的常用属性。 103 | 104 | # TODO 105 | 106 | - 添加HDMI/DP输入时音频音量的调节 (显示器不支持音频输出暂时没法测试) 107 | 108 | 109 | # 参考资料 110 | 111 | [MSDN: High-Level Monitor API](https://msdn.microsoft.com/en-us/library/vs/alm/dd692964(v=vs.85).aspx) 112 | 113 | [MSDN: Low-Level Monitor Configuration](https://msdn.microsoft.com/en-us/library/windows/desktop/dd692982(v=vs.85).aspx) 114 | 115 | [Wiki: Monitor Control Command Set](https://en.wikipedia.org/wiki/Monitor_Control_Command_Set) 116 | 117 | [PDF: VESA Monitor Control Command Set](https://milek7.pl/ddcbacklight/mccs.pdf) 118 | 119 | 120 | 121 | # 其它使用方法(vcp.py) 122 | 123 | 1. 调用 `enumerate_monitors()` 函数获得一个可操作的物理显示器对象列表 124 | 125 | ```python 126 | from vcp import * 127 | 128 | try: 129 | monitors = enumerate_monitors() 130 | except OSError as err: 131 | exit(1) 132 | ``` 133 | 134 | 2. 迭代列表中的对象并尝试创建每个显示器对应的 `PhyMonitor()` 实例,需要处理可能抛出的异常,如显示器不支持 DDC/CI 或者I2C通讯失败等异常 135 | ```python 136 | phy_monitors = [] 137 | for i in monitors: 138 | try: 139 | monitor = PhyMonitor(i) 140 | except OSError as err: 141 | logging.error(err) 142 | # 忽略这个显示器并继续 143 | continue 144 | phy_monitors.append(monitor) 145 | 146 | # 选一个显示器测试 147 | pm = phy_monitors[0] 148 | # 显示型号 149 | pm.model 150 | ``` 151 | 152 | 3. 对每个 `PhyMonitor()` 实例进行期望的操作 153 | 154 | 155 | 156 | # PhyMonitor() class 157 | 158 | ## 常用属性的操作 159 | 160 | 注意:大部分属性操作过程中如出现异常,只会有 logging.error 日志,不会抛出异常 161 | 162 | 包装后的属性: 163 | 164 | ```text 165 | color_temperature 166 | 167 | brightness 168 | brightness_max 169 | 170 | contrast 171 | contrast_max 172 | 173 | color_preset 174 | color_preset_list 175 | 176 | rgb_gain 177 | rgb_gain_max 178 | 179 | osd_language 180 | osd_languages_list 181 | 182 | power_mode 183 | power_mode_list 184 | 185 | input_src 186 | input_src_list 187 | ``` 188 | 189 | 190 | ### `color_temperature` : 设置屏幕的色温(K) 191 | 192 | 显示器可能并不支持你设定的色温值,而且可能在显示器面板上设定的色温值不一定和这个属性报告的一样。 193 | 色温越高,屏幕颜色越冷,反之屏幕偏暖。 不建议操作这个属性来设置色温,使用 `color_preset` 属性。 194 | 195 | ```python 196 | # 读取当前色温设置 197 | pm.color_temperature 198 | >>> 7200 199 | # 设置色温 200 | pm.color_temperature = 6500 201 | ``` 202 | 203 | ### `brightness` 设置亮度 204 | 读取/设置显示器的亮度,允许值: 0 - `pm.brightness_max` 205 | 206 | ```python 207 | # 允许设置的最大亮度 208 | pm.brightness_max 209 | >>> 100 210 | # 读取当前亮度 211 | pm.brightness 212 | >>> 50 213 | # 设置亮度 214 | pm.brightness = 60 215 | ``` 216 | 217 | ### `contrast` 设置对比度 218 | 219 | 读取设置显示器的对比度,允许值: 0 - `pm.contrast_max` 220 | 使用方法同亮度属性 221 | 222 | ### `color_preset` 色温/颜色预设 223 | 224 | 读取/设置当前的颜色预设,列表中的可用值你的显示器不一定都支持。 225 | 226 | ```python 227 | # 查看VCP标准中可用的预设 228 | pm.color_preset_list 229 | >>> ['sRGB', 'Display Native', '4000K', '5000K', '6500K', '7500K', 230 | '8200K', '9300K', '10000K', '11500K', 'User Mode 1', 'User Mode 2', 'User Mode 3'] 231 | # 读取当前使用的预设 232 | pm.color_preset 233 | # 设置新的预设 234 | pm.color_preset = 'sRGB' 235 | ``` 236 | 237 | ### `rgb_gain` RGB颜色均衡 238 | 239 | 设置 RGB 三基色的均衡,注意:有些显示器只有使用用户模式(`'User Mode 1'`)的 `color_preset` 才能调整RGB均衡。 240 | 241 | ```python 242 | # 允许设置的最大值 243 | pm.rgb_gain_max 244 | >>> 100 245 | # 当前的RGB 均衡 246 | pm.rgb_gain 247 | >>> (100, 100, 100) 248 | # 设置新的RGB均衡 249 | pm.rgb_gain = 100, 100, 80 # "降低蓝光" 250 | pm.rgb_gain = [90, 90, 100] # 加强蓝光 251 | ``` 252 | 253 | ### `osd_language` 菜单语言 254 | 255 | 在我自己的显示器上测试有一点Bug,不支持设置土耳其和另外两个我不知道是什么的语言( 囧 ),其它显示器支持的语言OK。 256 | 257 | ```python 258 | # 查看VCP 标准中支持设置的语言,不是显示器支持的语言 259 | pm.osd_languages_list 260 | # 查看当前的OSD语言 261 | pm.osd_language 262 | >>> 'Chinese-traditional' 263 | # 设置OSD语言 264 | pm.osd_language = 'English' 265 | ``` 266 | 267 | ### `power_mode` 电源开关 268 | 269 | 设置为 'off' 相当于按下显示器面板上的电源键关机。 270 | 设置为 'on' 相当于按下显示器面板上的电源键开机。 271 | 272 | ```python 273 | pm.power_mode 274 | >>> 'on' 275 | # 关闭显示器电源 276 | pm.power_mode = 'off' 277 | pm.power_mode 278 | >>> 'off' 279 | # 再次打开显示器电源 280 | pm.power_mode = 'on' 281 | >>> 'on' 282 | ``` 283 | 284 | ### `input_src` 输入信号选择 285 | 286 | 可以设置 `input_src_list` 里面的输入源 287 | 288 | ```python 289 | # VCP 标准中的 输入源 290 | pm.input_src_list 291 | >>> ['Analog video (R/G/B) 1', 'Analog video (R/G/B) 2', 'Digital video (TMDS) 1 DVI 1', ...] 292 | # 当前的输入源, VGA 293 | pm.input_src 294 | >>> 'Analog video (R/G/B) 1' 295 | # 切换输入源为 DVI 1 296 | pm.input_src = 'Digital video (TMDS) 1 DVI 1' 297 | ``` 298 | 299 | 300 | 301 | ## 常用方法 302 | 303 | ### `reset_factory()` 恢复出厂设置 304 | 305 | 恢复显示器的出厂设置 306 | 307 | ### `auto_setup_perform()` 自动调整 308 | 309 | 只有使用VGA时才需要自动调节 310 | 311 | 312 | ### `close()` 313 | 314 | 调用 Windows 的 `DestroyPhysicalMonitor()` API 来销毁HANDLE 315 | 316 | 317 | ## 显示器信息属性 318 | 319 | `info_poweron_hours` 开机小时数 320 | 321 | `info_pannel_type` 面板子像素排列信息 322 | 323 | `model` 显示器型号 324 | 325 | 326 | ## 发送其它命令, 添加其它功能 327 | 328 | 1. 参考VCP指令列表,使用 329 | 330 | `set_vcp_value_by_name()` 和 `get_vcp_value_by_name()` 来发送 `vcp_code.VCP_CODE` 中已定义的功能。 331 | 332 | `vcp_code.VCP_CODE` 里面的代码并不完整,可以根据需要执行添加code到这个字典中。 333 | 334 | 335 | 2. 或者使用 336 | 337 | `send_vcp_code()` 和 `read_vcp_code()` 来发送指令代码(数字) 338 | 339 | 340 | # Todo 341 | 342 | 找台支持HDMI音频的显示器测试设置HDMI声音输出音量 343 | 344 | -------------------------------------------------------------------------------- /tkui.py: -------------------------------------------------------------------------------- 1 | #!python3 2 | # coding = utf-8 3 | 4 | import tkinter as tk 5 | from tkinter import ttk 6 | import logging 7 | import os 8 | 9 | """ 10 | 注意: GUI中显示的配置不会自动刷新,要查看新的配置目前需要重启应用程序 11 | """ 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | def _get_attr(object_, property_name): 17 | try: 18 | return getattr(object_, property_name) 19 | except AttributeError as err: 20 | _LOGGER.error(err) 21 | return None 22 | 23 | 24 | def _set_attr(object_, property_name, value): 25 | try: 26 | setattr(object_, property_name, value) 27 | except AttributeError as err: 28 | _LOGGER.error(err) 29 | 30 | 31 | class PropertySlider(tk.Scale): 32 | """ 33 | 设置数值的滑条控件 34 | """ 35 | def __init__(self, parent, phy_monitor, property_name: str, max_value=None, **kwargs): 36 | super(PropertySlider, self).__init__(parent, **kwargs) 37 | 38 | self.phy_monitor = phy_monitor 39 | self.property_name = property_name 40 | 41 | # get Max Value 42 | if max_value: 43 | self.max_value = max_value 44 | else: 45 | _LOGGER.warning("'max_value' not set, set max_value=100") 46 | self.max_value = 100 47 | 48 | self.var = tk.IntVar() 49 | self.var.set(_get_attr(self.phy_monitor, self.property_name)) 50 | self.configure(orient=tk.HORIZONTAL, from_=0, 51 | to=self.max_value, 52 | variable=self.var, 53 | length=200, 54 | showvalue=1) 55 | 56 | # 如果让 Widget 的操作即时响应,可能会导致发送vcp指令太频繁而报错,所以在鼠标放开后才设置新的值 57 | self.bind('', 58 | lambda event: _set_attr(self.phy_monitor, self.property_name, self.var.get())) 59 | 60 | 61 | class RGBSlider(ttk.LabelFrame): 62 | """ 63 | 设置RGB均衡的滑条控件 64 | """ 65 | def __init__(self, parent, phy_monitor, property_name: str, max_value=None, **kwargs): 66 | super(RGBSlider, self).__init__(parent, **kwargs) 67 | self.phy_monitor = phy_monitor 68 | self.property_name = property_name 69 | 70 | self.configure(text='RGB 均衡') 71 | 72 | # get Max Value 73 | if max_value: 74 | self.max_value = max_value 75 | else: 76 | _LOGGER.warning("'max_value' not set, set max_value=100") 77 | self.max_value = 100 78 | 79 | self.r_var = tk.IntVar() 80 | self.g_var = tk.IntVar() 81 | self.b_var = tk.IntVar() 82 | 83 | current_rgb = _get_attr(self.phy_monitor, self.property_name) 84 | self.r_var.set(current_rgb[0]) 85 | self.g_var.set(current_rgb[1]) 86 | self.b_var.set(current_rgb[2]) 87 | 88 | # UI Init 89 | self.r_bar = tk.Scale(self, orient=tk.HORIZONTAL, from_=0, to=self.max_value, variable=self.r_var, 90 | length=200, showvalue=1) 91 | self.g_bar = tk.Scale(self, orient=tk.HORIZONTAL, from_=0, to=self.max_value, variable=self.g_var, 92 | length=200, showvalue=1) 93 | self.b_bar = tk.Scale(self, orient=tk.HORIZONTAL, from_=0, to=self.max_value, variable=self.b_var, 94 | length=200, showvalue=1) 95 | 96 | # layout 97 | ttk.Label(self, text='R:').grid(row=0, column=0, sticky='SW') 98 | ttk.Label(self, text='G:').grid(row=1, column=0, sticky='SW') 99 | ttk.Label(self, text='B:').grid(row=2, column=0, sticky='SW') 100 | self.r_bar.grid(row=0, column=1, sticky='SW') 101 | self.g_bar.grid(row=1, column=1, sticky='SW') 102 | self.b_bar.grid(row=2, column=1, sticky='SW') 103 | 104 | self.r_bar.bind('', self.__set_rgb) 105 | self.g_bar.bind('', self.__set_rgb) 106 | self.b_bar.bind('', self.__set_rgb) 107 | 108 | def __set_rgb(self, event): 109 | # get desired RGB 110 | rgb = self.r_var.get(), self.g_var.get(), self.b_var.get() 111 | _set_attr(self.phy_monitor, self.property_name, rgb) 112 | 113 | 114 | class PowerButtonWidget(ttk.Button): 115 | """ 116 | 电源按钮控件 117 | """ 118 | def __init__(self, parent, phy_monitor, property_name: str, value_list: list, **kwargs): 119 | super(PowerButtonWidget, self).__init__(parent, **kwargs) 120 | self.phy_monitor = phy_monitor 121 | self.property_name = property_name 122 | self.value_list = value_list 123 | self.value = tk.StringVar() 124 | 125 | self.value.set(_get_attr(self.phy_monitor, self.property_name)) 126 | self.__current_value_index = self.value_list.index(self.value.get()) 127 | self.configure(textvariable=self.value, command=self.__click_action) 128 | 129 | def __click_action(self): 130 | self.__current_value_index += 1 131 | if self.__current_value_index >= len(self.value_list): 132 | self.__current_value_index = 0 133 | 134 | value = self.value_list[self.__current_value_index] 135 | _set_attr(self.phy_monitor, self.property_name, value) 136 | self.value.set(value) 137 | 138 | 139 | class OptionListWidget(ttk.OptionMenu): 140 | """ 141 | 下拉列表菜单控件 142 | TODO: 跟踪StringVar的变化,会执行相应的命令,但是会执行两次。 143 | 通过比较当前设置来避免设置两次,但是无效的设置仍然会被执行两次。担心EEPROM的寿命 144 | """ 145 | def __init__(self, parent, phy_monitor, property_name: str, options_list: list, **kwargs): 146 | 147 | self.phy_monitor = phy_monitor 148 | self.property_name = property_name 149 | self.options_list = options_list 150 | self.var = tk.StringVar() 151 | self.var.set(_get_attr(self.phy_monitor, self.property_name)) 152 | 153 | super(OptionListWidget, self).__init__(parent, self.var, self.var.get(), *self.options_list, **kwargs) 154 | # trace Change Event. 155 | self.var.trace('w', self.__set_value) 156 | 157 | def __set_value(self, *event): 158 | value = self.var.get() 159 | old_value = _get_attr(self.phy_monitor, self.property_name) 160 | if old_value == value: 161 | logging.info('ignored: update setting: ' + value) 162 | return 163 | _set_attr(self.phy_monitor, self.property_name, value) 164 | 165 | 166 | class MonitorTab(ttk.Frame): 167 | """ 168 | 一个显示器实例的Tab 169 | """ 170 | def __init__(self, parent, phy_monitor, **kwargs): 171 | super(MonitorTab, self).__init__(parent, **kwargs) 172 | self.phy_monitor = phy_monitor 173 | 174 | self.__init_widgets() 175 | self.__init_ui() 176 | 177 | def __init_widgets(self): 178 | """ 179 | initialize UI elements. 180 | :return: 181 | """ 182 | self.model_name = self.phy_monitor.model 183 | self.brightness_bar = PropertySlider(self, self.phy_monitor, 'brightness', self.phy_monitor.brightness_max) 184 | self.contrast_bar = PropertySlider(self, self.phy_monitor, 'contrast', self.phy_monitor.contrast_max) 185 | self.rgb_slider = RGBSlider(self, self.phy_monitor, 'rgb_gain', self.phy_monitor.rgb_gain_max) 186 | self.power_button = PowerButtonWidget(self, self.phy_monitor, 'power_mode', self.phy_monitor.power_mode_list) 187 | 188 | self.color_preset_option = OptionListWidget(self, self.phy_monitor, 189 | 'color_preset', self.phy_monitor.color_preset_list) 190 | self.osd_lang_option = OptionListWidget(self, self.phy_monitor, 191 | 'osd_language', self.phy_monitor.osd_languages_list) 192 | self.input_select_option = OptionListWidget(self, self.phy_monitor, 193 | 'input_src', self.phy_monitor.input_src_list) 194 | 195 | self.reset_factory_button = ttk.Button(self, text="恢复出厂设置", command=self.phy_monitor.reset_factory) 196 | self.auto_setup_button = ttk.Button(self, text="自动调整", command=self.phy_monitor.auto_setup_perform) 197 | 198 | def __init_ui(self): 199 | ttk.Label(self, text='亮度:').grid(row=0, column=0, sticky='SW') 200 | ttk.Label(self, text='对比度:').grid(row=1, column=0, sticky='SW') 201 | ttk.Label(self, text='当前电源状态:').grid(row=3, column=0, sticky='SW') 202 | ttk.Label(self, text='颜色配置文件:').grid(row=4, column=0, sticky='SW') 203 | ttk.Label(self, text='当前OSD语言:').grid(row=5, column=0, sticky='SW') 204 | ttk.Label(self, text='输入信号选择:').grid(row=6, column=0, sticky='SW') 205 | 206 | self.brightness_bar.grid(row=0, column=1, sticky='W') 207 | self.contrast_bar.grid(row=1, column=1, sticky='W') 208 | self.rgb_slider.grid(row=2, column=0, columnspan=2, sticky='WE') 209 | self.power_button.grid(row=3, column=1, sticky='W') 210 | self.color_preset_option.grid(row=4, column=1, sticky='W') 211 | self.osd_lang_option.grid(row=5, column=1, sticky='W') 212 | self.input_select_option.grid(row=6, column=1, sticky='W') 213 | self.auto_setup_button.grid(row=7, column=0, sticky='W') 214 | self.reset_factory_button.grid(row=7, column=1, sticky='E') 215 | 216 | 217 | class TkApp(tk.Tk): 218 | """ 219 | APP 220 | """ 221 | def __init__(self, *args, **kwargs): 222 | super(TkApp, self).__init__(*args, **kwargs) 223 | self.status_text_var = tk.StringVar() 224 | self.status_text_bar = ttk.Label(self, textvariable=self.status_text_var) 225 | self.notebook = ttk.Notebook(self) 226 | 227 | self.__init_ui() 228 | 229 | def __init_ui(self): 230 | self.notebook.grid(row=0, column=0, sticky='NESW') 231 | self.status_text_bar.grid(row=1, column=0, sticky='SW') 232 | self.grid_columnconfigure(0, weight=1) 233 | self.grid_rowconfigure(0, weight=1) 234 | self.geometry('300x400') 235 | 236 | def add_monitors_to_tab(self, phy_monitor_list: list): 237 | """ 238 | 将PhyMonitor对象添加到NoteBook widget 239 | :param phy_monitor_list: 240 | :return: 241 | """ 242 | for pm in phy_monitor_list: 243 | widget = MonitorTab(self.notebook, pm) 244 | self.notebook.add(widget, text=widget.model_name) 245 | self.status_text_var.set('{} monitor(s) found.'.format(len(phy_monitor_list))) 246 | 247 | def add_logfile_button(self, logfile_path: str): 248 | ttk.Button(self, text='查看日志文件', command=lambda: os.system('explorer /select, "{}"'.format(logfile_path)))\ 249 | .grid(row=1, column=0, sticky='SE') 250 | 251 | 252 | if __name__ == '__main__': 253 | # Test Code 254 | import vcp 255 | import threading 256 | 257 | logging.basicConfig(level=logging.DEBUG) 258 | 259 | app = TkApp() 260 | app.title('DDC/CI APP') 261 | app.status_text_var.set('正在检测显示器...') 262 | 263 | def background_task(): 264 | monitors = [] 265 | for i in vcp.enumerate_monitors(): 266 | try: 267 | monitors.append(vcp.PhyMonitor(i)) 268 | except OSError: 269 | pass 270 | app.status_text_var.set(' ') 271 | app.add_monitors_to_tab(monitors) 272 | 273 | threading.Thread(target=background_task, daemon=True).start() 274 | app.mainloop() 275 | -------------------------------------------------------------------------------- /vcp.py: -------------------------------------------------------------------------------- 1 | # coding = utf-8 2 | 3 | import sys 4 | import logging 5 | import ctypes 6 | from ctypes import wintypes 7 | import vcp_code 8 | from typing import Tuple 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | """ 13 | 14 | # Reference 15 | [High-Level Monitor API](https://msdn.microsoft.com/en-us/library/vs/alm/dd692964(v=vs.85).aspx) 16 | [Low-Level Monitor Configuration](https://msdn.microsoft.com/en-us/library/windows/desktop/dd692982(v=vs.85).aspx) 17 | 18 | [Monitor Control Command Set](https://en.wikipedia.org/wiki/Monitor_Control_Command_Set) 19 | https://milek7.pl/ddcbacklight/mccs.pdf 20 | 21 | """ 22 | 23 | 24 | # #################################### Use Windows API to enumerate monitors 25 | def _get_physical_monitors_from_hmonitor(hmonitor: wintypes.HMONITOR) -> list: 26 | """ 27 | Retrieves the physical monitors associated with an HMONITOR monitor handle 28 | 29 | https://msdn.microsoft.com/en-us/library/vs/alm/dd692950(v=vs.85).aspx 30 | BOOL GetPhysicalMonitorsFromHMONITOR( 31 | _In_ HMONITOR hMonitor, 32 | _In_ DWORD dwPhysicalMonitorArraySize, 33 | _Out_ LPPHYSICAL_MONITOR pPhysicalMonitorArray 34 | ); 35 | 36 | Retrieves the number of physical monitors associated with an HMONITOR monitor handle. 37 | Call this function before calling GetPhysicalMonitorsFromHMONITOR. 38 | https://msdn.microsoft.com/en-us/library/dd692948(v=vs.85).aspx 39 | BOOL GetNumberOfPhysicalMonitorsFromHMONITOR( 40 | _In_ HMONITOR hMonitor, 41 | _Out_ LPDWORD pdwNumberOfPhysicalMonitors 42 | ); 43 | 44 | :param hmonitor: 45 | :return: 46 | 47 | """ 48 | class _PhysicalMonitorStructure(ctypes.Structure): 49 | """ 50 | PHYSICAL_MONITOR Structure. 51 | https://msdn.microsoft.com/en-us/library/vs/alm/dd692967(v=vs.85).aspx 52 | typedef struct _PHYSICAL_MONITOR { 53 | HANDLE hPhysicalMonitor; 54 | WCHAR szPhysicalMonitorDescription[PHYSICAL_MONITOR_DESCRIPTION_SIZE]; 55 | } PHYSICAL_MONITOR, *LPPHYSICAL_MONITOR; 56 | 57 | PHYSICAL_MONITOR_DESCRIPTION_SIZE = 128 58 | """ 59 | _fields_ = [ 60 | ("hPhysicalMonitor", wintypes.HANDLE), 61 | ("szPhysicalMonitorDescription", wintypes.WCHAR * 128) 62 | ] 63 | 64 | # Retrieves the number of physical monitors 65 | phy_monitor_number = wintypes.DWORD() 66 | api_call_get_number = ctypes.windll.Dxva2.GetNumberOfPhysicalMonitorsFromHMONITOR 67 | if not api_call_get_number(hmonitor, ctypes.byref(phy_monitor_number)): 68 | _LOGGER.error(ctypes.WinError()) 69 | return [] 70 | 71 | # Retrieves the physical monitors 72 | api_call_get_monitor = ctypes.windll.Dxva2.GetPhysicalMonitorsFromHMONITOR 73 | # create array 74 | phy_monitor_array = (_PhysicalMonitorStructure * phy_monitor_number.value)() 75 | if not api_call_get_monitor(hmonitor, phy_monitor_number, phy_monitor_array): 76 | _LOGGER.error(ctypes.WinError()) 77 | return [] 78 | 79 | return list(phy_monitor_array) 80 | 81 | 82 | def enumerate_monitors() -> list: 83 | """ 84 | enumerate all physical monitor. 85 | ** 请注意防止返回的 Handle 对象被GC! 86 | 87 | https://msdn.microsoft.com/en-us/library/dd162610(v=vs.85).aspx 88 | BOOL EnumDisplayMonitors( 89 | _In_ HDC hdc, 90 | _In_ LPCRECT lprcClip, 91 | _In_ MONITORENUMPROC lpfnEnum, 92 | _In_ LPARAM dwData 93 | ); 94 | 95 | :return: list contains physical monitor handles 96 | """ 97 | all_hmonitor = [] 98 | 99 | # Factory function of EnumDisplayMonitors callback. 100 | # 保持引用以防止被GC ! 101 | # https://msdn.microsoft.com/en-us/library/dd145061(v=vs.85).aspx 102 | _MONITOR_ENUM_PROC = ctypes.WINFUNCTYPE(wintypes.BOOL, 103 | wintypes.HMONITOR, 104 | wintypes.HDC, 105 | ctypes.POINTER(wintypes.LPRECT), 106 | wintypes.LPARAM) 107 | 108 | def __monitor_enum_proc_callback(hmonitor_: wintypes.HMONITOR, hdc, lprect, lparam) -> bool: 109 | """ 110 | EnumDisplayMonitors callback, append HMONITOR to all_hmonitor list. 111 | :param hmonitor_: 112 | :param hdc: 113 | :param lprect: 114 | :param lparam: 115 | :return: 116 | """ 117 | all_hmonitor.append(hmonitor_) 118 | return True 119 | 120 | if not ctypes.windll.user32.EnumDisplayMonitors(None, None, 121 | _MONITOR_ENUM_PROC(__monitor_enum_proc_callback), None): 122 | raise ctypes.WinError() 123 | 124 | # get physical monitor handle 125 | handles = [] 126 | for hmonitor in all_hmonitor: 127 | handles.extend(_get_physical_monitors_from_hmonitor(hmonitor)) 128 | 129 | return handles 130 | 131 | 132 | class PhyMonitor(object): 133 | """ 134 | 一个物理显示器的VCP控制class,封装常用操作. 135 | """ 136 | def __init__(self, phy_monitor): 137 | self._phy_monitor = phy_monitor 138 | self._phy_monitor_handle = self._phy_monitor.hPhysicalMonitor 139 | # VCP Capabilities String 140 | self._caps_string = '' 141 | # Monitor model name 142 | self.model = '' 143 | self.info_display_type = '' 144 | 145 | self._get_monitor_caps() 146 | if self._caps_string != '': 147 | self._get_model_info() 148 | 149 | def _get_monitor_caps(self): 150 | """ 151 | https://msdn.microsoft.com/en-us/library/windows/desktop/dd692938(v=vs.85).aspx 152 | BOOL GetCapabilitiesStringLength( 153 | _In_ HANDLE hMonitor, 154 | _Out_ LPDWORD pdwCapabilitiesStringLengthInCharacters 155 | ); 156 | 157 | https://msdn.microsoft.com/en-us/library/windows/desktop/dd692934(v=vs.85).aspx 158 | BOOL CapabilitiesRequestAndCapabilitiesReply( 159 | _In_ HANDLE hMonitor, 160 | _Out_ LPSTR pszASCIICapabilitiesString, 161 | _In_ DWORD dwCapabilitiesStringLengthInCharacters 162 | ); 163 | :return: 164 | """ 165 | 166 | caps_string_length = wintypes.DWORD() 167 | if not ctypes.windll.Dxva2.GetCapabilitiesStringLength(self._phy_monitor_handle, 168 | ctypes.byref(caps_string_length)): 169 | _LOGGER.error(ctypes.WinError()) 170 | raise ctypes.WinError() 171 | 172 | caps_string = (ctypes.c_char * caps_string_length.value)() 173 | if not ctypes.windll.Dxva2.CapabilitiesRequestAndCapabilitiesReply( 174 | self._phy_monitor_handle, caps_string, caps_string_length): 175 | _LOGGER.error(ctypes.WinError()) 176 | return 177 | 178 | self._caps_string = caps_string.value.decode('ASCII') 179 | 180 | def _get_model_info(self): 181 | """ 182 | analyze caps string 183 | :return: 184 | """ 185 | def find_(src: str, start_: str, end_: str) -> str: 186 | """ 187 | 查找 start_ 和 end_ 之间包围的内容 188 | :param src: 待寻找的支付串 189 | :param start_: 190 | :param end_: 191 | :return: 192 | """ 193 | start_index = src.find(start_) 194 | if start_index == -1: 195 | # not found 196 | return '' 197 | start_index = start_index + len(start_) 198 | 199 | end_index = src.find(end_, start_index) 200 | if end_index == -1: 201 | return '' 202 | return src[start_index:end_index] 203 | 204 | model = find_(self._caps_string, 'model(', ')') 205 | if model == '': 206 | _LOGGER.warning('unable to find model info in vcp caps string: {}'.format(self._caps_string)) 207 | self.model = model 208 | 209 | info_display_type = find_(self._caps_string, 'type(', ')') 210 | if info_display_type == '': 211 | _LOGGER.warning('unable to find display type info in vcp caps string: {}'.format(self._caps_string)) 212 | self.info_display_type = info_display_type 213 | 214 | def close(self): 215 | """ 216 | Close WinAPI Handle. 217 | 218 | https://msdn.microsoft.com/en-us/library/windows/desktop/dd692936(v=vs.85).aspx 219 | BOOL DestroyPhysicalMonitor( 220 | _In_ HANDLE hMonitor 221 | ); 222 | :return: 223 | """ 224 | import ctypes 225 | api_call = ctypes.windll.Dxva2.DestroyPhysicalMonitor 226 | 227 | if not api_call(self._phy_monitor_handle): 228 | _LOGGER.error(ctypes.WinError()) 229 | 230 | # ########################## 发送/读取 VCP 设置的函数 231 | 232 | def send_vcp_code(self, code: int, value: int) -> bool: 233 | """ 234 | send vcp code to monitor. 235 | 236 | https://msdn.microsoft.com/en-us/library/dd692979(v=vs.85).aspx 237 | BOOL SetVCPFeature( 238 | _In_ HANDLE hMonitor, 239 | _In_ BYTE bVCPCode, 240 | _In_ DWORD dwNewValue 241 | ); 242 | 243 | :param code: VCP Code 244 | :param value: Data 245 | :return: Win32 API return 246 | """ 247 | if code is None: 248 | _LOGGER.error('vcp code to send is None. ignored.') 249 | return False 250 | 251 | api_call = ctypes.windll.Dxva2.SetVCPFeature 252 | code = wintypes.BYTE(code) 253 | new_value = wintypes.DWORD(value) 254 | api_call.restype = ctypes.c_bool 255 | ret_ = api_call(self._phy_monitor_handle, code, new_value) 256 | if not ret_: 257 | _LOGGER.error('send vcp command failed: ' + hex(code)) 258 | _LOGGER.error(ctypes.WinError()) 259 | return ret_ 260 | 261 | def read_vcp_code(self, code: int) -> Tuple[int, int]: 262 | """ 263 | send vcp code to monitor, get current value and max value. 264 | 265 | https://msdn.microsoft.com/en-us/library/dd692953(v=vs.85).aspx 266 | BOOL GetVCPFeatureAndVCPFeatureReply( 267 | _In_ HANDLE hMonitor, 268 | _In_ BYTE bVCPCode, 269 | _Out_ LPMC_VCP_CODE_TYPE pvct, 270 | _Out_ LPDWORD pdwCurrentValue, 271 | _Out_ LPDWORD pdwMaximumValue 272 | ); 273 | 274 | :param code: VCP Code 275 | :return: current_value, max_value 276 | """ 277 | if code is None: 278 | _LOGGER.error('vcp code to send is None. ignored.') 279 | return 0, 0 280 | 281 | api_call = ctypes.windll.Dxva2.GetVCPFeatureAndVCPFeatureReply 282 | api_in_vcp_code = wintypes.BYTE(code) 283 | api_out_current_value = wintypes.DWORD() 284 | api_out_max_value = wintypes.DWORD() 285 | 286 | if not api_call(self._phy_monitor_handle, api_in_vcp_code, None, 287 | ctypes.byref(api_out_current_value), ctypes.byref(api_out_max_value)): 288 | _LOGGER.error('get vcp command failed: ' + hex(code)) 289 | _LOGGER.error(ctypes.WinError()) 290 | return api_out_current_value.value, api_out_max_value.value 291 | 292 | def set_vcp_value_by_name(self, vcp_code_key: str, value: int) -> bool: 293 | """ 294 | 根据功能名称发送vcp code和数据 295 | :param vcp_code_key: key name of vcp_code.VCP_CODE dict 296 | :param value: new value 297 | :return: 298 | """ 299 | return self.send_vcp_code(vcp_code.VCP_CODE.get(vcp_code_key), value) 300 | 301 | def get_vcp_value_by_name(self, vcp_code_key: str) -> Tuple[int, int]: 302 | """ 303 | 根据功能名称读取vcp code的值和最大值 304 | :param vcp_code_key: key name of vcp_code.VCP_CODE dict 305 | :return: current_value, max_value 306 | """ 307 | return self.read_vcp_code(vcp_code.VCP_CODE.get(vcp_code_key)) 308 | 309 | # ########################### 经过包装后方便调用的属性/方法 310 | 311 | def reset_factory(self): 312 | """ 313 | Reset monitor to factory defaults 314 | :return: 315 | """ 316 | self.set_vcp_value_by_name('Restore Factory Defaults', 1) 317 | 318 | @property 319 | def color_temperature(self): 320 | increment = self.get_vcp_value_by_name('User Color Temperature Increment')[0] 321 | current = self.get_vcp_value_by_name('User Color Temperature')[0] 322 | return 3000 + current * increment 323 | 324 | @color_temperature.setter 325 | def color_temperature(self, value: int): 326 | increment = self.get_vcp_value_by_name('User Color Temperature Increment')[0] 327 | new_value = (value - 3000) // increment 328 | self.set_vcp_value_by_name('User Color Temperature', new_value) 329 | 330 | @property 331 | def brightness_max(self): 332 | return self.get_vcp_value_by_name('Luminance')[1] 333 | 334 | @property 335 | def brightness(self): 336 | return self.get_vcp_value_by_name('Luminance')[0] 337 | 338 | @brightness.setter 339 | def brightness(self, value): 340 | """ 341 | 设置亮度 342 | :param value: 343 | :return: 344 | """ 345 | brightness_max = self.brightness_max 346 | if value < 0 or value > brightness_max: 347 | _LOGGER.warning('invalid brightness level: {}, allowed: 0-{}'.format( 348 | value, brightness_max)) 349 | return 350 | self.set_vcp_value_by_name('Luminance', value) 351 | 352 | @property 353 | def contrast_max(self): 354 | return self.get_vcp_value_by_name('Contrast')[1] 355 | 356 | @property 357 | def contrast(self): 358 | return self.get_vcp_value_by_name('Contrast')[0] 359 | 360 | @contrast.setter 361 | def contrast(self, value): 362 | contrast_max = self.contrast_max 363 | if value < 0 or value > contrast_max: 364 | _LOGGER.warning('invalid contrast level: {}, allowed: 0-{}'.format( 365 | value, contrast_max)) 366 | return 367 | self.set_vcp_value_by_name('Contrast', value) 368 | 369 | @property 370 | def color_preset_list(self) -> list: 371 | """ 372 | 可用的color preset, 显示器不一定全部支持 373 | :return: 374 | """ 375 | return list(vcp_code.COLOR_PRESET_CODE.keys()) 376 | 377 | @property 378 | def color_preset(self) -> str: 379 | """ 380 | 当前的color preset 381 | :return: 382 | """ 383 | preset = self.get_vcp_value_by_name('Select Color Preset')[0] 384 | for i in list(vcp_code.COLOR_PRESET_CODE.keys()): 385 | if vcp_code.COLOR_PRESET_CODE[i] == preset: 386 | return i 387 | return '' 388 | 389 | @color_preset.setter 390 | def color_preset(self, preset: str): 391 | if preset not in self.color_preset_list: 392 | _LOGGER.warning('invalid color preset: {}, available:{}'.format( 393 | preset, self.color_preset_list)) 394 | return 395 | self.set_vcp_value_by_name('Select Color Preset', vcp_code.COLOR_PRESET_CODE.get(preset)) 396 | 397 | @property 398 | def rgb_gain_max(self): 399 | """ 400 | 最大允许设置的RGB值 401 | ! 只取红色的RGB最大值作为3个颜色的参考 402 | :return: 403 | """ 404 | return self.get_vcp_value_by_name('Video Gain Red')[1] 405 | 406 | @property 407 | def rgb_gain(self) -> Tuple[int, int, int]: 408 | """ 409 | 410 | :return: Red, Green, Blue 411 | """ 412 | rg = self.get_vcp_value_by_name('Video Gain Red')[0] 413 | gg = self.get_vcp_value_by_name('Video Gain Green')[0] 414 | bg = self.get_vcp_value_by_name('Video Gain Blue')[0] 415 | return rg, gg, bg 416 | 417 | @rgb_gain.setter 418 | def rgb_gain(self, value_pack): 419 | max_ = self.rgb_gain_max 420 | 421 | # 检查传入参数 422 | def check_input(value) -> bool: 423 | """ 424 | 检查传入的RGB gain 425 | :param value: 426 | :return: 427 | """ 428 | if value < 0 or value > max_: 429 | _LOGGER.warning('invalid RGB value: {}, allowed: 0-{}'.format( 430 | value, max_)) 431 | return False 432 | return True 433 | try: 434 | rg = value_pack[0] 435 | gg = value_pack[1] 436 | bg = value_pack[2] 437 | except Exception as err: 438 | _LOGGER.error(err) 439 | return 440 | if not (check_input(rg) and check_input(gg) and check_input(bg)): 441 | return 442 | # 设置 RGB Gain 443 | self.set_vcp_value_by_name('Video Gain Red', rg) 444 | self.set_vcp_value_by_name('Video Gain Green', gg) 445 | self.set_vcp_value_by_name('Video Gain Blue', bg) 446 | 447 | def auto_setup_perform(self): 448 | """ 449 | 执行自动调整 450 | :return: 451 | """ 452 | self.set_vcp_value_by_name('Auto Setup', vcp_code.AUTO_SETUP_CODE.get('Manual Perform')) 453 | 454 | @property 455 | def info_poweron_hours(self): 456 | """ 457 | 返回显示器的开机时间 (Hours) 458 | :return: 459 | """ 460 | return self.get_vcp_value_by_name('Display Usage Time')[0] 461 | 462 | @property 463 | def osd_languages_list(self) -> list: 464 | """ 465 | VCP 中指定的语言列表 466 | :return: 467 | """ 468 | return list(vcp_code.OSD_LANG_CODE.keys()) 469 | 470 | @property 471 | def osd_language(self): 472 | language = self.get_vcp_value_by_name('OSD Language')[0] 473 | for i in list(vcp_code.OSD_LANG_CODE.keys()): 474 | if vcp_code.OSD_LANG_CODE[i] == language: 475 | return i 476 | return '' 477 | 478 | @osd_language.setter 479 | def osd_language(self, language: str): 480 | if language not in self.osd_languages_list: 481 | _LOGGER.warning('invalid OSD Language: {}, available:{}'.format( 482 | language, self.osd_languages_list)) 483 | return 484 | self.set_vcp_value_by_name('OSD Language', vcp_code.OSD_LANG_CODE.get(language)) 485 | 486 | @property 487 | def power_mode_list(self) -> list: 488 | """ 489 | VCP 中指定的电源状态 490 | :return: 491 | """ 492 | return list(vcp_code.POWER_MODE_CODE.keys()) 493 | 494 | @property 495 | def power_mode(self): 496 | power_ = self.get_vcp_value_by_name('Power Mode')[0] 497 | for i in list(vcp_code.POWER_MODE_CODE.keys()): 498 | if vcp_code.POWER_MODE_CODE[i] == power_: 499 | return i 500 | # return 'off' to fix quirky, example: when power-off, it return 0x02 501 | return 'off' 502 | 503 | @power_mode.setter 504 | def power_mode(self, mode: str): 505 | if mode not in self.power_mode_list: 506 | _LOGGER.warning('invalid power mode: {}, available:{}'.format( 507 | mode, self.power_mode_list)) 508 | return 509 | self.set_vcp_value_by_name('Power Mode', vcp_code.POWER_MODE_CODE.get(mode)) 510 | 511 | @property 512 | def input_src_list(self) -> list: 513 | """ 514 | VCP 中指定的输入源 515 | :return: 516 | """ 517 | return list(vcp_code.INPUT_SRC_CODE.keys()) 518 | 519 | @property 520 | def input_src(self): 521 | input_ = self.get_vcp_value_by_name('Input Source')[0] 522 | for i in list(vcp_code.INPUT_SRC_CODE.keys()): 523 | if vcp_code.INPUT_SRC_CODE[i] == input_: 524 | return i 525 | return '' 526 | 527 | @input_src.setter 528 | def input_src(self, src: str): 529 | if src not in self.input_src_list: 530 | _LOGGER.warning('invalid input source: {}, available:{}'.format( 531 | src, self.input_src_list)) 532 | return 533 | self.set_vcp_value_by_name('Input Source', vcp_code.INPUT_SRC_CODE.get(src)) 534 | 535 | @property 536 | def info_pannel_type(self) -> str: 537 | pannel_type = self.get_vcp_value_by_name('Flat Panel Sub-Pixel Layout')[0] 538 | return vcp_code.FLAT_PANEL_SUB_PIXEL_LAYOUT_CODE.get(pannel_type, '') 539 | 540 | 541 | if __name__ == '__main__': 542 | # test code 543 | 544 | logging.basicConfig(level=logging.INFO) 545 | 546 | # 检查是否在支持的平台上工作 547 | # os.popen('ver.exe').read() 548 | if sys.platform != 'win32' or sys.version_info.major != 3: 549 | _LOGGER.error('不支持的平台,需要 Windows Vista+, Python 3') 550 | sys.exit(1) 551 | 552 | try: 553 | monitors = enumerate_monitors() 554 | except OSError: 555 | sys.exit(1) 556 | 557 | phy_monitors = [] 558 | for h_monitor in monitors: 559 | try: 560 | monitor = PhyMonitor(h_monitor) 561 | except OSError as os_err: 562 | _LOGGER.error(os_err) 563 | # 忽略这个显示器并继续 564 | continue 565 | _LOGGER.info('found {}'.format(monitor.model)) 566 | phy_monitors.append(monitor) 567 | 568 | test_monitor = phy_monitors[0] 569 | -------------------------------------------------------------------------------- /vcp_code.py: -------------------------------------------------------------------------------- 1 | 2 | VCP_CODE = { 3 | # ######################### Preset Operation ####################### 4 | # 0: ignore, non-zero: reset factory 5 | 6 | # Restore factory defaults for color settings. 7 | 'Restore Factory Color Defaults': 0x08, 8 | 9 | # Restore all factory presets including luminance / contrast, geometry, color and TV defaults. 10 | 'Restore Factory Defaults': 0x04, 11 | 12 | # Restore factory defaults for geometry adjustments. 13 | 'Restore Factory Geometry Defaults': 0x06, 14 | 15 | # Restores factory defaults for luminance and contrast adjustments. 16 | 'Restore Factory Luminance / Contrast Defaults': 0x05, 17 | 18 | # Restore factory defaults for TV functions. 19 | 'Restore Factory TV Defaults': 0x0A, 20 | 21 | # Store/Restore the user saved values for current mode. 22 | # Byte: SL, 0x01: store current setting, 0x02: Restore factory defaults for current mode. 23 | 'Save / Restore Settings': 0xB0, 24 | 25 | 'VCP Code Page': 0x00, 26 | 27 | # ###################### Image Adjustment VCP Codes ############ 28 | # RO, 29 | 'User Color Temperature Increment': 0x0B, 30 | 31 | # RW, read: 3000K + 'User Color Temperature Increment' * 'User Color Temperature' 32 | 'User Color Temperature': 0x0C, 33 | 34 | # RW, control brightness level 35 | 'Luminance': 0x10, 36 | 37 | # RW, video sampling clock frequency 38 | 'Clock': 0x0E, 39 | 40 | 'Flesh Tone Enhancement': 0x11, 41 | 42 | # Contrast of the image 43 | 'Contrast': 0x12, 44 | 45 | # deprecated, It must NOT be implemented in new designs! 46 | # 'Backlight Control': 0x13, 47 | 48 | # ref: COLOR_PRESET_* 49 | # read: current color preset 50 | 'Select Color Preset': 0x14, 51 | 52 | # RGB: Red, test: 0k 53 | 'Video Gain Red': 0x16, 54 | # RGB:Green, test: ok 55 | 'Video Gain Green': 0x18, 56 | # RGB: Blue, test: ok 57 | 'Video Gain Blue': 0x1A, 58 | 59 | # 'User Color Vision Compensation': 0x17, 60 | # 'Focus': 0x1C, 61 | 62 | # send: AUTO_SETUP_* 63 | 'Auto Setup': 0x1E, 64 | 65 | # send: AUTO_SETUP_* 66 | 'Auto Color Setup': 0x1F, 67 | 68 | 'Gray Scale Expansion': 0x2E, 69 | 70 | # test: ok, 但不知道做什么 71 | 'Video Black Level: Red': 0x6C, 72 | 'Video Black Level: Green': 0x6E, 73 | 'Video Black Level: Blue': 0x70, 74 | 75 | 'Gamma': 0x72, 76 | 77 | 'Adjust Zoom': 0x7C, 78 | 'Sharpness': 0x87, 79 | 80 | # ############################# Display Control VCP Code Cross-Reference 81 | # unit: Hour 82 | 'Display Usage Time': 0xC0, 83 | 'Display Controller ID': 0xC8, 84 | 'Display Firmware Level': 0xC9, 85 | 86 | # see OSD_LANG_CODE 87 | 'OSD Language': 0xCC, 88 | 89 | # 90 | 'Power Mode': 0xD6, 91 | # two bytes: H: MCCS version number, L: MCCS revision number 92 | 'VCP Version': 0xDF, 93 | 94 | # ######################## Geometry VCP Codes 95 | 'Bottom Corner Flare': 0x4A, 96 | 'Bottom Corner Hook': 0x4C, 97 | 'Display Scaling': 0x86, 98 | 'Horizontal Convergence M / G': 0x29, 99 | 'Horizontal Convergence R / B': 0x28, 100 | 'Horizontal Keystone': 0x42, 101 | 'Horizontal linearity': 0x2A, 102 | 'Horizontal Linearity Balance': 0x2C, 103 | 'Horizontal Mirror (Flip)': 0x82, 104 | 'Horizontal Parallelogram': 0x40, 105 | 'Horizontal Pincushion': 0x24, 106 | 'Horizontal Pincushion Balance': 0x26, 107 | 'Horizontal Position (Phase)': 0x20, 108 | 'Horizontal Size': 0x22, 109 | 'Rotation': 0x44, 110 | 'Scan Mode': 0xDA, 111 | 'Top Corner Flare': 0x46, 112 | 'Top Corner Hook': 0x48, 113 | 'Vertical Convergence M / G': 0x39, 114 | 'Vertical Convergence R / B': 0x38, 115 | 'Vertical Keystone': 0x43, 116 | 'Vertical Linearity': 0x3A, 117 | 'Vertical Linearity Balance': 0x3C, 118 | 'Vertical Mirror (Flip)': 0x84, 119 | 'Vertical Parallelogram': 0x41, 120 | 'Vertical Pincushion': 0x34, 121 | 'Vertical Pincushion Balance': 0x36, 122 | 'Vertical Position (Phase)': 0x30, 123 | 'Vertical Size': 0x32, 124 | 'Window Position (BR_X)': 0x97, 125 | 'Window Position (BR_Y)': 0x98, 126 | 'Window Position (TL_X)': 0x95, 127 | 'Window Position (TL_Y)': 0x96, 128 | 129 | # ############### Miscellaneous Functions VCP Codes 130 | 'Active Control': 0x52, 131 | 132 | # 0x01: disable, 0x02: enable 133 | 'Ambient Light Sensor': 0x66, 134 | 'Application Enable Key': 0xC6, 135 | 'Asset Tag': 0xD2, 136 | 'Auxiliary Display Data ': 0xCF, 137 | 'Auxiliary Display Size': 0xCE, 138 | 'Auxiliary Power Output': 0xD7, 139 | # only for CRT, >= 0x01 perform degauss 140 | 'Degauss': 0x01, 141 | 'Display Descriptor Length': 0xC2, 142 | 'Display Identification Data Operation': 0x78, 143 | # DISPLAY_TECH_TYPE 144 | 'Display Technology Type': 0xB6, 145 | 'Enable Display of ‘Display Descriptor’': 0xC4, 146 | # FLAT_PANEL_SUB_PIXEL_LAYOUT_CODE 147 | 'Flat Panel Sub-Pixel Layout': 0xB2, 148 | # Input source control 149 | 'Input Source': 0x60, 150 | 'New Control Value': 0x02, 151 | 'Output Select': 0xD0, 152 | 'Performance Preservation': 0x54, 153 | 'Remote Procedure Call': 0x76, 154 | 'Scratch Pad': 0xDE, 155 | 'Soft Controls': 0x03, 156 | 'Status Indicators (Host)': 0xCD, 157 | 'Transmit Display Descriptor': 0xC3, 158 | 'TV-Channel Up / Down': 0x8B, 159 | 160 | # ############################### Audio Function VCP Code Cross-reference 161 | # Control Volume 162 | 'Audio: Balance L/R': 0x93, 163 | 'Audio: Bass': 0x91, 164 | 'Audio: Jack Connection Status': 0x65, 165 | 'Audio: Microphone Volume': 0x64, 166 | 'Audio: Mute (screen blank)': 0x8D, 167 | 'Audio: Processor Mode': 0x94, 168 | 'Audio: Speaker Select': 0x63, 169 | 'Audio: Speaker Volume': 0x62, 170 | 'Audio: Treble': 0x8F, 171 | 172 | # ############################### DPVL Support Cross-reference 173 | 174 | } 175 | 176 | 177 | # 0x14, Select Color Preset 178 | # 显示器不一定支持所有模式 179 | COLOR_PRESET_CODE = { 180 | 'sRGB': 0x01, 181 | 'Display Native': 0x02, 182 | '4000K': 0x03, 183 | '5000K': 0x04, 184 | '6500K': 0x05, 185 | '7500K': 0x06, 186 | '8200K': 0x07, 187 | '9300K': 0x08, 188 | '10000K': 0x09, 189 | '11500K': 0x0A, 190 | 'User Mode 1': 0x0B, 191 | 'User Mode 2': 0x0C, 192 | 'User Mode 3': 0x0D 193 | } 194 | 195 | AUTO_SETUP_CODE = { 196 | 'off': 0x00, 197 | 'Manual Perform': 0x01, 198 | 'Continuous': 0x02 199 | } 200 | 201 | POWER_MODE_CODE = { 202 | 'on': 0x01, 203 | # 相当于按电源键待机 204 | 'off': 0x05, 205 | } 206 | 207 | # OSD 菜单语言列表 208 | OSD_LANG_CODE = { 209 | 'Reserved/ignored': 0x00, 210 | 'Chinese-traditional': 0x01, 211 | 'English': 0x02, 212 | 'French': 0x03, 213 | 'German': 0x04, 214 | 'Italian': 0x05, 215 | 'Japanese': 0x06, 216 | 'Korean': 0x07, 217 | 'Portuguese-Portugal': 0x08, 218 | 'Russian': 0x09, 219 | 'Spanish': 0x0A, 220 | 'Swedish': 0x0B, 221 | 'Turkish': 0x0C, 222 | 'Chinese-simplified': 0x0D, 223 | 'Portuguese-Brazil': 0x0E, 224 | 'Arabic': 0x0F, 225 | 'Bulgarian': 0x10, 226 | 'Croatian': 0x11, 227 | 'Czech': 0x12, 228 | 'Danish': 0x13, 229 | 'Dutch': 0x14, 230 | 'Estonian': 0x15, 231 | 'Finnish': 0x16, 232 | 'Greek': 0x17, 233 | 'Hebrew': 0x18, 234 | 'Hindi': 0x19, 235 | 'Hungarian': 0x1A, 236 | 'Latvian': 0x1B, 237 | 'Lithuanian': 0x1C, 238 | 'Norwegian': 0x1D, 239 | 'Polish': 0x1E, 240 | 'Romanian': 0x1F, 241 | 'Serbian': 0x20, 242 | 'Slovak': 0x21, 243 | 'Slovenian': 0x22, 244 | 'Thai': 0x23, 245 | 'Ukrainian': 0x24, 246 | 'Vietnamese': 0x25 247 | } 248 | 249 | # 输入源设置 250 | INPUT_SRC_CODE = { 251 | 'Analog video (R/G/B) 1': 0x01, 252 | 'Analog video (R/G/B) 2': 0x02, 253 | 'Digital video (TMDS) 1 DVI 1': 0x03, 254 | 'Digital video (TMDS) 2 DVI 2': 0x04, 255 | 'Composite video 1': 0x05, 256 | 'Composite video 2': 0x06, 257 | 'S-video 1': 0x07, 258 | 'S-video 2': 0x08, 259 | 'Tuner 1': 0x09, 260 | 'Tuner 2': 0x0A, 261 | 'Tuner 3': 0x0B, 262 | 'Component video (YPbPr / YCbCr) 1': 0x0C, 263 | 'Component video (YPbPr / YCbCr) 2': 0x0D, 264 | 'Component video (YPbPr / YCbCr) 3': 0x0E, 265 | 'DisplayPort 1': 0x0F, 266 | 'DisplayPort 2': 0x10, 267 | 'Digital Video (TMDS) 3 HDMI 1': 0x11, 268 | 'Digital Video (TMDS) 4 HDMI 2': 0x12 269 | } 270 | 271 | # 面板子像素排列方式 272 | FLAT_PANEL_SUB_PIXEL_LAYOUT_CODE = { 273 | 0x00: 'Sub-pixel layout is not defined', 274 | 0x01: 'Red / Green / Blue vertical stripe', 275 | 0x02: 'Red / Green / Blue horizontal stripe', 276 | 0x03: 'Blue / Green / Red vertical stripe', 277 | 0x04: 'Blue/ Green / Red horizontal stripe', 278 | 0x05: 'Quad-pixel, a 2 x 2 sub-pixel structure with red at top left, blue at bottom right and green at top right and bottom left', 279 | 0x06: 'Quad-pixel, a 2 x 2 sub-pixel structure with red at bottom left, blue at top right and green at top left and bottom right', 280 | 0x07: 'Delta (triad)', 281 | 0x08: 'Mosaic with interleaved sub-pixels of different colors' 282 | } 283 | --------------------------------------------------------------------------------