В офисе давно лежал старый Raspberry Pi. Раньше он работал MIDI-синтезатором, потом просто пылился. Мы решили повесить на него дашборд Grafana с живой статистикой по сборкам, чтобы команда видела прогресс на мониторе в офисе.

Первая идея была открыть Grafana в браузере в режиме киоска. Не сработало. Chromium, который идёт в этой сборке Raspberry Pi OS, слишком старый. Современная Grafana использует JS-фичи, которых в этом движке просто нет. Страница показывала ошибку.
И тут мы вспомнили, что Avalonia UI прекрасно работает на одноплатных компьютерах, и написали нативное приложение на Avalonia, которое рисует те же данные напрямую.
Вытаскиваем данные из Grafana
Есть API, которым пользуется сама Grafana внутри. Это /api/ds/query с сервис-аккаунтом и тем же SQL, что лежит в панелях дашборда:
var payload = new { queries = new object[] { new { refId = "A", datasource = new { type = "grafana-postgresql-datasource", uid = "<uid>" }, rawSql, format = "table" } }, from = from.ToUnixTimeMilliseconds().ToString(), to = to.ToUnixTimeMilliseconds().ToString() }; using var response = await _http.PostAsJsonAsync("/api/ds/query", payload, ct);
Ответ приходит в виде фреймов. Разбираем его в обычный List<Dictionary<string, object?>> и отдаём во ViewModel.
Карту и график рисуем сами через DrawingContext, без сторонних чарт-библиотек для карты (точки, подписи, круги по размеру значения) и с Eremex.Avalonia.Charts для самого графика сборок.
Запустили, открылось окно, карта и график заполнились реальными данными из Grafana. Подход рабочий.

