Зачастую JavaScript ассоциируют с формочками в браузере, а C++ с железом и суровыми оптимизациями. Но что, если не противопоставлять «формочки» и «железо», а объединить?


На недавней конференции HolyJS Никита Дубко показал, как с помощью WebHIDf можно подключаться из браузера к самым разным пользовательским устройствам. Вероятно, для «железячников» многое сказанное в докладе очевидно — зато для JS-разработчиков это, наоборот, возможность узнать что-то совершенно новое. Доклад понравился участникам, и теперь мы сделали для Хабра текстовую версию. Далее повествование идёт от лица спикера.



Оглавление



О себе


Я веб-разработчик со стажем более 10 лет. Немножко подкастер, возможно, вы слышали подкаст «Веб-стандарты». И я по четвергам (не всегда по четвергам) играю в Dungeons & Dragons с друзьями, на выходных сплавляюсь на байдарках… и иногда играю на барабанах и пианино — вы не поверите, но для доклада это важно! Оно, по крайней мере, пригодится.


А ещё у меня есть сайт с другими моими докладами и текстами.


Как всё началось…


Бывает, заходишь в какую-нибудь соцсеть, и начинается: «Вот этот ваш фронтенд, вы там просто кнопки красите, пиксели двигаете! Вообще у вас даже там XML нету, о чём с вами говорить? Джейсонами программируете… И к реальным устройствам у вас доступа нету! Вот, мы — трушные программисты, мы там можем через драйверы обращаться к реальным устройствам! Вот мы — программисты, а вы там, во фронтенде…»


И вот, я когда такое вижу, у меня немножечко подгорает, потому что, а правда ли мы не можем подключаться к реальным устройствам из браузера? И вот, я решил немножечко поковыряться в этой теме, и оказалось, ну — спойлеры…


Где-то в C++


Немножечко про C++, вы же пришли на конференцию HolyJS, поэтому здесь про C++.


HID


Есть такой старый «способ общаться» — HID (Human Interface Devices). Это не только про C++, можно и на ассемблере это делать, драйвера можете писать на чём хотите.


HID — это тип устройств и описание протоколов работы (спецификация) с такими устройствами, которые являются прослойкой между человеком и компьютером. Скажем, человек хочет что-то напечатать на клавиатуре — есть протокол, который позволяет считывать, что клавиатура делает. Используете мышку — компьютер тоже должен понимать, как вы ей пользуетесь. Или, например, вы подключили к лэптопу геймпад и хотите во что-то играть.


Всё это делается через протокол HID, он древний, в нём есть USB- и Bluetooth-устройства. Мы сегодня поговорим только про USB, потому что про Bluetooth там отдельная история, но в целом она не сильно сложнее. Мне просто очень хочется в ноутбук свой позасовывать сегодня что-нибудь. Можете увидеть, у меня тут рядом с ноутбуком стоят «спойлеры».


Драйверы


И вам нужно знать про такую штуку, как драйверы. Когда вы работаете с устройствами, вы на самом деле не работаете с ними напрямую. Как правило, вы из своего приложения пользуетесь в операционной системе какой-то библиотекой (драйвером), которая позволяет работать с вашим реальным устройством уже напрямую при помощи каких-то интересных сигналов.



Как это обычно происходит? Вы из приложения вызываете что-то в операционной системе. В Windows для этого есть DLL или могут быть свои самописные драйвера, в Linux — другие способы, но суть в том, что вы в операционной системе что-то дёргаете, а она уже как-то общается с реальным устройством. Но в принципе вы можете для операционной системы сами это написать.


Реальное устройство что-то возвращает операционной системе — как правило, такой сырой набор байтов. Драйвер превращает это в какие-то более-менее понятные данные и возвращает вашему устройству уже что-то обработанное.


Но на самом деле там не просто один раз дёргают и всё. Операционная система постоянно общается с реальным устройством. Это называется «поллинг».



Если вы работали с HTTP-поллингом — похожая история, только сильно чаще: 125 раз в секунду. Причём это дефолтное значение для мышек, клавиатур и тех же геймпадов, но устройство можно разгонять и еще быстрее опрашивать.


И общаетесь вы при помощи отчётов (reports), я буду называть их сегодня репортами, так проще, по спецификации. Они бывают трёх категорий:


Input Reports
Feature Reports
Output Reports


Вы опрашиваете устройство 125 раз в секунду, при этом для плавной графики достаточно отрисовать кадр 60 раз в секунду, то есть фрейм. То есть, кажется, что если бы мы могли это делать в вебе, то с устройствами можно работать каждый кадр — ну прикольно же!


Что такое репорт?


Что такое вот эти самые отчёты? Они выглядят как-то так:


Offset(d) 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15

00000000 01 7d 80 7f 80 08 00 f0 00 00 98 5b fd 07 00 57 
00000016 00 9b fe 8d 0d 1b 1f 6d 05 00 00 00 00 00 1b 00 
00000032 00 01 2b a9 b8 41 16 ab 86 e5 12 00 80 00 00 00 
00000048 80 00 00 00 00 80 00 00 00 80 00 00 00 00 80 00

