Кадр с OLED уходит в браузер как есть: биты пикселей без сжатия, а браузер рисует из них ту же картинку на canvas

Кадр с OLED уходит в браузер как есть: биты пикселей без сжатия, а браузер рисует из них ту же картинку на canvas

В первой части я рассказал об AtsPLC - контроллере на ESP8266, который программируется на языке ladder прямо на нем самом: на OLED 128x64 и тремя кнопками. А во время работы эта же схема показывает состояние процесса.

Первая часть: Ladder на экране 0.96": программирую ПЛК тремя кнопками

Теперь расскажу, как это сделано. Речь пойдет об ESP8266, у которого всего около 80 КБ оперативной памяти, о FreeRTOS и о C++.

Программа: сети и элементы

Программа устроена как дерево из трех уровней:

Ladder        список сетей, до 80
  Network     одна сеть, до 5 элементов
    LogicElement   контакт, таймер, компаратор, выход...

Сеть выполняется слева направо. Каждый элемент получает состояние предыдущего элемента и возвращает свое. Всего состояний три:

  • active - сигнал проходит;

  • passive - сигнал не проходит;

  • stop - сеть остановлена, и элементы ничего не считают.

Состояние stop нужно для режима Stop. В этом режиме выходы сбрасываются в 0, таймеры перестают просить пробуждения, а привязки к WiFi отпускают свои переменные.

Интерфейс элемента

Каждый элемент - это класс, унаследованный от LogicElement. Ему нужно реализовать четыре основных метода:

virtual bool DoAction(bool prev_elem_changed, LogicItemState prev_elem_state) = 0;
virtual void Render(FrameBuffer *fb, LogicItemState prev_elem_state, Point *start_point) = 0;
virtual size_t Serialize(uint8_t *buffer, size_t buffer_size) = 0;
virtual size_t Deserialize(uint8_t *buffer, size_t buffer_size) = 0;
  • DoAction - логика элемента. Он получает состояние предыдущего элемента и возвращает true, если его собственное состояние изменилось. По этому флагу контроллер понимает, что проход нужно повторить.

  • Render - рисует элемент в кадр, начиная с точки start_point, и сдвигает эту точку на свою ширину. Так следующий элемент знает, откуда рисоваться.

  • Serialize - пишет в буфер код типа элемента и его параметры. Весь файл программы занимает не больше 4 КБ.

  • Deserialize - читает параметры обратно и сразу проверяет их диапазоны. Например, таймер не примет задержку больше 99999 секунд. Если значение вне диапазона, файл программы считается битым.

При загрузке программы сначала читается код типа, по нему LogicElementFactory создает нужный объект, а дальше объект сам читает свои параметры через Deserialize.

Кроме этих четырех методов, элемент реагирует на кнопки при редактировании: SelectPrior, SelectNext, PageUp, PageDown, Change и Option. В итоге новый элемент - это один класс, где в одном месте собраны его логика, вид, хранение и редактирование.

Цикл контроллера без постоянного опроса

Цикл контроллера простой: прочитать входы, выполнить программу, записать выходы. Но крутить его без дела нет смысла, поэтому задача контроллера спит и просыпается только тогда, когда что-то случилось:

  • нажата кнопка;

  • изменился дискретный вход;

  • пришел запрос на пробуждение от элемента;

  • пришла команда от веб-интерфейса.

Все эти события приходят в одну группу событий FreeRTOS. Задача ждет ее с тайм-аутом, а тайм-аут равен времени до ближайшего запроса на пробуждение.

Один проход выглядит так:

do {
    FetchIOValues();
    bool any_changes_in_actions = ladder.DoAction();
    CommitChanges();
    if (!any_changes_in_actions) {
        break;
    }
    ...
} while (!expired);

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

Запросы на пробуждение

Элемент сам просит контроллер разбудить его через нужное время. При этом у запроса есть приоритет:

  • critical - для таймеров и генератора. Такой запрос срабатывает точно вовремя.

  • idle - для мигания курсора и обновления индикатора. Такой запрос можно объединить с соседним, если они ближе 100 мс друг к другу.

