С каждым днём всё ближе обжигающее японское лето, поэтому я всё больше думал о своей давней идее: дистанционном управлении кондиционером воздуха в моей спальне через Интернет. Простым нажатием кнопки за десять минут до отправления ко сну я мог бы включить кондиционер, который бы превращал спальню в прохладный комфортный оазис к тому моменту, как я почищу зубы и поднимусь на второй этаж. В прошлом году это так и осталось идеей; в этом году я довёл её до реализации.
Хотя идея дистанционного управления кондиционером далеко не нова (многие уже проложили этот путь до меня, став для меня источником вдохновения), я считаю, что моя реализация достаточно уникальна.
Самым простым было купить необходимые компоненты — Raspberry Pi Zero WH и Infrared Transceiver Hat; нетерпеливо ждать их оказалось уже не так легко.
После получения заказа я осознал, что transceiver hat прибыл без самого инфракрасного датчика и инфракрасных диодов, поэтому мне пришлось заказать необходимые компоненты с Amazon, самостоятельно припаять их и убедиться, что я могу получать инфракрасный сигнал в Raspberry Pi OS при помощи команды LIRC с интуитивно понятным названием mode2
.
Заметили, чего не хватает? Ещё хочу отметить, что меня постоянно поражает крошечный размер Raspberry Pi Zero. Приложил для сравнения крышку от бутылки.
На этом этапе мне нужно было принять важное решение: насколько амбициозным я хочу сделать проект? У меня было четыре варианта.
Первая сложность связана с сигналами пульта кондиционера. В отличие от пультов телевизоров или ламп, которые обычно отправляют при каждом нажатии на кнопку один и тот же сигнал, позволяя приёмнику интерпретировать и согласовывать состояние, пульты кондиционеров сами хранят состояние и при каждом нажатии кнопки передают целиком обновлённое состояние. Возникает вопрос: устроит ли меня простая запись и воспроизведение нескольких известных состояний пульта, или я хочу погрузиться глубже — декодировать, проанализировать и воспроизводить сигнал любого нужного состояния?
Вторая сложность связана с развёртыванием веб-сервера, предоставляющего интерфейс пользователя для управления кондиционером. Выбрать ли мне простое развёртывание веб-сервера Phoenix на Raspberry Pi OS или пойти поболее сложному, но, безусловно, и более крутому пути связывания Phoenix с Nerves?
В обоих случаях я выбрал вторые варианты, более безумные, но и, бесспорно, более впечатляющие. Давайте сначала поговорим о Nerves.
Наверно, не самое лучшее соединение в моей жизни, но должен сказать, что прошло много лун с тех пор, как я в последний раз держал в руках паяльник.[1]
[1] Примечание: изначально я припаял только один IR-диод, потому что так выглядели платы во всех статьях, которые я видел в Интернете. Результат меня разочаровал, потому что плата никак не реагировала на мои команды. Расстроившись, я наудачу прикрепил ещё один диод, даже не припаяв его, ни на что особо не надеясь. Затем я проверил диоды объективом своего iPhone и, к своему искреннему удивлению, увидел, что они оба испускают инфракрасное излучение. Думаю, это связано с сектором SJ1
, соединённым каплей припоя на большинстве плат, но не на моей; впрочем, эту теорию я не проверял.
▍ Elixir на встроенных системах
Nerves — это одна из самых существенных библиотек Elixir. Phoenix позволяет писать веб-серверы, превосходящие по скорости гепарда, Ecto позволяет общаться с широким спектром баз данных, а Nx позволяет подчинить себе мощь машинного обучения и искусственного интеллекта. Nerves же позволяет беспроблемно развёртывать Elixir на встроенных устройствах, например, на Raspberry Pi. И у меня наконец-то появилась возможность воспользоваться ею!
Nerves объединяет ваше приложение с урезанным ядром Linux и несколькими необходимыми утилитами в прошивку, которую можно «прожечь» на карту microSD, что позволит встроенному устройству напрямую загружать BEAM (Erlang Virtual Machine). Такой минималистичный подход, в отличие от развёртывания приложения Elixir в полнофункциональной Raspberry Pi OS, обеспечивает множество преимуществ:
- Весь бандл операционной системы и приложения занимает десятки, а не сотни мегабайтов[2]; [2] К тому же он потребляет микроскопический объём памяти.
- система загружается и делает приложение доступным за секунды, а не за десятки секунд;
- обновления приложения и его зависимостей обрабатываются как часть вашего кода, а не через медленный и сложный в воспроизведении менеджер пакетов;
- благодаря малому количеству ПО вектор атаки существенно меньше;
- вместо работы с системами init Linux вы определяете приложения Erlang;
- при этом всё равно сохраняется способность ядра Linux взаимодействовать с оборудованием через драйверы Linux.
Создание и запуск базового приложения Nerves оказались очень лёгким процессом, зато гораздо сложнее было заставить работать в Nerves инфракрасный приёмник и передатчик; недостаточно было просто выполнить apt install lirc
и отредактировать config.txt
, как в Raspberry Pi OS.
К счастью, Nerves может похвастаться потрясающей документацией, помогающей в течение всего процесса настройки системы. Даже несмотря на то. что в половине случаев я почти не понимал, что делаю, мне удалось упаковать lirc-tools
в пользовательское пространство, внедрить поддержку инфракрасного пульта управления в ядро Linux и настроить свой проект Nerves на работу с этой операционной системой. Но потом… ничего не произошло, устройства /dev/lircX
не стали доступны сразу волшебным образом.
Оказалось, что нужно было выполнить ещё несколько шагов и преодолеть ещё несколько препятствий. В частности:
- для начала нужно было
переписать стандартный fwup.conf
, чтобы позже можно былопереписать стандартный config.txt
и загрузить мой device tree overlay; в результате выполнения этого шага я получил доступ к крайне необходимым устройствам/dev/lirc0
и/dev/lirc1
; - заменить
поломанный плагин default.so
LIRC на тот, который я вежливоукралпозаимствовал из моей предыдущей установки Raspberry Pi OS; очевидно, что это некорректный способ решения проблемы, поэтому я сообщил о баге разработчикам Nerves, но он может быть актуальным, пока разработчики это не исправят; - создавать при запуске
папку /var/run/lirc
, чтобы LIRC мог создавать свои файлы сокета и PID, а затем воспользоваться MuonTrap для запускаlircd
— это гораздо проще, чем работа с системой init!; - сконфигурировать параметры LIRC, наложив
overlayfs
поверх стандартной файловой системы; переопределить erlinit.config
так, чтобыtmpfs
монтировалась в/etc/lirc/lircd.conf.d
с возможностью записи — Nerves монтирует рутовую файловую систему в режиме «только для чтения», поэтому это необходимо для создания конфигураций LIRC на лету!
Закончив с этим, я приступил к написанию простого декодера сигналов, который должен превращать вывод команды LIRC mode2
в строку CSV; затем можно будет импортировать её в Google Sheets для анализа!
SSH к IEx никогда не перестанет меня удивлять.
▍ Взламываем код
Прочитав упомянутые выше статьи, я начал догадываться, что инфракрасный сигнал пульта закодирован модифицированной версией [3] протокола NEC. Этот протокол представляет двоичный ноль в виде последовательностей кратковременных импульсов (562,5 мкс), за которыми идёт короткая пауза, а двоичную единицу — в виде последовательностей кратковременных импульсов, за которыми следует длинная пауза (1,6875 мс) [4].
[3] Модифицированной, потому что, по крайней мере, согласно найденной мной спецификации, протокол NEC достаточно строго относится к общей структуре сигнала; он начинается с пакета импульсов 9 мс, за которым идёт пауза 4,5 мс; затем следует сочетание address
и address’
(логической инверсии), а заканчивается сигнал command
и command’
. Ничто из этого не оказалось применимо к сигналу, генерируемому пультом моего кондиционера: вначале шёл пакет импульсов 4,5 мс и пауза 4,5 мс; address
и address’
присутствовали только в первых двух датаграммах, но отсутствовали в третьей; вместо одного блока из command
и command’
было два блока команд, а в некоторых ситуациях второй блок command’
заменялся другой структурой (иными словами, Toshiba отказалась от блока чётности command’
и использовала этот раздел под другие цели).
[4] Кстати, именно из-за этого строгого тайминга мне пришлось использовать LIRC для отправки сигналов с моей стороны. Когда я попробовал передавать сигнал при помощи только одного кода Elixir, мне не удавалось подобрать тайминг достаточно верно.
Благодаря упомянутому в предыдущем разделе декодеру (позже исправленному, чтобы он различал части начала и конца блока) мой процесс работы стал похож на нечто такое:
- открываем SSH-подключение к моему приложению Nerves, чтобы получить доступ к приложению через IEx shell, не в
bash
или чём-то подобном; - вызываем функцию моего декодера, которая использует MuonTrap для запуска
mode2
, 3 секунды ждёт вывода, а затем прекращаетmode2
(mode2
выполняется бесконечно, но меня интересует сканирование только одной команды за раз); - нажимаем кнопку на пульте дистанционного управления кондиционером для сканирования отправляемого кода; делаем упор на внесение наименьших возможных изменений[5] по сравнению с ранее отсканированной командой, чтобы с лёгкостью различать части сигнала;
- берём декодированную строку CSV и вставляем её в мою огромную Google-таблицу.
[5] Например, на одном этапе я сканирую код, обозначающий режим охлаждения при 20,0°C с автоматической скоростью вентилятор. На следующем этапе я оставляю тот же режим и скорость вентилятора, но поднимаю температуру на один градус. Разница между этими двумя сигналами покажет, где в сигнале закодирована температура. (На самом деле, оказалось, что она закодирована в двух местах — половина градуса 20,5°C хранилась далеко от целой части.)
Когда я говорю «огромную», я подразумеваю действительно огромную Google-таблицу.
Эта таблица гораздо длиннее необходимого? Да, это так. Было ли весело её создавать. Да, было. Оценил ли я благодаря ей преимущества сверхширокого дисплея? Вам действительно нужен ответ на этот вопрос?[6]
[6] Если кто-то найдёт логику или причину подобного кодирования температуры, то я с удовольствием выслушаю. Она не увеличивается монотонно со значением температуры и я не смог найти никакой логики даже с учётом разной endianness.
Единственное, с чем было немного сложно разобраться, был самый последний байт — контрольная сумма. К счастью, это оказалась простая сумма байтов в третьей датаграмме (минус часть с переполнением); это даже не потребовало учёта различий в endianness.
Имея на руках готовую таблицу, можно было приступать к написанию (а потом и исправлению) модуля кодировщика, который сможет преобразовать любое состояние в представление сигнала, понятное кондиционеру. К счастью, кондиционер в моей гостиной уже был умным и в то же время понимал сигнал, испускаемый пультом кондиционера в спальне; поэтому я мог воспользоваться преимуществом цикла обратной связи: пробуем отправить сигнал → проверяем в мобильном приложении умного кондиционера, понял ли он его так, как должен.
Спустя несколько часов я смог полностью управлять кондиционером из сессии SSH, а ещё через несколько часов у меня уже был очень простой, но функциональный веб-интерфейс, созданный при помощи Phoenix и LiveView[7]. Меня не перестаёт удивлять то, что благодаря всего двум строкам на LiveView можно синхронизировать состояние между двумя браузерными окнами на двух разных устройствах. Поистине волшебное чувство!
[7] Забавный факт: фундамент UI я сгенерировал, отправив фото пульта своего кондиционера на Claude.ai и попросив ИИ сгенерировать код на HTML и TailwindCSS, который бы был похож на предмет из реального мира.
Я решил сделать интерфейс пользователя на японском, и не спрашивайте меня, зачем.
Меня сильно впечатлила простота и увлекательность разработки моего первого IoT-устройства при помощи Nerves, поэтому я уже придумываю новые идеи для дальнейшей реализации.
Кроме того, я благодарю сообщество Elixir/Nerves в канале #nerves
на официальном Discord-сервере Elixir. Оно стало источником вдохновения и оказало огромную поддержку!
Автор: ru_vds