Материалы по UART-дисплею TwoTrees SK1 и альтернативному клиенту для работы с Moonraker/Klipper после перехода на ванильный Armbian.
Дисплей питается от 5V, но уровни RX/TX - 3.3V.
Не подключайте UART дисплея к 5V TTL. Общую землю (GND) подключать обязательно.
- Запуск Rust-клиента
vaptechclientс async UART service для штатного HMI-дисплея. - Разбор входящих HMI-событий: startup
0x91, touch, numeric input для ползунов. - Навигация по базовым экранам: Home, Print, Files, Fans, Settings/Network/Calibration.
- Moonraker WebSocket subscribe и reducer состояния принтера.
- Read-only WebSocket watchdog через
server.info: если поток событий замолчал, клиент проверяет соединение и сам уходит в reconnect. - Live-отрисовка температур сопла/стола, target values, прогресса печати и времени.
- Автовосстановление экрана печати после init дисплея, если печать уже идёт.
- Орка/G-code thumbnail pipeline: download через Moonraker, extract, TJC encode, cache, асинхронная доставка на page 2 и page 77.
- Проверка актуальности thumbnail через
RenderTarget, чтобы не залить старый эскиз в уже сменившийся экран или слот файла. - Иконки подсветки и вентиляторов на странице печати обновляются от фактического состояния Moonraker.
- Управление подсветкой через
SET_PIN PIN=caselight. - Управление тремя вентиляторами с HMI page 6: model/part fan, auxiliary/side fan, case/filter fan.
- Pause/resume печати с page 2 component 5: при печати отправляет pause, на паузе отправляет resume.
- Настраиваемый info-лог для touch/numeric HMI-событий без сырого UART hex.
- Unit/integration tests покрывают parser, reducer, renderer, runtime, thumbnail pipeline и print pipeline.
tools/display_uart_demo_host- Python-демо/песочница для дисплея.display_uart_raw_protocol.md- список известных UART-команд и входящих кадров.vaptechclient/- Rust-клиент для HMI-дисплея и Moonraker.esp32-tft/- экспериментальный клиент под ESP32-C6.
vaptechclient - текущий Rust-клиент. Он запускает:
- async UART service для дисплея;
- Moonraker WebSocket client;
- центральный runtime;
- reducer состояния принтера;
- renderer команд HMI;
- thumbnail pipeline для Orca/G-code preview.
Moonraker write-path включён точечно. Сейчас из UI обратно в принтер отправляются
только явно разрешённые runtime команды: подсветка, вентиляторы, target
температуры, pause/resume. Остальные MoonrakerRequest намеренно отбрасываются,
чтобы новые кнопки не начали внезапно управлять принтером без явного подключения.
Главный принцип:
Intent
↓
Request
↓
Confirmation
↓
State
↓
Render
UI не делает optimistic update. Например, нажатие подсветки не меняет
AppState.lights.case_light напрямую. Оно создаёт MoonrakerRequest, Moonraker
выполняет команду, затем WebSocket присылает фактический статус, reducer обновляет
AppState, и только после этого renderer меняет иконку.
Основные слои:
src/hmi/- протокол HMI: команды, события, frame buffer, parser, serial service.src/moonraker/- WebSocket client, parser Moonraker JSON, события принтера.src/app/state.rs- единое состояние приложения.src/app/reducers/- применение внешних событий кAppState.src/app/runner.rs- application core: принимаетAppEvent, меняет состояние и складывает outputs.src/runtime.rs- async glue: доставляет события в runner и отправляет HMI/Moonraker/thumbnail effects.src/ui/route.rs- таблицаpage/component -> UiIntent.src/ui/intent.rs- семантические намерения пользователя.src/ui/action_handler.rs- guards и преобразование intent в изменения HMI state / Moonraker requests.src/ui/render_target.rs- решает, что сейчас нужно показать на дисплее.src/ui/render_full.rs- полная отрисовка выбранногоRenderTarget.src/ui/render_diff.rs- минимальная отрисовка изменений между old/newAppState.src/ui/components.rs- vendor mapping: физические компоненты HMI (b5,b6,pic 2/3).src/thumbnail/- извлечение preview из G-code, TJC encoding, cache, worker.
Состояние HMI и состояние принтера разделены:
HmiState
current_screen
selected move/file/etc
PrinterState / PrintState / TemperatureState / FanState / LightState
приходят из Moonraker/reducer
Moonraker reducer не должен менять выбранный пользователем экран. Он обновляет только состояние принтера. Выбор визуального представления делается отдельно:
AppState
↓
resolve_render_target()
↓
RenderTarget
↓
render_full/render_diff
Пример:
HmiState.current_screen = Home
PrinterStatus = Printing
=> RenderTarget::Home(HomeMode::Printing)
=> page 2
Если пользователь ушёл в Files во время печати:
HmiState.current_screen = Files
PrinterStatus = Printing
=> RenderTarget::Files
То есть активная печать не крадёт экран пользователя.
UART bytes
↓
FrameBuffer
↓
parse_frame()
↓
HmiEvent::Touch { page, component }
↓
route_touch(page, component)
↓
UiIntent
↓
intent_is_blocked_by_printer_state()
↓
apply_hmi_intent()
↓
moonraker_requests_for_intent()
↓
render_diff(old_state, new_state)
route.rs не знает про AppState. Это простая таблица известных кнопок.
action_handler.rs знает про состояние и решает:
- можно ли выполнить intent в текущем состоянии принтера;
- нужно ли поменять
HmiState; - нужно ли создать
MoonrakerRequest.
WebSocket message
↓
parse_moonraker_message()
↓
Vec<MoonrakerEvent>
↓
AppEvent::Moonraker(...)
↓
reduce_moonraker_event()
↓
AppState
↓
render_diff(old_state, new_state)
Подписка WebSocket сейчас включает:
print_statsvirtual_sdcardextruderheater_bedtoolheadoutput_pin caselightfanfan_generic Side_fanfan_generic Filter_fan
Moonraker client отслеживает время последнего входящего WebSocket frame. Если
поток молчит дольше 10s, клиент отправляет read-only heartbeat server.info и
ждёт ответ до 2s. Если ответа нет или запись/чтение падает, текущий WebSocket
закрывается через ошибку, а внешний loop выполняет reconnect. Heartbeat не
использует write-path принтера и не меняет состояние Klipper.
Физические имена компонентов HMI не должны расползаться по проекту.
Правильно:
render_case_light_icon(target, state.lights.case_light)Неправильно:
HmiCommand::raw("b6.picc=3")Компоненты и номера картинок держим в src/ui/components.rs. Например:
CaseLightIcon:
Home -> b5
Print -> b6
off -> pic 2
on -> pic 3
Rust toolchain на принтер ставить не нужно. Нормальный путь сейчас такой:
ноут/ПК
cargo build --release --target aarch64-unknown-linux-musl
↓
принтер
/usr/local/bin/vaptechclient
/etc/vaptechclient/config.toml
/etc/systemd/system/vaptechclient.service
Готовые шаблоны лежат в vaptechclient/packaging/.
Собрать пакетный набор:
cd vaptechclient
./packaging/build-release.shПоложить на принтер и перезапустить service:
cd vaptechclient
./packaging/deploy.sh 192.168.0.20Первым делом после установки проверьте на принтере:
sudo nano /etc/vaptechclient/config.toml
sudo systemctl status vaptechclient
sudo journalctl -u vaptechclient -fВ конфиге особенно важен путь к UART дисплея:
[hmi]
serial = "/dev/ttyS1"
baud = 115200Если клиент запускается прямо на принтере, Moonraker обычно указывается как
127.0.0.1:7125.
- Добавить семантический intent в
src/ui/intent.rs.
pub enum UiIntent {
ToggleCaseLight,
// ...
}- Привязать HMI page/component в
src/ui/route.rs.
(2, 6) => UiIntent::ToggleCaseLight,-
Если действие опасно во время печати, добавить guard в
intent_is_blocked_by_printer_state(). -
Если intent меняет только интерфейс, обработать его в
apply_hmi_intent(). -
Если intent должен управлять принтером, вернуть
MoonrakerRequestизmoonraker_requests_for_intent(). -
Добавить unit tests:
- route test:
page/component -> UiIntent; - action handler test: intent создаёт правильный request или меняет HMI state;
- runner test: touch проходит весь путь до outputs.
- Добавить событие в
src/moonraker/event.rs.
pub enum MoonrakerEvent {
CaseLightChanged(bool),
// ...
}- Распарсить JSON в
src/moonraker/parser.rs.
events.push(MoonrakerEvent::CaseLightChanged(value > 0.5));-
Если нужен новый object, добавить его в подписку
objects_subscribe_message()вsrc/moonraker/client.rs. -
Обновить reducer в
src/app/reducers/moonraker.rs.
MoonrakerEvent::CaseLightChanged(enabled) => {
state.lights.case_light = enabled;
}-
Добавить render diff/full, если изменение должно быть видно на экране.
-
Добавить tests:
- parser test с реальным JSON-фрагментом;
- reducer test;
- render_diff test, если событие меняет визуальное состояние.
- Добавить request в
src/ui/effect.rs.
pub enum MoonrakerRequest {
SetCaseLight(bool),
// ...
}-
Создать request из intent в
moonraker_requests_for_intent(). -
В
src/runtime.rsявно разрешить пересылку этого request. По умолчанию runtime не должен отправлять новые управляющие команды в Moonraker. -
В
src/moonraker/client.rsсериализовать request в JSON-RPC или G-code script.
{
"jsonrpc": "2.0",
"method": "printer.gcode.script",
"params": {
"script": "SET_PIN PIN=caselight VALUE=1"
},
"id": 42
}- Добавить
info-лог перед отправкой. Это сильно упрощает проверку на живом принтере:
sending caselight command to Moonraker enabled=true
-
Не менять
AppStateиз request path. Дождаться подтверждения черезMoonrakerEvent. -
Добавить tests:
- action handler создаёт request;
- runtime пересылает только разрешённый request;
- Moonraker client генерирует корректный JSON.
- Добавить семантический helper в
src/ui/components.rs.
pub fn render_case_light_icon(target: RenderTarget, enabled: bool) -> Vec<HmiCommand>-
Внутри helper держать mapping
RenderTarget -> component. -
Подключить helper в
render_full. -
Подключить helper в
render_diff, если состояние может меняться без смены страницы. -
Добавить tests на mapping и render output.
Thumbnail не рендерится синхронно в UI:
render_full()
↓
RenderTarget::thumbnail_request(...)
↓
ThumbnailRequest
↓
worker
↓
cache
↓
ThumbnailReady
↓
current RenderTarget::accepts_thumbnail(...)
↓
HmiCommand cp.write(...)
Для страницы печати и страницы результата используется один pipeline, но разные
ThumbnailTarget.
Правило создания и доставки thumbnail намеренно лежит рядом с visual target:
RenderTarget::thumbnail_request(&AppState)решает, нужен ли экрану эскиз и какойThumbnailTargetиспользовать;RenderTarget::accepts_thumbnail(&AppState, &ThumbnailKey)решает, можно ли лить готовый thumbnail в текущий экран.
Runtime не знает смыслов PrintPage, ResultPage и FileSlot; он только
доставляет готовый thumbnail, если текущий RenderTarget его принимает. Если
пользователь ушёл с экрана или слот файла уже занят другим path, готовый эскиз
остаётся в cache и не отправляется в UART.
Пример:
[printer]
host = "192.168.0.20"
moonraker_port = 7125
[hmi]
serial = "/dev/ttyUSB0"
baud = 115200
[log]
level = "info"
touch_level = "info"
numeric_level = "info"Полный пример лежит в vaptechclient/config/config.example.toml.
Из каталога vaptechclient:
cargo run -- --config config/config.example.tomlС debug-логами:
RUST_LOG=debug cargo run -- --config config/config.example.tomlС максимально подробными логами UART/Moonraker:
RUST_LOG=trace cargo run -- --config config/config.example.tomlRUST_LOG имеет приоритет над [log].level из конфига.
error- только критические ошибки сервисов.warn- обрывы Moonraker/WebSocket и восстановимые ошибки.info- старт runtime, подключение к Moonraker.debug- разобранные события Moonraker, HMI-команды после render diff.trace- сырые входящие сообщения Moonraker и подробный UART-поток.
Для обычной проверки дисплея удобнее debug. Для разбора протокола - trace.
Отдельно можно настроить уровень логирования разобранных HMI-событий дисплея:
[log]
touch_level = "info"
numeric_level = "info"Поддерживаются off, trace, debug, info, warn, error.
touch_level логирует HmiEvent touch с полями page и component.
numeric_level логирует HmiEvent numeric с полями page, component и
value. Это уже разобранные события, без сырого hex UART.
cargo fmt
cargo testДисплей присылает init как одиночный байт:
0x91
Это не terminated-frame и не 91 ff ff ff.
После init клиент смотрит cached state:
- если печать активна или на паузе - отправляет
page 2и полный render страницы печати; - иначе отправляет
page 0.
Это важно после краткой потери питания дисплея: печать не прерывается, а экран перерисовывается из текущего состояния клиента.