Поэтому контроллер не просыпается чаще, чем нужно. Например, мигание курсора просто подстраивается под соседнее пробуждение и не добавляет своих.

Одна блокировка на все дерево

С деревом программы работают сразу три задачи: задача контроллера (кнопки и выполнение программы), задача отрисовки и HTTP-сервер (загрузка и выгрузка программы). Поэтому доступ к дереву идет только под одним общим мьютексом.

Отрисовка на 128x64

Кадр собирается в буфере в памяти. Один пиксель занимает один бит, поэтому весь кадр - это всего 128 * 64 / 8 = 1024 байта. Отрисовкой занимается отдельная задача, и она отправляет кадр на SSD1306 по I2C, только если в нем что-то изменилось.

На экран помещаются две сети и строка состояния сверху. Шрифтов несколько: 4x7, 5x7, 6x12 и 8x14. Мелкие шрифты нужны, чтобы параметры помещались прямо в элемент. Например, генератор показывает сразу обе длительности.

Сигнал в цепи рисуется линией толщиной 2 пикселя. Активный участок - это сплошная линия, а пассивный - шахматный узор: верхняя и нижняя строки пикселей чередуются через каждые 2 пикселя. Даже на маленьком экране такой узор хорошо отличается от сплошной линии.

Как узнать, помещается ли элемент

При редактировании контроллер предлагает только те элементы, которые помещаются на оставшееся место в сети. Но ширину элементов я нигде в таблице не храню. Вместо этого элемент рисуется во временный буфер, и по тому, насколько сдвинулась точка отрисовки, становится понятна его ширина:

new_element->Render(frame_buffer, LogicItemState::lisPassive, &start_point);
...
bool element_not_fit = new_element_width > place_width;

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

Веб-интерфейс: копия экрана на canvas

Браузер и устройство показывают одну и ту же программу: браузер рисует кадры с OLED на canvas, а желтым выделяет сети, которые сейчас видны на устройстве

Браузер и устройство показывают одну и ту же программу: браузер рисует кадры с OLED на canvas, а желтым выделяет сети, которые сейчас видны на устройстве

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

Что это дает:

  • Не нужно писать и поддерживать второй интерфейс.

  • Все, что умеет устройство, сразу умеет и браузер. Новый элемент появляется в браузере без единой строки кода на JavaScript.

  • Нажатие из браузера попадает в ту же группу событий FreeRTOS, что и нажатие физической кнопки, поэтому код обработки один и тот же.

API

URI

Метод

Что делает

/devconfig

GET

размеры экрана

/bitmap

GET

кадр экрана

/keypress

POST

нажатие или отпускание кнопки

/program/download

GET

скачать программу

/program/upload

POST

загрузить программу

/workmode

GET, POST

режим работы

/update

POST

обновление прошивки

GET /bitmap отдает кадр как есть: 1024 байта, один бит на пиксель. Никакого PNG и никакого сжатия - браузер сам раскладывает биты по пикселям.

При этом кадр не отправляется зря. В ответе есть заголовок ETag со временем последнего изменения кадра, и браузер присылает его обратно в If-None-Match. Если кадр не изменился, сервер отвечает 304 без тела.

Нажатие кнопки выглядит так:

POST /keypress
{"key":38,"down":1}

Здесь 38 - стрелка вверх, 40 - стрелка вниз, а 16 - правый Shift, он работает как Select.

В браузере видно больше сетей

На OLED помещаются только две сети. В браузере места гораздо больше, и мне хотелось этим воспользоваться.

Для этого сервер в каждом ответе передает заголовок X-DataPaging:

{"offset":0,"count":7}

Здесь offset - номер первой сети на экране устройства, а count - сколько всего сетей. Браузер делает canvas высотой на всю программу и рисует текущий кадр в нужное место. Сети, которые сейчас не видны на устройстве, остаются на canvas от прошлых кадров. Когда сеть добавляется или удаляется, браузер сдвигает уже нарисованную часть. А желтый фон показывает, какие сети сейчас видны на OLED.

