[Перевод] Отображение веб-контента на дисплее E-Ink

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

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

The final result - e-ink display with the timetable

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

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

Выбор подходящего оборудования

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

Поэтому я рекомендую продукты Soldered. В частности, особенно привлекательной мне показалась линейка дисплеев на электронных чернилах Inkplate. В них есть микроконтроллер ESP32, который очень походит для режима сна и крайне низкого энергопотребления. Он имеет встроенный Wi-Fi для получения данных из Интернета. У него есть зарядное устройство для аккумулятора и таймер реального времени (RTC) для обновления дисплея по графику. Кроме того, у него есть документация со множеством примеров, подходящих в нашей ситуации. Лично я выбрал Inkplate 6COLOR с дисплеем 600×448 пикселей и 6 цветами. Я знаю, что изменения в школьных расписаниях всегда обозначаются красным, поэтому цвета могут пригодиться. Я выбрал комплект «всё включено» за 169 евро, состоящий из платы, дисплея, корпуса и аккумулятора.

После получения комплекта я немного поигрался с разными примерами из публичных репозиториев производителя. Плату можно программировать двумя способами: или при помощи Arduino, или на MicroPython. Поначалу показалось, что в примерах и документации по Arduino больше полезной информации. Однако чтобы снизить количество строк кода, не зная при этом реальных последствий и компромиссов, я решил попробовать MicroPython. Благодаря примерам мне быстро удалось научиться отрисовывать на экране текст и фигуры, подключаться к Интернету, отображать состояние аккумулятора и, что важно, отображать цветные картинки.

Получение контента веб-сайта

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

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

Получать контент веб-сайта при помощи Playwright достаточно легко. Вот код для навигации по странице к расписанию:

from playwright.sync_api import sync_playwright, Playwright
def run(p: Playwright):
    browser = p.chromium.launch()
    context = browser.new_context(
        http_credentials={"username": "$USER", "password": "$PASSWD"},
        viewport={ 'width': 270, 'height': 700 }
    )
    page = context.new_page()
    page.goto("$URL")
    page.get_by_role("link", name="Auswahl").click()
    page.get_by_text("5d").click()

Этот код запускает браузер (Chromium), задаёт размер окна просмотра, открывает новую страницу с нужным URL и нажимает на этой и следующей странице кнопку. Стоит отметить, что в этом случае мне пришлось использовать достаточно необычный механизм Basic HTTP Authentication — на большинстве страниц можно пропустить http_credentials=... и вместо этого заполнить форму логина именем и паролем, а затем нажать на кнопку. Чтобы получить показанный выше код, нужно или изучить исходный HTML веб-сайта, или воспользоваться codegen Playwright для записи действий на веб-сайте и автоматической генерации кода.

Получение контента нужным нам способом

Если входить на веб-сайт школы вручную, то на этом этапе мы увидим вот такую страницу:

Screenshot of the timetable

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

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

Как оказалось, в Playwright есть готовые решения. Можно делать скриншот не только всей страницы, но и конкретных элементов этой страницы. В данном случае можно найти элемент HTML с классом ui-content и сделать скриншот только этого элемента. Более того, мы можем настроить стили элементов на скриншоте, чтобы они отображались на дисплее с электронными чернилами нужным нам образом. На самом деле, сделать это можно множеством разных способов, например, использовать функцию evaluate для элементов, воспользоваться свойством style функции screenshot или, как поступил я, использовать функцию страницы add_script_tag. В моём случае я заменил серые фоны белыми, а также уменьшил некоторые паддинги и границы. «Уровень зума» можно настроить при помощи свойств viewport и deviceScaleFactor контекста браузера, как мы делали это ранее. Показанный ниже код выполняет все эти действия, дополняя показанную выше функцию run, в том числе закрывает браузер и возвращает изображение со скриншотом.

    js_scipt  = """
    document.querySelector('.ui-content').style.background="#FFF";
    document.querySelector('.ui-content').style.padding="5px";
    document.querySelector('#plan').style.margin="0.3em 0";
    document.querySelectorAll('#plan li').forEach(elem => elem.style.background="#FFF");    
    """
    page.add_script_tag(content=js_scipt)

    element = page.locator('.ui-content')
    image = element.screenshot()
    
    context.close()
    browser.close()
    return image

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

