У меня есть склонность к реализации глупых и/или бессмысленных проектов. Перед вами один из них, который появился в результате беседы, закончившейся словами: «Слушай, а ведь технически, возможно…», — не вопрос, давай сделаем.
DDC (канал данных дисплея) – это протокол для считывания информации о том, какие разрешения и в целом параметры поддерживает монитор. Позднее он был расширен до версии DDC/CI, которая позволяет настраивать яркость и прочие атрибуты, но суть начальной идеи заключалась в установке на каждое устройство дешёвой EEPROM с интерфейсом I2C, на которой бы хранилась некая базовая информация. (Технически изначальная идея была даже проще, но мы не станем заострять на этом внимание).
Внедрение этой технологии началось ещё во времена VGA, но она настолько закрепилась, что поддерживается даже в современном оборудовании с HDMI и DisplayPort. Всё верно – в HDMI-кабеле среди высокоскоростных дифференциальных пар скрывается чрезвычайно медленная шина I2C.
В крохотных OLED-дисплеях с точечной матрицей зачастую присутствует I2C-контроллер, поэтому у меня возникла идея подключить такой дисплей прямиком в порт HDMI. Смешно, не так ли? Давайте пробовать.
Схема соединения
Я обрезал нерабочий HDMI-кабель и отыскал интересующие нас контакты: SCL, SDA, 5V, DDC-GND и HPD (обнаружение активного соединения). Распиновку я нашёл в Google:
На этой схеме показан разъём HDMI. При подключении к контактам проводов его нужно развернуть слева направо.
Номер вывода HDMI | Сигнал |
---|---|
1 | TMDS Date 2+ |
2 | TMDS Data 2 shield |
3 | TMDS Data 2- |
4 | TMDS Data 1+ |
5 | TMDS Data 1 shield |
6 | TMDS Data 1- |
7 | TMDS Data 0+ |
8 | TMDS Data 0 shield |
9 | TMDS Data 0- |
10 | TMDS Clock+ |
11 | TMDS Clock shield |
12 | TMDS Clock- |
13 | CEC |
14 | HEC Data- |
15 | SCL (Serial Clock for DDC |
16 | SDA (Serial Data Line for DDC |
17 | DDC / CEC / HEC Ground |
18 | +5 V Power (50 mA max) |
19 | Hot Plug Detect (1.3) / HEC Data+ (1.4) |
Когда дело доходит до переделки оборудования, обычно я стараюсь придерживаться наиболее безопасных вариантов – никто не хочет наблюдать синий дым, особенно если речь идёт о дорогой плате. Однако сегодня я решил испытать судьбу и собираюсь припаять этот дисплей прямо к распотрошённому кабелю HDMI, подключённому к моему относительно новому ноутбуку. Какой накал страстей! Если я напортачу, то этот дурацкий эксперимент мне дорого обойдётся.
Для скачивания спецификации HDMI необходимо зарегистрироваться, что никак не вписывается в мои сжатые планы реализации, но название вывода Hot Plug Detect (обнаружение активного соединения) довольно описательно. Я предположил, что для подачи сигнала о подключении кабеля этот контакт нужно подтянуть либо вверх, либо вниз. Установка резистора 20кОм на линию 5В решила эту задачу. С помощью осциллографа теперь можно регистрировать активность на линиях SCL/SDA, когда кабель подключён к ноутбуку.
После этого я жирно припаял к разъёму гребёнки четыре интересующие нас линии. Для данного эксперимента я заказал пару OLED-экранов, которые оснащены контроллерами SSD1306 и поставляются на печатных платах с четырехконтактной гребёнкой.
I2C и SMBus
В Linux можно организовать доступ к устройствам I2C, загрузив модуль i2c-dev
(modprobe i2c-dev
), который отобразит в /dev/i2c-*
кучу устройств I2C. Мой ноутбук показывает девять таких устройств.
Некоторые из них на деле относятся к протоколу SMBus, основанному на I2C. Вроде как это тот же I2C, но с кучей дополнительных ограничений вроде ограничения транзакции размером 32 байта.
Также желательно установить пакет i2c-tools
, который поставляется с утилитой i2cdetect
и устанавливает для групповых разрешений правило udev
. Для доступа к устройствам I2C без sudo
добавьте себя в группу I2C (sudo usermod -G i2c -a username
) и снова авторизуйтесь, чтобы изменения вступили в силу. Мне также пришлось выполнить для этого udevadm trigger
. Может показаться, что проще выполнить перезагрузку, но так делать ни в коем случае не надо.
Имейте ввиду: именуются устройства I2C не последовательно. Я узнал, что линией HDMI DDC, к которой я припаялся, была
/dev/i2c-3
, но после выгрузки и повторной загрузки модуля ей стала уже/dev/i2c-4
. Здесь нужно быть очень внимательным, так как запись (или даже считывание) из неверного устройства I2C может запросто привести к порче оборудования ноутбука.
Я установил ещё один пакет, ddcutil
, только чтобы иметь возможность выполнять ddcutil detect
. Эта команда выводит список дисплеев и связанную с ними шину I2C. Также можно выполнить i2cdetect -l
, которая выведет список устройств I2C с их описанием. В моём случае три линии I2C содержали в своём описании i915 gmbus
, где i915 – это графический драйвер Intel. Однако проще всего такие вещи выяснять всё же с помощью ddcutil
.
Первые тесты
Осциллограф показал, что линии SCL/SDA уже подтянуты к верхнему уровню, значит, у нас должно получиться подключить экран без использования дополнительного оборудования. Очевидно, что линия 5В на порту HDMI может подавать до 50мА, значит, нам даже не потребуется блок питания. Чудесно!
i2cdetect
может сканировать шину I2C на предмет подключённых устройств. Как и ожидалось, без подключённого кабеля она ничего не обнаружила. Однако, когда я подключил свой распотрошённый провод с впаянным резистором, отобразилось очень много ответов. Я не совсем понимаю, что здесь происходит, но важно то, что когда я подключил дисплей, по адресу 0x3c
появилось дополнительное устройство.
Самый быстрый способ взаимодействия с дисплеем – это скрипт Python. Библиотека SMBus позволяет всё ловко наладить.
import smbus
bus = smbus.SMBus(4) # для /dev/i2c-4
i2caddr = 0x3c
bus.write_i2c_block_data(i2caddr, 0, [0xaf] ) # включает дисплей
Прежде чем мы сможем что-либо вывести на дисплей, нужно выполнить кучу команд, в том числе активировать генератор подкачки заряда. Обратите внимание, что спецификация SSD1306, по крайней мере, найденная мной копия, в своём конце содержит указания по применению, которые объясняют процесс инициализации яснее, чем основной документ (некоторые команды в таблице отсутствуют). Как обычно, самый быстрый способ начать работу – это посмотреть исходный код существующих библиотек, поэтому я нашёл чей-то пакет для SSD1306 и скопировал оттуда команды инициализации. Дисплей ожил!
Я также нашёл скрипт для отрисовки текста на SSD1306, куда сразу встроил код для SMBus. Всё прошло успешно!
Никакого микроконтроллера или прочего оборудования, только SSD1306, подключённый прямо в HDMI-порт. Для меня это выглядит весьма удовлетворительно.
Вывод на дисплей данных
Придерживаясь пока что скрипта Python, я хочу иметь возможность брать изображение 128х64 и передавать его на дисплей. Применяемая мной утилита отрисовки текста с помощью команд SSD1306 управляет адресом столбца и страницы, куда записываются данные, благодаря чему один символ можно отрисовывать без влияния на остальную часть дисплея (отсюда и неинициализированные фоновые пиксели на изображении выше).
Здесь доступно очень много режимов адресации памяти, сдобренных мутной терминологией. SEG или COL – это координата X, COM – это координата Y, но группируются они по страницам. В спецификации приводятся кое-какие схемы.
Сам дисплей монохромный, каждая страница состоит из 8 строк (COM), и когда мы передаём данные на дисплей, каждый байт занимает одну страницу, один столбец пикселей. Было бы логичнее настроить дисплей в режим вертикальной адресации, чтобы биты располагались по порядку, но всё же быстрее оказалось просто перетасовать биты на нашей стороне.
С помощью библиотеки PIL можно преобразовать изображение в монохромное, используя команду .convert(1)
, после чего сериализовать его командой .tobytes()
. В результате каждый байт будет представлять 8 горизонтальных пикселей, но нам нужно, чтобы он представлял 8 вертикальных. Вместо реализации утомительной побитовой логики, проще будет исправить это, повернув изображение перед сериализацией на 90 градусов, а затем загрузив полученные байты в матрицу NumPy и транспонировав её. Такой метод либо заработает идеально с первого раза, либо выведет полную чушь. В последнем случае нужно будет изменять порядок операций, пока всё не заработает. На деле гораздо проще, чем в теории.
Как я говорил, SMBus не позволит отправлять более 32 байт за раз, несмотря на то, что это простое устройство I2C. Этот нюанс можно обойти, обратившись к устройству напрямую из скрипта Python. Хитрость в том, чтобы использовать ioctl
для настройки адреса слэйва. В заголовочном файле ядра i2c-dev.h
есть определения констант, среди которых нас интересует лишь I2C_SLAVE
.
import io, fcntl
dev = "/dev/i2c-4"
I2C_SLAVE=0x0703 # из i2c-dev.h
i2caddr = 0x3c
bus = io.open(dev, "wb", buffering=0)
fcntl.ioctl(bus, I2C_SLAVE, i2caddr)
bus.write(bytearray([0x00, 0xaf]))
Поочерёдно отправляя 1024 байта нулей или 0xFF
, я смог оценить, насколько быстро при этом обновлялся дисплей. Быстрее всего обновление происходило при одновременной отправке 256 байт. Не уверен, является ли это ограничением оборудования I2C (может, есть какой-то дополнительный уровень буферизации?)
В таких условиях я смог добиться скорости в районе 5-10 кадров в секунду (против 2 кадров, которыми ограничивается SMBus). Думаю, что DDC работает на 100кГц, но всё равно он превосходит те возможности, ради которых создавался.
Превращение в монитор
Можно просто заставить приложение делать отрисовку прямо на этом экране, но меня такой вариант не устроит – хочу, чтобы это был монитор.
(Я даже не уверен, что у нас тут конкретно за приложение, но это не играет роли. Хочу, чтобы у меня получился монитор!)
Можно также написать собственный видеодрайвер. Несмотря на богатый обучающий потенциал, это бы означало невероятный объём усилий, а я планирую закончить сегодня вечером.
В природе существует множество dummy-видеодрайверов, которые используются в консольных машинах для активации VNC и прочего. В нашем случае должен сгодиться xserver-xorg-video-dummy
, но есть у меня неприятное чувство, что он не сработает как надо, поскольку у нас также присутствуют и реальные выходы на дисплей. Есть ещё Xvfb
, виртуальный буфер кадров, но он тоже не особо сгодиться, если мы хотим расширить на него рабочий стол.
Так как я использую xorg
, то наиболее правильным способом имитировать монитор, не тратя на это несколько дней, будет xrandr
.
xrandr
– это и библиотека, и утилита командной строки пользовательского пространства.
На знакомство с терминологией этого инструмента у меня ушло немало времени, так как объясняется она не лучшим образом.
framebuffer
– это весь рабочий стол, то есть то, что сохраняется во время скриншота.output
– это физический видеовыход.monitor
– это виртуальная область, которая обычно отображается на часть или весь буфер кадров и, как правило, соответствует одному выходу. Если окно развернуть, то оно заполнит весь монитор.- Однако можно настроить один выход на дисплей под несколько мониторов. Например, чтобы разделить широкий экран на два монитора.
- И наоборот, несколько выходов могут относиться к одному монитору, то есть несколько физических экранов могут рассматриваться как один. В таком случае развёрнутое окно охватит их все.
mode
– это формат видео, состоящий из как минимум ширины, высоты и частоты кадров. В частности, здесь используются моделайны VESA CVT, которые можно генерировать с помощью утилитыcvt
.addmode
иdelmode
вxrandr
служат для связывания имеющегося режима с выходом на дисплей.newmode
иrmmode
вxrandr
служат для добавления на сервер нового режима, который затем можно связать с выходом.
Обратите внимание, что этот список относится исключительно к xrandr
. В других контекстах Linux термины output
, display
, monitor
и screen
используются иначе.
На моём ноутбуке вызов xrandr
показывает пять видеовыходов: eDP-1, являющийся основным экраном с огромным множеством доступных режимов, и четыре отключённых (HDMI-1, HDMI-2, DP-1, DP-2), три из которых предположительно доступны через Thunderbolt или другие интерфейсы.
Имитация монитора, попытка 1
Если подумать, то лучше всего для этого будет убедить xrandr
, что один из неиспользуемых видеовыходов подключён. Для инструментов вроде VNC есть целый рынок «заглушек», которые заставляют видеокарту думать, что монитор подключён. Естественно, нам этого делать не нужно, а нужно заставить xrandr
вести себя должным образом программно.
Для того чтобы вывести наше аномально низкое разрешение 128х64 по HDMI, в теории сначала нужно сгенерировать моделайн CVT:
$ cvt 128 64
# 128x64 39.06 Hz (CVT) hsync: 3.12 kHz; pclk: 0.50 MHz
Modeline "128x64_60.00" 0.50 128 136 144 160 64 67 77 80 -hsync +vsync
Затем этот режим добавить на сервер X:
$ xrandr --newmode "128x64_60.00" 0.50 128 136 144 160 64 67 77 80 -hsync +vsync
На этом этапе xrandr
отображает в конце своего вывода неиспользуемые режимы. Здесь может показаться, будто режим является частью последнего выхода из перечисленных, но это не так (пока). Вот теперь мы добавляем этот режим к одному из выходов:
xrandr --addmode HDMI-1 128x64_60.00
И пробуем использовать:
xrandr --output HDMI-1 --mode 128x64_60.00 --right-of eDP-1
Добавлю, что у меня на ноутбуке есть горячая клавиша, которой я переключаю режимы дисплея, поэтому я вполне могу пробовать здесь всё что угодно. В противном же случае есть вероятность, что вы ничего не увидите. Хотя всё равно должна быть возможность получить доступ к другим виртуальным терминалам через Ctrl+Alt+F2 и так далее, поскольку они настраивают дисплей при помощи KMS (механизма смены видеорежимов средствами ядра), который расположен на уровень ниже сервера X.
Я попробовал использовать и HDMI-1, и HDMI-2. Оба выводятся в списке как отключённые. Наш кабель, подключённый к HDMI-1, подтягивает контакт Hot Plug Detect вверх, но на стандартные запросы DDC не отвечает.
Возможно, я использовал не все возможности, но добиться результата так и не смог. Подозреваю, что видеодрайвер просто не может справиться с этим смехотворным разрешением, а моделайн – это просто мусор. Частота 39.06Гц определённо меня заинтересовала, но когда я снова попробовал установить конкретно это значение, ничего не получилось.
Честно говоря, подобное издевательство над видеовыходами в любом случае кажется плохим решением.
Чтобы зачистить весь этот беспорядок, сначала отвяжите режимы от любых выходов командой --delmode
, а затем командой --rmmode
удалите их с сервера X.
Имитация монитора, попытка 2
Когда вы изменяете настройки дисплея, xrandr
обычно выставляет все связанные параметры автоматом, но если углубиться, то их можно подстроить и вручную. Как пишут в интернете, для создания виртуального монитора можно просто расширить буфер кадров и определить этот монитор в нём, не заморачиваясь связыванием его с выходом.
Интересно, что если сделать буфер кадров больше необходимого, то по умолчанию при приближении курсора мыши к краю он будет автоматически панорамироваться. Это полезно знать, но здесь нам необходимо такую возможность исключить. Опция --panning
получает до двенадцати параметров: для panning area
(области панорамирования), tracking area
(области отслеживания) и border
(границ).
Область отслеживания – это та, которой ограничено перемещение курсора мыши. Как правило, для панорамирования, отслеживания и буфера кадров устанавливается одинаковый размер. Я не уверен, что именно представляет в данном контексте «граница», так как при корректировке этого параметра никаких изменений не заметил.
При установке панорамирования на 0x0
оно отключается, но это также ограничивает и область отслеживания, так что мышь новой части буфера кадров достичь не сможет. Вместо этого, мы ограничим панорамирование до размера основного монитора, по сути, его отключив, и расширим область отслеживания, добавив наш новый участок буфера кадров. Вот вся соответствующая команда:
xrandr --fb 2048x1080 --output eDP-1 --panning 1920x1080/2048x1080
Затем можно определить новый монитор, который будет находиться в этом новом участке буфера:
xrandr --setmonitor virtual 128/22x64/11+1920+0 none
Размер выставляется в пикселях и мм. Предполагаю, что это где-то 22х11мм. virtual
– это имя нового монитора, его можно назвать как угодно. none
– это выход. Просмотреть мониторы можно командой xrandr --listmonitors
, а впоследствии удалить всё это безобразие командой xrandr --delmonitor virtual
.
Теперь я могу указать скрипту выводить эту часть буфера кадров на OLED-экран. Ура! Правда, есть у этого метода один нюанс – отслеживание здесь не L-образное, и мышь может попадать в полосу буфера, которая не соответствует ни одному монитору. Не знаю, есть ли для этого какое-то простое решение, но если уж будет очень напрягать, то можно установить допустимые положения курсора в скрипте через Xlib
.
Считывание буфера кадров
Я предполагал, что на этом этапе мне придётся выбросить свой скрипт Python, но есть библиотека python-xlib
, которая даёт доступ почти ко всему, что нам нужно. Немного напрягает отсутствие какой-либо документации к ней, да и имена методов не совпадают, к примеру XGetImage
— теперь называется root.get_image
.
Любопытный факт. Знаете ли вы, что курсор мыши отображается аппаратно? Думаю, в этом есть смысл. Это также объясняет, почему он обычно не попадает на скриншоты. Но нам нужно захватывать буфер кадров вместе с курсором поверх него, так что здесь потребуется намного больше работы.
Получить изображение курсора, как правило, можно с помощью XFixesCursorImage
, но в python-xlib
ещё не реализовали все возможности из XFixes
. Я уже был готов начать заново в Си, но обнаружил, что кто-то всю эту работу проделал до меня, реализовав привязывание к X11/XFixes
при помощи ctypes
специально для получения информации о курсоре.
Теперь у нас есть всё необходимое для захвата изображения нового виртуального монитора, наложения курсора в нужном месте (не забывая об xhot
и yhot
, смещения изображения указателя/курсора), преобразования результата в монохромное изображение с необходимой перетасовкой бит и непрерывного вывода этого изображения на дисплей.
Это четвёртое рабочее пространство i3 с отсутствующим i3status
и странным искажением в верхней части фона. Красота!
Демо
Итоги
Для повышения частоты кадров можно доработать скрипт, чтобы вместо полной перерисовки изображения для каждого кадра отправлять только его изменения. Но как бы фантастически это ни звучало, учитывая абсолютную бесполезность этого второго крохотного экрана, реализовывать подобную затею я не собираюсь.
Если вы вдруг по какой-то безумной причине решите повторить это сами, то скрипт лежит на GitHub.
Автор: Дмитрий Брайт