Строка состояния рисуется на отдельном canvas, поэтому она всегда видна сверху.

В итоге достаточно один раз пролистать программу на устройстве, и в браузере будет видна вся программа целиком. Картинка к тому же сохраняется в localStorage, так что после перезагрузки страницы она появляется сразу.

Где живет SPA

Фронтенд написан на Angular. После сборки файлы index.html, main-*.js и styles-*.css встраиваются прямо в прошивку через COMPONENT_EMBED_FILES, и HTTP-сервер отдает их из flash как есть. Файловая система для этого не нужна.

Безопасная загрузка программы

Загруженная программа сначала разбирается. Если файл битый, сервер отвечает 400, а устройство перечитывает старую программу.

Если же файл в порядке, новая программа заменяет старую только в оперативной памяти. Во flash она попадает позже: при смене режима работы или при подтверждении правки на устройстве. Пока этого не случилось, перезапуск вернет старую программу.

Так устройство защищено от файла, который правильный по структуре, но не помещается на экран - например, от сети из пяти широких элементов. Такой файл пройдет разбор, а потом при отрисовке сработает assert, и устройство перезапустится. Но перезапустится оно уже со старой программой.

Перезапуск без потери состояния

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

Для этого у ESP8266 есть RTC-память. Она переживает программный перезапуск, но не пропадание питания. В ней я храню:

  • признак горячего старта;

  • счетчик перезапусков;

  • какая часть программы была на экране и какая сеть была выбрана;

  • текущее время;

  • флаг режима Debug.

Целостность данных проверяется магическим числом и crc32. В результате после перезапуска экран открывается на том же месте, а время не сбрасывается.

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

Хранение: две копии и миграции

Flash разбит так:

ota_0      1 МБ   прошивка
ota_1      1 МБ   прошивка, для OTA
storage_0  128 КБ данные
storage_1  128 КБ копия данных
backups    512 КБ 4 слота для программ

Программа и настройки пишутся сразу в storage_0 и storage_1. А при чтении берется та копия, у которой сходится crc32. Так что если питание пропадет прямо во время записи, одна копия все равно останется целой.

Структура настроек меняется от версии к версии. Чтобы после обновления прошивки старые настройки не пропадали, я использую свою библиотеку MigrateAnyCppData. Она описывает цепочку версий структуры и переводит старые данные в новую версию.

WiFi: одно радио и очередь запросов

У ESP8266 одно радио, а режимов работы три: подключение к роутеру, поиск сетей и своя точка доступа. Поэтому в один момент может работать только один режим.

Какой режим нужен, решают элементы программы. WiFiStaBinding просит подключение к роутеру, WiFiBinding - поиск сети, а WiFiApBinding - точку доступа. Запросы попадают в очередь, и служба WiFi переключает режимы по очереди.

Тут есть одна тонкость. Если элемент перед контактом стал пассивным, контакт отменяет чтение своей переменной. Для привязки WiFi это значит, что отменяется и сам запрос: поиск, точка доступа или подключение останавливаются. Поэтому контакт, который читает привязанную переменную, лучше ставить первым в сети:

плохо:   ||--[AI > 130]--[V1 == 0]--...    точка доступа перезапускается, когда AI <= 130
хорошо:  ||--[V1 == 0]--[AI > 130]--...    точка доступа работает всегда

Патч для SNTP в lwIP

Время контроллер берет по SNTP, а SNTP в ESP8266_RTOS_SDK - это клиент из lwIP. В нем нашлась неприятная мелочь. Допустим, в настройках указано имя сервера времени, которого не существует, например с опечаткой. DNS возвращает ошибку, и клиент сразу пробует следующий сервер, без всякой паузы. Получается цикл повторов: DNS-запросы идут один за другим и зря нагружают сеть и процессор.