Screenshot of the timetable with new styles

Подготовка изображения для дисплея

Если библиотека устройства поддерживает отображение цветных PNG, я могу просто передать данные изображения получившегося скриншота и на этом закончить. На самом деле, библиотека Inkplate Arduino поддерживает это, но не библиотека Micropython (подробнее об этом ниже). Однако мне показалось удобнее полностью управлять созданием изображения на стороне сервера, например, выполнять поворот изображения, обрабатывать разные размеры скриншотов и манипулировать цветом.

Подобные манипуляции с изображением лучше всего выполнять при помощи библиотеки Pillow. Поэтому мы начнём нашу функцию с получения скриншота Playwright и преобразования его в объект изображения Pillow:

def get_full_image():
    # Получаем изображение области веб-сайта
    with sync_playwright() as playwright:
        png_data = run(playwright)

    # Преобразуем данные изображения в объект изображения Pillow
    screenshot = Image.open(io.BytesIO(png_data))

Далее мы определяем нашу палитру, состоящую из шести отображаемых цветов плюс белого. Определение этих цветов можно легко найти в документации Inkplate. Затем мы создаём новое изображение с этой ограниченной палитрой, что соответствует изображениям Pillow в режиме P. Функция image.quantize находит для каждого цвета изображения ближайший цвет из новой палитры. На этом этапе можно было поэкспериментировать с дизерингом, но я не стал этого делать. Дискретизированное изображение вставляется в новое изображение, чтобы размер всегда был одинаковым. Далее возвращаются байты изображения:

    # 6 цветов inkplate color
    palette = [
        0, 0, 0, # чёрный
        255, 255, 255, # белый
        0, 255, 0, # зелёный
        0, 0, 255, # синий
        255, 0, 0, # красный
        255, 255, 0, # жёлтый
        255, 153, 0, # оранжевый
    ]
    
    # Создаём изображение в режиме ограниченной палитры и белым фоном
    screen_size = (600, 448)
    result_img = Image.new('P', screen_size, 1)
    result_img.putpalette(palette)

    # Дискретизируем скриншот в ограниченную палитру
    screenshot = screenshot.quantize(palette=result_img, dither=1)
    screenshot = screenshot.rotate(90, expand=True)

    # Вставляем дискретизированный скриншот в готовое изображение
    crop_size = tuple(map(min, screenshot.size, screen_size))
    box = (0,0) + crop_size
    result_img.paste(screenshot.crop(box), box)
    
    return result_img.tobytes()

Передача изображения

После извлечения информации с веб-сайта и генерации изображения нам нужно сделать его доступным как веб-приложение в Интернете.

Для создания очень простого веб-сервера воспользуемся библиотекой Werkzeug:

from werkzeug.wrappers import Request, Response
from werkzeug.serving import run_simple

def application(environ, start_response):
    response = Response(get_full_image(), mimetype='application/octet-stream')
    return response(environ, start_response)

if __name__ == '__main__':
    run_simple('0.0.0.0', 5050, application) 

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

Для хостинга приложения есть множество вариантов. В идеале он должен быть легко развёртываемым, требовать минимального обслуживания и стоить не очень дорого. Возможно, у вас уже есть VPS, которым можно воспользоваться. В противном случае для хостинга можно выбрать Fly.io или Google Cloud Run. Я выбрал первый, потому что у меня уже есть там аккаунт.

В обоих случаях нам нужно создать контейнер Docker с нашим приложением и его зависимостями. То есть нам понадобится Python и пакеты Python pillow, Werkzeug и playwright. Также нам нужно установить браузер командой playwright install --with-deps chromium. Определив всё это в Dockerfile, создав аккаунт Fly.io и установив его CLI-инструмент flyctl, можно развернуть приложение простой командой fly launch. Мы получаем URL вида my-cool-scraper.fly.dev, позволяющий нам запрашивать изображение из Интернета!

Показ изображения на устройстве с дисплеем на электронных чернилах