Классно, да? Вот вот второй отчёт.


Offset(d) 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15

00000000 01 7e 81 7f 7e 08 00 bc 00 00 17 a1 fd fd ff 01 
00000016 00 01 00 8d 00 a3 20 d1 07 00 00 00 00 00 1b 00 
00000032 00 02 3c 0b 5b 94 1a 87 de c0 1b 41 0b 5e 94 1a 
00000048 87 de c0 1b 00 80 00 00 00 80 00 00 00 00 80 00

Видите разницу? Очень читаемая штука, ага.


Это просто сырой набор байтов, с ними нужно ещё уметь работать. Причём железячники — люди, которые очень сильно оптимизируют всё, и в этом маленьком кусочке очень много информации. А мы в нашем фронтенде, чтобы передать маленький кусочек информации, шлём мегабайтные JSON.



Вот это всё про USB. USB — это старая спецификация про то, как работать с тем, что мы втыкаем куда-то, независимо от того, что это USB Type A, Type-C и так далее — это всё достаточно обратно совместимые штуки.


В этой спецификации есть подраздел HID — Human Interface Devices.



Вы можете заметить, последняя версия спецификации 1.11 написана в 2001 году, и с тех пор она не менялась, и она до сих пор актуальна (как минимум для обратной совместимости с теми устройствами, которые успели наклепать).


Давайте немножечко погрузимся в эту прекрасную спецификацию, я, пока летел в самолёте, всю её прочитал, 97 страниц потрясающих табличек.


Подключение

Перечисление


Что делает ваш компьютер/ноутбук/устройство/хост, когда к нему подключается USB-устройство? Чтобы поместить его в свой список устройств и дать ему какой-то идентификатор, ему нужно понимать, что за устройство в него воткнулось и как-то его обозначить.


Дескрипторы


Для этого есть команда request Get_Descriptor, и USB-устройства, как правило, её поддерживают. Как она реализована, мы погружаться не будем, потому что всё-таки конференция по JS.


Но суть в том, что дескриптор — это такая штука, которая что-то описывает, то есть описывает, например, формат данных. Но по факту у каждого устройства есть много дескрипторов.



Есть стандартные дескрипторы, описанные в спецификации USB:


  • Device descriptor — формат, который описывает, что за устройство, какому вендору оно принадлежит (например, для геймпада PlayStation DualShock это будет корпорация «Sony»), какой у него идентификатор самого устройства, серийный номер и так далее. В общем, это информация об устройстве.


  • Configuration descriptor — это дескриптор про какие-то физические параметры устройств, то есть там что-то на уровне электроники: какая частота обновления и прочее. Нам это не так интересно, мы на это влиять не можем.


  • Interface descriptor. У вас на самом деле в вашем USB-устройстве может быть несколько интерфейсов, через которые вы можете взаимодействовать с этим устройством. И вы можете описать дескрипторами, что это за интерфейсы.


  • Endpoint descriptor — это история про то, что можно «дёрнуть», как в API.


  • И string-дескриптор — это просто описание в виде строки. На случай, если не хватило первых четырёх, вы можете просто текстом что-то зашить в вашем устройстве. Например, послание от разработчиков.



А также существуют HID-специфичные дескрипторы, которые есть именно в Human Interface Devices:


  • Собственно HID descriptor, это просто специальный формат под HID.


  • Report descriptor — это то, что я вам показывал: есть feature-репорты, input-репорты и output-репорты.


  • Physical descriptor. Тоже прикольная штука. «Репорт» — это просто набор байтов, который сообщает какую-то информацию с сенсоров. А физический пытается определить, как человек взаимодействует с этим устройством. Скажем, в report-дескрипторе можно сообщить «на клавиатуре человек нажал такую-то кнопку», а физическим дескриптором — «он нажал это большим пальцем левой руки». Мало где используется, но для некоторых устройств это очень прикольная штука, и у них прописано большое количество ID наших внешних органов. То есть у вашего глазного яблока есть айдишник в спецификации, чтобы вы понимали.



Всё это лежит в read only memory — памяти, которая прямо зашивается в устройство. Но на самом деле, есть устройства, в которых это можно перепрошивать.


Давайте попробуем это почитать.


Device Descriptor


12 01 01 00 00 00 00
08 ff ff 00 01 01 00
04 0e 30 01

Нам это пригодится, просто поверьте!


Допустим, у вас есть вот такой Device Descriptor, это просто набор байтов. Как его читать? Вам просто нужно залезть в спецификацию, там всё чётко расписано. Я этот пример прямо из неё взял. Привожу его в hexadecimal, так принято.


Первый байт в любом дескрипторе — это размер дескриптора: когда устройство подключается, с дескрипторами работает USB-парсер, и для начала работы ему нужно знать, какой кусок мы сейчас обрабатываем. Здесь это 0x12, что в переводе в десятичную систему означает 18 байт.


Дальше 0x01 — это тип дескриптора: даёт понять, что это девайс-дескриптор, а не какой-то другой. Это константа.