Исправление маленькое: после ошибки DNS следующая попытка откладывается на SNTP_RETRY_TIMEOUT, как и при других ошибках SNTP. Переход на запасной сервер при этом работает как раньше.

-    sntp_try_next_server(NULL);
+    sys_untimeout(sntp_try_next_server, NULL);
+    sys_timeout((u32_t)SNTP_RETRY_TIMEOUT, sntp_try_next_server, NULL);

Патч лежит в PLC_esp8266/patches/ и накладывается на SDK при make project_prepare. В апреле 2025 года я отправил его и в сам lwIP (https://savannah.nongnu.org/patch/?10518), но ответа пока нет.

Тесты и CI

Код покрыт юнит-тестами на CppUTest. Тесты собираются под Linux, а вместо функций SDK в них работают заглушки и моки. Сейчас тестов 785. С тестами можно спокойно переделывать ядро и не бояться, что что-то незаметно сломается.

Тесты нужны не только для проверки, но и для отладки. В VSCode для этого есть готовая конфигурация “Debug Tests”: по F5 тесты собираются и запускаются под gdb, и точки останова можно ставить прямо в коде контроллера, без устройства. Большую часть возможностей я так и разрабатывал - через тесты, а некоторые из них проверял на настоящем устройстве только в самом конце. Этот подход я использую давно и даже писал о нем статью: Упрощение отладки с помощью unit-тестов.

На каждый pull request и на каждый push в main GitHub Actions выполняет три шага по очереди:

  1. Проверка кода: форматирование через clang-format, сборка веб-интерфейса и статический анализ через cppcheck и clang-tidy.

  2. Юнит-тесты.

  3. Сборка прошивки для OTA.

Результат третьего шага - готовая прошивка AtsPLC-esp8266.ota.bin. Поэтому собирать проект самому не обязательно: прошивку можно скачать на странице сборки в разделе Actions.

Если на устройстве уже стоит AtsPLC, прошивку можно обновить по WiFi одной командой:

curl -H 'Content-Type:application/octet-stream' \
     --data-binary @AtsPLC-esp8266.ota.bin http://192.168.1.100/update

Здесь 192.168.1.100 - адрес устройства в вашей сети. Перед записью размер файла проверяется: прошивка больше раздела в 1 МБ не примется. После записи устройство переключается на новый раздел и перезапускается, а благодаря hotreload экран открывается на том же месте.

Инструменты

Tools/ladder.py переводит файл программы в текстовую схему:

python3 Tools/ladder.py ascii Demo/security-alarm.dat
16  ||--[V2 > 0]--[V2 <= 30]------------------------------>>

                                                      V2
17  >>--[SQW off 500ms on 500ms]---------------------(+)--||

Второй режим, cpp, делает код для Ladder::InitialLoad() - это программа, которая загружается по умолчанию. Получается удобно: программу можно собрать на устройстве, скачать через браузер и сразу превратить в код.

А Tools/Bmp2Ssd1306 переводит картинки в формат для SSD1306. С его помощью нарисованы значки элементов.

Итог

Для этого проекта удачными оказались такие решения:

  • Браузер рисует копию экрана. Интерфейс получается один на устройство и браузер, и браузер сразу умеет все, что умеет устройство.

  • Контроллер спит, пока его не разбудят. Таймеры срабатывают точно вовремя, а мелкие пробуждения вроде мигания курсора объединяются и не будят контроллер лишний раз.

  • Изменения сразу проходят по всей программе. Если выход или переменная изменились, проход тут же повторяется, и сети, которые их читают, видят новое значение в том же цикле. А ограничение в 20 мс не дает программе с ошибкой повесить контроллер.

  • Перезапуск без потери состояния. После обновления прошивки или ошибки экран открывается на том же месте, а время не сбрасывается. Режим Debug переживает перезапуск, но не пропадание питания.

  • Безопасная загрузка программы. Пока новая программа не сохранена во flash, перезапуск возвращает старую.

  • Две копии данных с crc32. Если питание пропадет во время записи, программа и настройки не потеряются.

Ссылки