Дальше встал вопрос, как это показать на самом Pi.
Настраиваем режим киоска на RPi
Одноплатник будет показывать только дашборд. Рабочий стол не нужен. Оконный менеджер тоже лишний. Приложение занимает весь экран. Взяли Raspberry Pi OS Lite. Скачали образ под архитектуру устройства (у нас Pi 3B, значит arm64) с официального сайта Raspberry Pi.
Запись на SD-карту через rpi-imager в режиме командной строки:
winget install --id RaspberryPiFoundation.RaspberryPiImager -e & "C:\Program Files\Raspberry Pi Ltd\Imager\rpi-imager.exe" --cli "путь к .img.xz" "\\.\PhysicalDriveN"
Перед записью стоит перепроверить, что PhysicalDriveN — это действительно съёмная SD-карта, а не системный диск. В Windows это видно по полю MediaType у Get-CimInstance Win32_DiskDrive.
Свежий образ не создаёт пользователя и не включает SSH сам по себе. До первой загрузки на загрузочном разделе нужно положить два файла:
# ssh — пустой файл, включает SSH-сервер # userconf.txt — одна строка username:password-hash PASS=$(openssl rand -base64 12 | tr -d '/+=' | cut -c1-16) HASH=$(openssl passwd -6 "$PASS") echo "pi:$HASH" > userconf.txt
Запуск без окна
Обычное десктопное Avalonia-приложение, которое уже работало на машине разработчика, нужно немного доработать. В обычном режиме Avalonia создаёт Window, а он живёт внутри оконного менеджера — на headless-образе его нет вообще. Нужен режим, где приложение рисует прямо в экран через DRM/KMS, без X11 и без Wayland.
Ничего изобретать не нужно, Avalonia официально поддерживает такой режим. Есть официальная инструкция по embedded Linux; по сути меняются три небольших места, десктопный запуск при этом остаётся рабочим.
Первое — подключаем пакет Avalonia.LinuxFramebuffer, в нём живёт DRM-бэкенд:
dotnet add package Avalonia.LinuxFramebuffer
Второе — точка входа. Обычный запуск через StartWithClassicDesktopLifetime оставляем для десктопа, а при флаге --drm уходим в StartLinuxDrm, который рисует прямо в фреймбуфер:
public static int Main(string[] args) { var builder = BuildAvaloniaApp(); // На плате рисуем прямо в DRM/KMS; на десктопе для разработки — обычное окно. if (args.Contains("--drm")) return builder.StartLinuxDrm(args, card: null); builder.StartWithClassicDesktopLifetime(args); return 0; }
Третье — App.axaml.cs. В оконном режиме Avalonia отдаёт IClassicDesktopStyleApplicationLifetime и ждёт Window; в DRM-режиме — ISingleViewApplicationLifetime, которому нужен не Window, а обычный UserControl на весь экран. Обрабатываем оба случая:
public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { // Разработка на десктопе: обычное окно. desktop.MainWindow = new MainWindow { DataContext = new MainWindowViewModel() }; } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) { // Киоск на плате: ни окна, ни оконного менеджера — только вью на весь экран. singleView.MainView = new MainView { DataContext = new MainWindowViewModel() }; } base.OnFrameworkInitializationCompleted(); }
Сам интерфейс при этом переезжает из Window (MainWindow) в обычный UserControl (MainView) — та же разметка и та же ViewModel, просто корневой элемент другой. На Windows приложение по-прежнему открывается в окне для разработки, а на плате с --drm занимает экран напрямую.
Из своего добавили одну правку. В примере из документации есть код, который читает нажатия клавиш с консоли, чтобы служебный ввод не отображался на экране поверх приложения:
private static void SilenceConsole() { new Thread(() => { Console.CursorVisible = false; while (true) Console.ReadKey(true); }) { IsBackground = true }.Start(); }
Под systemd stdin не настоящий терминал, и Console.ReadKey там падает с исключением. Процесс валится сразу после старта. Лечится проверкой перед запуском потока:
private static void SilenceConsole() { if (Console.IsInputRedirected) return; // ... остальное без изменений }
Настройка Embedded Linux
В Lite-образе не хватает нескольких графических библиотек. Вот этот список необходимо доустановить:
sudo apt-get update sudo apt-get install -y \ libgbm1 libinput10 libudev1 libdrm2 \ libegl1 libgles2 libgl1 libglx-mesa0 \ libfontconfig1 libfreetype6
И добавить пользователя в группы, которые дают доступ к DRM и input устройствам:
sudo usermod -aG video,render,input pi
Публикация приложения и настройка автозапуска
Публикуем self-contained однофайловую сборку под нужную архитектуру:
dotnet publish -c Release -r linux-arm64 --self-contained true \ -p:PublishSingleFile=true \ -p:IncludeNativeLibrariesForSelfExtract=true \ -o publish/linux-arm64
На Pi не нужен отдельно установленный рантайм .NET. Копируем один файл и даём права на запуск.
Настроим автозапуск дашборда через systemd:
[Unit] Description=GrafanaRPI kiosk dashboard After=network-online.target Wants=network-online.target [Service] ExecStart=/home/pi/GrafanaRPI --drm Environment=GRAFANA_TOKEN=<токен сервис-аккаунта> Environment=GRAFANA_RANGE_HOURS=24 Restart=always RestartSec=5 User=pi [Install] WantedBy=multi-user.target
sudo systemctl daemon-reload sudo systemctl enable --now grafanarpi.service
Restart=always пригодился не только на будущее. Пока мы искали недостающие библиотеки, сервис падал и перезапускался много раз подряд. Смотреть за этим удобно через:
sudo journalctl -u grafanarpi.service -f
На случай утечки памяти
Restart=always спасает от падения процесса, но не от утечки, которая медленно съедает всю память устройства. На 1 ГБ RAM это не абстрактный риск. Если приложение когда-нибудь начнёт расти без остановки, без ограничения оно утащит за собой весь Pi, а не только себя, прежде чем OOM killer успеет завершить проблемный процесс.
На всякий случай решили сделать что-то вроде restart: always из Docker, только на уровне systemd: жёсткий потолок по памяти плюс гарантированный перезапуск, если в него упёрлись. Добавили cgroup-лимит прямо в unit:
[Service] ... MemoryMax=400M OOMPolicy=kill
На этом дашборд заработал именно так, как задумывался с самого начала: включили питание, через несколько секунд на мониторе карта и график, без единого лишнего экрана по пути.
Расход памяти у нативного Avalonia-приложения и у браузера
На развёрнутом устройстве (Pi 3B, 1 ГБ памяти) реальное потребление приложением:
$ ps -o pid,rss,vsz,cmd -p <pid> PID RSS VSZ CMD 713 234 MB 260 MB /home/pi/GrafanaRPI --drm
Это весь процесс целиком: рантайм .NET, Avalonia, Skia, вывод через DRM и наши данные.
Поставили свежий Chromium прямо на это устройство. Через минимальный X-сервер открыли настоящую страницу Grafana в режиме киоска и сравнили free -h до и после запуска:
used |
|
|---|---|
До запуска Chromium |
166 MiB |
После запуска Chromium |
511 MiB |
Разница, то есть реальная стоимость браузера с открытой Grafana, около 345 МБ. Это Xorg, сам Chromium и его отдельные процессы: рендерер (даже два, один под служебный webui), GPU-процесс, zygote-процессы, сетевой и storage-сервисы, каждый со своим crashpad-обработчиком.
345 МБ против 234 МБ у Avalonia. Разница не катастрофическая, но на устройстве с 1 ГБ памяти заметная сразу: меньше запаса на всё остальное, ближе к свопу при любой дополнительной нагрузке.
Проверка на другой плате: Orange Pi PC
Нашли ещё одну довольно древнюю плату — Orange Pi PC первой версии: Allwinner H3, четыре ядра Cortex-A7, но уже 32-битные (ARMv7, не ARMv8), и 1 ГБ памяти. Armbian вместо Raspberry Pi OS, поскольку официальный образ Raspberry Pi на чужом SoC не заведётся.

Приложение не меняли, изменился только RID на linux-arm у команды dotnet publish. Но по дороге поймали ещё три реальные проблемы, которых на Raspberry Pi не было:
Не хватало ICU. Минимальный образ Armbian не тянет за собой libicu, и .NET сразу падает с Couldn't find a valid ICU package. Ставить весь пакет ради этого не стали, просто выключили глобализацию:
Environment=DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
DRM выбрал не то устройство. На этой плате в /dev/dri два узла: card0 это sun4i-drm, настоящий видеоконтроллер, а card1 это lima, драйвер GPU Mali-400, который вообще не умеет mode-setting. Авто-определение Avalonia выбрало второй и падало с drmModeGetResources failed. Добавили в приложение переменную окружения, которая явно указывает нужную карту, вместо того чтобы полагаться на автоопределение.
Самое интересное: ложные ошибки TLS-сертификата. HTTPS-запросы к Grafana падали с NotTimeValid, хотя сертификат был абсолютно валиден, а часы на устройстве шли верно. Проверили независимо через curl и openssl s_client, оба подтвердили, что с сертификатом всё в порядке. Оказалось, это известный баг .NET 8 именно на 32-битном ARM с современной glibc с 64-битным time_t: нативная обвязка над OpenSSL неправильно сравнивает даты сертификата (issue на GitHub, исправлено только в .NET 9). Поэтому для встраиваемых проектов имеет смысл использовать .NET 10.
Раз уж плата другая, заодно прогнали те же измерения памяти на ней:
Raspberry Pi 3B (arm64) |
Orange Pi PC (armhf, 32-bit) |
|
|---|---|---|
Avalonia |
234 МБ |
164 МБ |
Chromium + X |
345 МБ |
210 МБ |
Везде на 32-битной плате память меньше, разница в районе 30-40%. Ожидаемо: указатели и часть внутренних структур вдвое меньше, накладные расходы падают у обоих решений одинаково.
От офисного дашборда к промышленному embedded
Мы делали игрушку для офиса: повесить статистику на старый Pi. Но пока возились, поймали себя на мысли, что весь получившийся рецепт — headless-образ, отрисовка прямо в экран через DRM без оконного менеджера, один self-contained файл, автозапуск через systemd и приложение, которое ходит только во внутренний API и больше никуда — это ровно то, что нужно промышленному embedded. Ту же мысль подробно разбирает Avalonia в своём блоге про industrial embedded UI на .NET; интересно посмотреть на свой проект под этим углом.
Образ целиком принадлежит вам. У нас всё устройство — это один образ ОС плюс один бинарник. Нет отдельного рантайма, который надо лицензировать или обновлять, нет браузера, который сам решит обновиться и что-нибудь сломать, нет ничего, что «звонит домой». Для промышленного применения это ключевое: такие устройства живут в эксплуатации по десять, пятнадцать, двадцать лет, и через десять лет прошивку нужно уметь пересобрать бит в бит. Зафиксированный образ плюс один файл — как раз про эту воспроизводимость.
Изолированные сети. Наше приложение общается только с внутренней Grafana, наружу в интернет не ходит вообще. В офисе это просто приятно, а в промышленности часто обязательно: оборонка, энергетика, фарма работают в air-gapped сетях как политика. Инструмент, которому для активации или лицензии нужен интернет, там отпадает сразу — и, как отмечает тот же блог, это нередко более жёсткий стоп-фактор, чем скорость отрисовки или совместимость с железом.
Один код на всё. Тот же UserControl, что на плате занимает экран через DRM, на рабочей машине открывается в обычном окне. Разработка и отладка на Windows, деплой на ARM Linux, код один — карту и график мы отлаживали в окне на десктопе, а на плату уехало ровно то же самое. Avalonia кроме DRM умеет ещё Windows, macOS, WebAssembly и мобильные: понадобись завтра тот же дашборд в вебе или в приложении у инженера в поле — переписывать логику не пришлось бы.
Итоги
Сделали дашборд, аналогичный Grafana, на RPi с помощью Avalonia UI. Официальный guide Avalonia по embedded Linux рабочий, но с оговорками: на Lite-образе графические библиотеки приходится доставлять руками, а пример кода — править под systemd, иначе он падает на старте. По памяти нативное решение легче браузерного киоска примерно в полтора раза (234 МБ против 345 МБ на этом же Pi), и на устройстве с 1 ГБ это заметно сразу. Перенос на Orange Pi PC не потребовал никаких изменений в приложении — поменялся только RID при публикации.
Наша команда уже несколько лет развивает EMXControls — набор компонентов для Avalonia (в том числе Eremex.Avalonia.Charts, на котором в этом проекте построен график сборок). Этой осенью выходит крупный релиз — о нём скоро напишем отдельно. Если у вас коммерческий проект с UI на Avalonia, напишите нам, поможем.