Далее два байта подряд, 0x100 — способ указать, что, это устройство поддерживает спецификацию USB 1.0.


Затем идет код класса, но тут забавная история. В USB есть понятие класса устройств, но HID настолько многообразен и туда так сложно жёстко что-то записать, что обычно в device-дескрипторе для HID-устройств пишется просто 0, а уже в других местах (вроде интерфейс-дескрипторов) описывается, что оно собой представляет. Вот и здесь это 0x00.


Аналогично с подклассом: для USB там много всякого есть, а для HID просто пишется, используется ли это устройство при загрузке (здесь не boot, 0x00). Я не шучу, вот так в спецификации прям написано:


Subclass code Description
0 No subclass
1 Boot interface subclass
2-255 Reserved

Заметьте, там много зарезервированных возможностей, но по факту используется всего один бит.


Последний байт первой строки 0x00 — это код протокола, и там тоже прикольно. В спецификации написано, что единичка — это клавиатура, двойка — это мышь, а остальные — зарезервированы. Ну, мало ли придумают что-нибудь ещё. С 2001 года пока не придумали. Обычно, если у вас не мышь/клавиатура, пишется 0.


Дальше указывается максимальный размер пакета нулевого эндпоинта, у нас это 0x08. Нулевой эндпоинт — это шина, к которой вы подключаетесь. Есть управляющая шина и шина прерываний, но главное в том, что контрольная шина работает пакетами. В вебе вы тоже работаете с HTTP-пакетами, TCP-пакетами, у вас там есть, условно, окно в 14 килобайт. Эта идея не нова, она пришла в том числе из железных устройств, где тоже есть свои протоколы. И мы как бы говорим хосту, что он будет работать с пакетами размером, в данном случае, 8 байт, и он будет просто чанки получать по 8 байт и их обрабатывать. По-моему, здесь валидные значения 8, 16, 32 и 64, если что-то другое указать, то может не сработать.


Дальше собственно то, что нам нужно, если мы хотим получить какую-то информацию об устройстве: Vendor ID пишется в следующих двух байтах, в моём примере это 0xffff. Vendor ID назначается комиссией USB, присваивается каким-то большим вендорам, но если вы делаете что-то кастомное, можете писать туда, что хотите.


Следующие два байта (здесь это 0x0001) — это Product ID, он назначается самим вендором, то есть тоже особо не контролируемая история. Но где-то в каких-то классификациях она указана, просто чтобы удобно было.


Последние два байта второй строки (0x0100) — это релизный номер устройства. Это тоже то, что придумывает производитель, у каждого устройства может быть свой, например, но у вас есть всего 2 байта.


Дальше индексы строковых дескрипторов (0x04, 0x0e, 0x30) — это как раз то, о чём я говорил. Можно просто указать, что в строковом дескрипторе по индексу 0е лежит строчка, которая описывает это устройство.


Вы когда-нибудь в Windows в контекстном меню «Моего компьютера» выбирали «О моём компьютере», заходили в список устройств? Вот всё, что вы видели там текстом, берётся отсюда. То есть там есть Vendor ID, Product ID, но по факту он берёт просто строковый дескриптор из реальных устройств и выводит вам.


И самое последнее здесь (0x01) — количество конфигураций устройства. Устройство может иметь несколько режимов работы и так далее, но в простейшем случае можно задать всего одну конфигурацию.


Есть ещё дескрипторы отчётов, вот пример:


Usage Page (Generic Desktop),
Usage (Joystick),
Collection (Application),
    Usage Page (Generic Desktop),
    Usage (Pointer),
    Collection (Physical),
        Logical Minimum (-127),
        Logical Maximum (127), Report Size (8),
        Report Count (2),
        Push,
        Usage (X),
        Usage (Y). Input (Data, Variable, Absolute).
        Usage (Hat switch),
        Logical Minimum (0),
        Logical Maximum (3),
        Physical Minimum 0),
        Physical Maximum (270),
        Unit (Degrees),
        Report Count (1),
        Report Size (4).
        Input (Data, Variable, Absolute, Null State),
        Logical Minimum (0),
        Logical Maximum (1),
        Report Count (2),
        Report Size (1),)
        Usage Page (Buttons),
        Usage Minimum (Button 1),
        Usage Maximum (Button 2),
        Unit (None),
        Input (Data, Variable, Absolute)
    End Collection,
    Usage Minimum (Button 3),
    Usage Minimum (Button 4),
    Input (Data, Variable, Absolute),
    Pop,
    Usage (Throttle),
    Report Count (1), 
    Input (Data, Variable, Absolute),
End Collection

Как это все читать?