Мне хочется получать и отображать изображение в четыре заданных момента дня, чтобы видеть обновления расписания перед школой, а также во второй половине дня, чтобы собрать портфель на следующий день. В промежутке между этим временем устройство должно работать в режиме низкого энергопотребления, чтобы оно могло максимально долго продержаться на одном заряде аккумулятора. Иными словами, дисплей должен просыпаться по «будильнику» четыре раза в день, обновлять изображение, а затем уходить в глубокий сон.

Сначала я попробовал реализовать всё это при помощи интеграции MicroPython, но столкнулся со множеством проблем. Невозможно было установить надёжное WiFi-соединение, запрос данных изображения через HTTPS с помощью библиотеки urequests иногда завершался сбоем, а какая-то имитирующая crontab библиотека не работала. У меня не было настроения разбираться во всех этих проблемах, поэтому я решил использовать библиотеку Arduino.

Это оказался хороший выбор. Не только повысилась надёжность получения изображения и работы WiFi, но и оказалось, что библиотека более понятная и функциональная. Например, в MicroPython мне приходилось использовать внешнюю библиотеку для получения изображений от конечной точки HTTPS, а изображения должны были иметь сырой формат с 4-битной глубиной цвета, потому что функция отрисовки изображений поддерживала только такой формат. В библиотеке Arduino есть функция, которая может автоматически получать изображения из конечных точек HTTPS, поддерживает форматы PNG и JPEG и даже может выполнять дизеринг.

Замечательное свойство библиотеки Arduino заключается в том, что для неё есть кучи примеров по большинству функций платы и множество готовых проектов. Основую часть важного для моего проекта кода я нашёл в примерах Show Pictures From Web и RTC with deep sleep.

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

При каждом просыпании устройства выполняются следующие действия:

  1. Считывается время текущего будильника (= причины для просыпания) и индекс будильника из RTC.

  2. Переход к следующему будильнику и его установка в RTC.

  3. Подключение к сети WiFi.

  4. Получение и отображение картинки со школьным расписанием.

  5. Отображение другой полезной информации, например, текущего времени.

  6. Включение в микроконтроллере ESP32 функции пробуждения ото сна по сигналам RTC.

  7. Переход в глубокий сон.

Стоит отметить, что я использую функции, поддерживаемые встроенным в устройство оборудованием. В частности, у него есть модуль таймера реального времени (Real-Time Clock, RTC), сообщающий текущее время, устанавливающий будильник для пробуждения устройства и даже хранящий ограниченное количество переменных. Я использую их для хранения индекса текущего будильника. Не забудьте только вставить батарейку-«таблетку»!

Функция set_alarm библиотеки Inkplate Arduino требует указать день месяца и день недели. Однако я не смог найти библиотечной функции для перехода от текущего дня к следующему, а обработка всех особых случаев дат явно выходила за рамки моих возможностей. Существует вторая функция установка будильника, в которой используется время Unix, она упрощает указание дельт времени, но работает не так надёжно. Поэтому я придумал очень хитрое решение: пробуждаю устройство в 23:59, если будильник поставлен на следующий день, жду минуту, а затем устанавливаю будильник уже на сегодня. Ну, а чего вы хотели, это ведь хобби-проект :)

Кстати, я обнаружил, что первая попытка получения изображения часто оканчивается неудачей. Подозреваю, что это может быть связано с автоматической остановкой и запуском на основании количества входящих запросов к машинам, которые хостятся на fly.io. Поэтому если функция получения изображения при первой попытке сообщает об ошибке, я просто запускаю её снова. Это обеспечивает мне надёжность в 99,99% — практически SLA, если не вчитываться в текст мелким шрифтом.

В заключение

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

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

Однако у проекта был один изъян: изначально я надеялся, что смогу завершить его примерно за двадцать часов непосредственной работы. Но в конечном итоге, по моим оценкам, я потратил на него, скорее, около пятидесяти часов. Впрочем, не меньше десяти часов было из этого времени было потрачено на попытки реализовать всё на MicroPython. Мне бы очень хотелось заранее знать, что библиотека MicroPython для устройств Inkplate — это гражданин второго сорта. Однако если вы хотите реализовать похожий проект, то, я думаю, сможете сделать это меньше чем за двадцать часов благодаря коду на GitHub.

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

© Habrahabr.ru