Давайте попробуем.


    static u8 dualshock4_usb_rdesc[] = {
0x05, 0x01,        /*    Usage Page (Desktop),          */
0x09, 0x05,        /*    Usage (Gamepad),               */
0xA1, 0x01,        /*    Collection (Application),      */
0x85, 0x01,        /*        Report ID (1),             */
0x09, 0x30,        /*        Usage (X),                 */
0x09, 0x31,        /*        Usage (Y),                 */
0x09, 0x32,        /*        Usage (Z),                 */
0x09, 0x35,        /*        Usage (Rz),                */
0x15, 0x00,        /*        Logical Minimum (0),       */
0x26, 0xFF, 0x00,  /*        Logical Maximum (255),     */
0x75, 0x08,        /*        Report Size (8),           */
0x95, 0x04,        /*        Report Count (4),          */
0x81, 0x02,        /*        Input (Variable),          */
0x09, 0x39,        /*        Usage (Hat Switch),        */
0x15, 0x00,        /*        Logical Minimum (0),       */
0x25, 0x07,        /*        Logical Maximum (7),       */
0x35, 0x00,        /*        Physical Minimum (0),      */
0x46, 0x3B, 0x01,  /*        Physical Maximum (315),    */
0x65, 0x14,        /*        Unit (Degrees),            */

Я это взял прямо из кода ядра Android, нашёл прямо в опенсорсе. И те, кто работают с USB-устройствами, обычно прямо так и пишут в коде: через запятую и дальше добавляют комментарии, потому что каждый раз это парсить сложно.


Но на самом деле достаточно простая штука: у вас есть всегда соответствие, назовём его ключ-значение.


Вот у вас есть ключ 0x05, согласно спецификации, этот ключ — Usage Page. Usage Page — это история о том, для чего устройство в принципе используется. И вот здесь 0x01, по спецификации это устройство для десктопа.


Идём дальше. 0x09 — это Usage, 0x05 — это зарезервированное значение для геймпадов.


Дальше начинается интересное: внутри report-дескриптора вы можете писать коллекции.


Как вы будете общаться с вашим устройством? Вы будете отправлять какую-то команду, а если он поддерживает несколько команд, как это отправить? Так вот, у каждой команды есть Report ID, и вот в этой коллекции как раз указывается, что у нас есть Report ID (1). И когда, например, ноутбук отправит мне команду через USB моему устройству и укажет, что Report ID — единичка, мне нужно распарсить всё это, что ниже, и правильно это применить. Если там Report ID — пятёрочка, то это другая история, с другими устройствами. В общем, USB там тоже программируется, оно умеет понимать по ID, что делать.


И дальше, смотрите, у геймпада есть X, Y, Z. Скорее всего, здесь идёт работа с 3D, наверное, это двигающийся стик. 0x09 — это Usage, X, Y, Z, опять же, зарезервированы.


Что интересно, 0x15 — это логический минимум. В устройствах заранее заложено, что ваше устройство может возвращать значение в определённом диапазоне (здесь от 0 до 255), но при этом отдельно есть минимум и максимум. И можно прямо в устройстве указать, что 0, например, нулю и будет соответствовать, а вот максимальное 255 будет соответствовать 3000. И зная это соответствие, вы уже обрабатываете на уровне кода, как-то масштабируете.


Там есть даже более сложные штуки, можно какие-то экспоненты возводить, в спецификации всё сильно продумано.


Дальше интересное. У вас есть 0x75 — это Report Size, это количество бит для одного куска информации. И Report Count — это сколько вам нужно таких кусков. То есть здесь мы по факту резервируем 4 байта: Report Size — 8, Report Count — 4.


И дальше команда Input говорит: «Вот сюда положи Variable — значение, которое будет меняться со временем». Есть ещё просто Constant, она обычно для резервирования памяти, потому что вам эти пакеты тоже нужно выравнивать.


Если вы ошибётесь в этих репорт-дескрипторах, когда прошиваете ваше устройство, то потеряете его, работать с ним будет невозможно. Поэтому есть инструменты для верификации всего этого.


На сайте usb.org вы можете найти HID Descriptor Tool, который как раз таки верифицирует.



HID Descriptor Tool с 2001 года валидирует ваши репорт-дескрипторы, отлично справляется. Но есть дополнительные всякие штуки, которые тоже можете в интернете найти.


Поллинг


Поллинг

Есть чё?

Дай мне


Операционная система постоянно спрашивает у устройства, как я говорил, 125 раз в секунду: «Есть чё? Есть отчёт? Дай, пожалуйста, отчёт». Она это делает так.


Pull

GET_REPORT 0x01


«Дай мне отчёт, вот я тебя прошу, дай что-нибудь». И устройство такое: «Ну ладно, вот у меня спустя 5 секунд пользователь кнопку нажал, я тебе дам данные». Но помимо самого поллинга, есть команды, которые позволяют получить что-то конкретное.


Например, я хочу от своего джойстика получить состояние гироскопа. И отправляю команду Get_Report: это request, который тоже прописан в спецификации. У него есть Report ID, здесь для примера 0x01 — и он мне пришлёт какой-то набор байтов.


Push

SET_REPORT


Аналогично я могу отправить какой-то репорт на устройство, попросить его что-то сделать. У него в дескрипторах написано, и оно умеет понимать свои собственные дескрипторы, если там напрограммировали нормально без багов. Я могу попросить устройство что-то сделать.


Push

SET_FEATURE


И есть ещё то, что есть в HID: можно вызвать какую-то фичу. Фича — это просто на самом деле удобный способ работать с репортами. Это дополнительный уровень абстракции в какой-то мере уже на самом устройстве, чтобы не прям набор байт жёстко записанный, а вот какую то фичу более коротким набором сделать. Это сделано в том числе для того, чтобы на маленькой железке сделать больше, там каждый бит экономится.


Вы это можете дебажить. Есть утилиты типа Wireshark:



Просто подключаете USB-устройство, смотрите, какими байтами оно общается. Причём Wireshark умеет многие штуки расшифровывать, так что вам не надо лезть в спецификацию. Это прикольно, можно всякое интересное найти.


Ну и про уровень абстракции: разработчики на C++ и прочие бэкендеры не пишут этот код постоянно сами руками, есть библиотеки.


HIDAPI



Они позволяют чуть проще это делать, используя уже какие-то готовые вызовы и так далее.


А есть ли такая штука в браузере?


HID уже в браузерах!


Да! На самом деле есть. Возможно, вы не замечали, но когда вы работаете с браузером, на клавиатуре что-то печатаете, мышкой кликаете, и он вас понимает, ну правда?


Так вот в исходном коде Chromium есть, например, файл services/device/public/mojom/hid.mojom


И там вы можете найти прямо то, про что я вам рассказывал.


const uint16 kGenericDesktopUndefined = 0x00;
const uint16 kGenericDesktopPointer = 0x01;
const uint16 kGenericDesktopMouse = 0x02;
const uint16 kGenericDesktopJoystick = 0x04;
const uint16 kGenericDesktopGamePad = 0x05;
const uint16 kGenericDesktopKeyboard = 0x06;
const uint16 kGenericDesktopKeypad = 0x07;
const uint16 kGenericDesktopMultiAxisController = 0x08; 
const uint16 kGenericDesktopX = 0x30;
const uint16 kGenericDesktopY = 0x31;
const uint16 kGenericDesktopZ = 0x32;
const uint16 kGenericDesktopRx = 0x33;
const uint16 kGenericDesktopRy = 0x34;
const uint16 kGenericDesktopRz = 0x35;
const uint16 kGenericDesktopSlider = 0x36;
const uint16 kGenericDesktopDial = 0x37;
const uint16 kGenericDesktopWheel = 0x38;
const uint16 kGenericDesktopHatSwitch = 0x39;
const uint16 kGenericDesktopCountedBuffer = 0x3a;
const uint16 kGenericDesktopByteCount = 0x3b;
const uint16 kGenericDesktopMotionWakeup = 0x3c;
const uint16 kGenericDesktopStart = 0x3d;
const uint16 kGenericDesktopSelect = 0x3e;
const uint16 kGenericDesktopVx = 0x40;
...

Там какие-то устройства описаны, им даны идентификаторы, то есть браузер вот как минимум с ними умеет работать.


Браузеры постоянно работают с устройствами Human Interface Devices. А прикиньте, было бы клёво, если бы мы как разработчики тоже могли с ними работать?


WebHID


Та-дам! WebHID — это браузерный API, который НЕ входит в W3C-стандарт. Но есть спецификация в WICG (Web Platform Incubator Community Group).



То есть приходят разработчики браузеров, придумывают крутые штуки, пишут для них спецификации. И эта штука, WebHID, уже включена по умолчанию с Chrome 89.


Chrome 89+ ✅


Включена по умолчанию — это значит, что вам даже экспериментальный флаг не надо включать. Она просто работает в вашем браузере, вы можете подключать разные устройства и обрабатывать их.


Mozilla ????


К сожалению, в Mozilla сказали, что они это реализовывать не будут. Во-первых, я так понимаю, у них сейчас не сильно много ресурсов, а во-вторых, fingerprinting, приватность данных. И в спецификации вы можете найти большие абзацы о том, что действительно при подключении устройства вы очень явно выдаёте пользователя. У него серийный номер устройства — это прям fingerprinting и есть, вряд ли он будет своё устройство кому-то другому отдавать.


Safari ????


Safari то же самое сказали, что из-за tracking prevention мы это реализовывать не будем.


Но у Chrome аудитория большая, и как прогрессивное улучшение в целом вы можете попробовать это сделать. И Chrome думает над тем, как сделать это безопаснее. В том числе, например, врать. Типа когда вы просите информацию об устройстве — а давай я тебе сначала вот так дам, а потом по-другому дам, сейчас вот ещё рандомный байтик замешаю. Короче, Chrome тоже старается делать это приватно.


Доверенный ввод


WebHID не работает с доверенным вводом. Что такое доверенный ввод? Вы, наверное, через клавиатуру вводите пароли, номера кредитных карт, адреса, через мышку тоже кликаете капчу какую-нибудь. В общем доверенный ввод — это то, через что можно получить что-то секретное.


Так вот, опять же, в исходниках Сhromium вы можете найти, какие конкретно устройства являются доверенным вводом и которые нельзя обрабатывать при помощи протокола WebHID.


bool IsAlwaysProtected(const mojom::HidUsageAndPage& hid_usage_and_page) { 
  const uint16_t usage = hid_usage_and_page.usage;
  const uint16_t usage_page = hid_usage_and_page.usage_page;

   if   (usage_page == mojom::kPageKeyboard) 
    return true;
  if    (usage_page != mojom::kPageGenericDesktop) 
    return false;
  if    (usage == mojom::kGenericDesktopPointer ||
    usage == mojom::kGenericDesktopMouse ||
    usage == mojom::kGenericDesktopKeyboard ||
    usage == mojom::kGenericDesktopKeypad) { 
    return true;
  }
  if    (usage >= mojom::kGenericDesktopSystemControl &&
    usage <= mojom::kGenericDesktopSystemWarmRestart) {
    return true;
  }
  if    (usage >= mojom::kGenericDesktopSystemDock &&
    usage <= mojom::kGenericDesktopSystemDisplaySwap) { 
    return true;
  }

  return false;
}

На самом в спеке есть blacklist таких устройств, которые не должны выдавать данные куда не надо.


Пользовательский жест


И ещё есть ситуация «требует пользовательский жест для разрешения работы». Это вполне нормальная история — то же самое, как работа со звуками. Вы сейчас не можете в браузерах просто взять и запустить звук, пользователь должен куда-то кликнуть на странице, произвести интеракцию.


Здесь та же история, мне было бы, наверно, страшно, если я просто открываю в новой вкладке что-то, и оно уже сразу лезет смотреть: «Какие у него там устройства, чё у него там?». Всё сразу пометили, а пользователь ещё на сайт не зашёл. Я хочу давать явно разрешение, чтобы к моему джойстику или ещё к чему-нибудь подключались.


Ну что, давайте попробуем реализовать что-то на этом WebHID?


Работа с HID


На самом деле ничего сложного. Вот правда. Вот всё, что я вам рассказывал — браузер очень многое взял на себя. Во-первых, у вас в навигаторе появляется атрибут hid, это тот самый интерфейс, через который вы можете начать работать с этими всеми устройствами.


Получаем список устройств


Вы можете запросить у navigator.hid устройства по какому-то фильтру. Когда-то при пустом фильтре протокол выдавал все устройства, но со временем реализация менялась, опять же из-за приватности. Суть в том, что вы, вероятно, не хотите получить вообще все устройства. Например, я хочу работать с джойстиком, могу указать конкретно «я работаю с джойстиками Sony, Product ID Sony DualShock 4» — пишу соответствующие числа.


if ("hid" in navigator) {
  const opts = {
    filters: [ 
      {
        vendorId: 0x0fd9,
        productId: 0x006d
      }
    ] 
  };
  const devices = await navigator.hid.requestDevice(opts); 
}

И если оно подключено, devices вернёт какой-то массив.


А ещё я могу не знать конкретное устройство, но хочу работать с категорией устройств. Скажем, вот есть в Usage категория Consumer Control. Я знаю, как с ними работать, и не важно, какого производителя. Указываю usagePage, usage:


if ("hid" in navigator) {
  const opts = {
    filters: [ 
      {
        usagePage: 0x000c, // Consumer
        usage: 0x0001 // Consumer Control
      }
    ]
  };
  const devices = await navigator.hid.requestDevice(opts); 
}

Мы выше уже говорили о том, что такое usagePage, теперь вам должно быть уже чуть понятнее, что это за слова.


Как вообще найти все эти устройства?


Во всех chromium-браузерах есть вкладочка about://device-log.



Там есть vendorId, productId, name (это взято из string-дескриптора), серийный номер (он, кстати, тоже может быть в string-дескрипторе) и так далее.


Вы можете заметить, что даже Magic Trackpad от Apple — это тоже HID-устройство, с которым вы как-то можете взаимодействовать.


Но что мне здесь не нравится (кстати, надо отправить этот фидбек в Google): тут значения в десятеричной системе счисления, а во всех спецификациях используется шестнадцатеричная, неудобно.


Если хотите посмотреть, какие у разных устройств vendorId, productId— есть специализированные сайты. Я, например, пользуюсь devicehunt.com, там можно найти даже очень редкие устройства.


Открываем соединение


Сначала мы просто получили список устройств, теперь нужно начать работать с конкретным. Есть устройство, мы должны открыть к нему соединение:


const device = devices[0];

await device.open();

device.addEventListener("inputreport", (event) => {
  const { data, device, reportId } = event;

  if (reportId === 0x01) { // <== You need specs here 
    // Что-то делаем
  }
 });

Метод open() — это абстракция, которая берёт на себя все эти «рукопожатия», получение дескрипторов, конфигурации и так далее — браузер всё это зашил в один метод, вам больше ничего не надо про это знать.


И у вас появляется event listener inputreport. Потрясающая вещь, которая просто говорит: «Когда устройство мне что-то пришлёт, я это помещу в event data» — всё. Вам как разработчику не очень надо понимать всё остальное (ну разве что reportId ещё очень полезная штука, чтобы понимать, а что за данные вам пришли).


А под капотом происходит вот этот поллинг с частотой 125 Гц: «Есть чё — нет ничего». А вам это придёт только в полезный для вас момент. Обалденно!


DataView


Тип данных DataView: если вы с ним работали, то можно спокойно использовать все методы оттуда. Если умеете работать с байтами, вам вообще сложно не будет.


Парсим


Теперь будет демо. Я с собой привёз белорусский DualShock.


Я нашёл сайт, где парсят разные устройства и красиво делают описания, что вообще в этих data-форматах, какие биты за что отвечают.



Вы можете заметить на шестой строчке 8 бит: у вас используется 1 байт для шифрования четырёх значений. То есть оптимизация максимальная, потому что нужно очень быстро передавать и по Bluetooth, и не только. А если бы мы это делали в JSON, было бы что-то типа {leftStickX: …}. Вот в плане оптимизации работы с памятью я восхищаюсь искренне железячниками.


Ещё из интересного можете заметить вот что: кнопочки геймпада (треугольничек, кружочек) занимают по одному биту, а вот когда на перекрестье вы нажимаете «вверх», на самом деле это не просто «вверх». Там пластинка, которая замыкает контакт одновременно слева сверху и справа сверху, и там высчитывается, что вы на самом деле нажали, при помощи битовых операций. В общем, прикольная история, можете почитать.


Зная всё это, я использую getUint8 для того, чтобы прям байты расковыривать, и начинаю парсить данные.


const buttonsLeftRight = data.getUint8(4);
const dPad = buttonsLeftRight & 0x0f;
const additionalButtons = data.getUint8(5); 
const serviceButtons = data.getUint8(6);

const buttons = {
  triangle: !!(buttonsLeftRight & 0x80), 
  circle: !!(buttonsLeftRight & 0x40), 
  cross: !!(buttonsLeftRight & 0x20), 
  square: !!(buttonsLeftRight & 0x10),

  up: dPad === 7 || dPad === 0 || dPad === 1,
  right: dPad === 1 || dPad === 2 || dPad === 3, 
  down: dPad === 3 || dPad === 4 || dPad === 5, 
  left: dPad === 5 || dPad === 6 || dPad === 7, 
  // ...
};

Парсить весело. Если ошибётесь, будет очень интересно дебажить. Но я сверялся с библиотекой, которая это уже умеет делать. То есть вам надо немножко битовых операций.


Что дальше? Если я хочу что-то отправить, попросить мой джойстик вообще что-то сделать, я точно так же формирую массив вот этих байтов, заранее резервирую нужный объём (например, знаю: чтобы заставить его светиться и вибрировать, мне нужно 16 байт, у него так в дескрипторе написано).


const report = new Uint8Array(16); 

// Report ID
report[0] = 0x05;

// Enable Rumble (0x01), Lightbar (0x02)
report[1] = 0xf0 | 0x01 | 0x02;

// Light / Heavy rumble motor

report[4] = 64; 
report[5] = 127;

// Lightbar Red / Green / Blue
report[6] = 255; 
report[7] = 0; 
report[8] = 0;

return device.sendReport(report[0], report.slice(1));

ID этого репорта 0x05, потому что ну вот так. А дальше просто, по этой спецификации, я задаю какие-то значения — device.sendReport. Всё. Если использовать это в более низкоуровневых языках, там чуть-чуть сложнее, хотя по факту тоже есть абстракции. Браузер берёт всё на себя. Он просто берёт эти байты, упаковывает и отправляет.


Время демо ????


Всё это затевалось для того, чтобы написать демку. Давайте попробуем с джойстиком наконец что-то сделать!


видеоверсия демо с 37:00


Подключаем джойстик. У него очень мощное название Wireless Controller (всё, что он о себе в названии говорит):



А вот в device можно всякое почитать. Тут есть productId (опять же десятеричный), productName (прямо очень полезный), vendorId:



Но самое интересное лежит в коллекциях.


Коллекции — это та самая история про отчёты, в которые умеет ваше устройство. И самое классное, что её уже распарсили. Сейчас покажу.


Вам не нужно вот эти вот числа самому преобразовывать («вот это логический максимум или физический максимум?»): это всё уже браузер распарсил.



Абстракция. Обалденно удобно, читаемо и понятно.


Можете посмотреть всякие параметры вроде unitFactorTimeExponent, unitSystem — тут можно задать, какими единицами измерения возвращаются какие-то значения. Короче, это всё вот прям здесь описано.


Да, не совсем понятно по reportId, а что это вообще такое: это отправка данных, чтобы что? Да, тут придётся в спецификацию залезть, но в любом случае придётся, даже железячники так делают.


Что у меня тут есть? Во-первых, я постоянно получаю данные из гироскопа и акселерометра, постоянно приходит report, который возвращает эти данные.


И когда я управляю джойстиками, могу видеть это в браузере:



Я себя чувствую как в какой-то серии «Теории Большого взрыва», когда они там открыли для себя умный дом. Я нажимаю кнопочки и могу это обрабатывать прямо в браузере. Даже вот эта большая кнопенция в центре работает.


Есть Gamepad API, возможно, кто-то из вас слышал. Так вот он умеет все кнопки, но не тачпад. А моим способом умеет. И хотя я не реализовал это в демке, можно получить даже позицию, где находится мой палец. Если вы хотите прямо полноценно научиться работать с джойстиком, вам придётся вот этот HID использовать.


Ну и да, всё для чего затевалось-то? Я хочу, например, на геймпаде подкрасить тачпад в другой цвет. Выбираю его в браузере, и цвет меняется! Я так радуюсь, просто как ребенок каждый раз от того, что оно работает. Два дня дебажил.


Ну и самое главное — а не слышно, да? Короче, он вибрирует. Я тут просто рандомом задаю значения. У него есть два моторчика — мягкий и тяжёлый. И я заставил эти штучки крутиться просто кнопкой в браузере. Классно!


Библиотека WebHID-DS4



На самом деле я мог не тратить на это время, а просто взять готовую библиотеку, там уже всё реализовано. Но я всегда нахожу хорошее решение уже после того, как сам сделал. Если захотите применять это у себя на сайте, берите лучше эту библиотеку, она поддерживается опенсорс-сообществом и всё такое. Но…


Meet + StreamDeck Helper


…но у меня есть другое устройство. Не знаю, видели ли вы раньше устройство Stream Deck, которое иногда используют стримеры:



Это удобная панелька, они бывают разного размера. Вы можете запрограммировать разные кнопки, чтобы они делали разные штуки. Например, там есть привязка к OBS: можно заставочку запустить, музычку запустить. Довольно прикольная штука для стримеров. Но я же говорил, что я играю на барабанах и пианино? Я захотел из этой штуки сделать drum pad. Типа я жму кнопку — играет какой-то звук.


Когда я начал готовиться к докладу, увидел, что Pete LePage (DevRel в Google) как раз тоже рассказывал про WebHID и при помощи StreamDeck сделал утилиту, которая работает с Google Meet.



То есть он управляет рабочими созвонами при помощи вот этой штуки. И писал в блоге, что это реально удобно, потому что иногда на вкладку переключиться надо — а с этой штуки можно, не находясь во вкладке браузера, например, замьютить себя. Иногда это прям полезно.


А Брамус ван Дамм использовал его наработку, чтобы сделать свой drum pad!


Поэтому я просто взял их код и немножко дотюнил.


Вы не найдете нормальной спецификации Stream Deck Protocol, коммерческая тайна. Но умельцы понажимали кнопки, раздебажили и обнаружили, какими отчётами как управлять.


Спасибо большое этим ребятам за то, что сделали всю работу по подготовке демки к моему докладу.


Время демо 2 ????


Теперь давайте попробуем подключить это устройство. Оно подключилось. Попробуем играть музыку.


видеоверсия с 42:55-44:22


Я нажимаю кнопки на Stream Deck, а в браузере отрисовываются все его кнопки и отображается, на какие происходит нажатие.



Чтобы вы понимали, для «Seven Nation Army» вам буквально 5 кнопок хватит. Когда у меня уже закончатся барабаны?


Прямо концерт для браузера и Stream Deck.


device.close()


Да, уходя, закрывайте двери. Не забывайте использовать device.close(), когда вы начинаете закрывать вкладку, у вас есть beforeunload и так далее — ивенты, обязательно на них подписывайтесь и отпускайте. Это как раз к тому вопросу, который был из зала, чтобы другим вкладкам как минимум не мешать. Вы забываете устройство, там, кстати, есть ещё метод forget, например, чтобы вообще от него отписаться, и вообще всё будет хорошо.


Что дальше? ????‍♂️


Что я вам ещё посоветую по теме.



Есть отличный репозиторий Awesome WebHID, там куча классных демок с другими устройствами, например, там Microsoft Switch, для этих джойстиков можно прикольно сделать всякое, но и для других устройств. На самом деле устройств таких не то чтобы много, но поиграться можно.


И есть потрясающая статья на web.dev, которой я тоже вдохновлялся: как в принципе с незнакомыми устройствами действовать, как подключаться, как с ними работать. Если хотите начать — начните с неё.


Мы уже вовсю готовим следующую HolyJS, которая пройдёт 10-11 ноября в онлайне и 20 ноября в Москве. Доклады конкретно про железо не обещаем, но общий принцип там будет тот же: всё, что может быть интересно JS-разработчику, даже если это отходит от стандартных фронтенд-задач. Так что если этот текст вас заинтересовал — вероятно, и там для вас найдётся интересное.

Комментарии (2)


  1. Fell-x27
    31.08.2022 20:35
    +1

    Вот бы ещё эта штука была повсеместной, а не как сейчас. WebUSB пока что более привлекателен.

    https://caniuse.com/webusb

    https://caniuse.com/webhid


  1. KivApple
    01.09.2022 11:00

    Непонятен аргумент про finger printing от других браузеров - можно же сделать явную выдачу доступа как с вебкамерой.