Rename crate to escpos-vfd and add tokio support

Rename crate to escpos-vfd and add tokio support

Introduce a configurable crate-ready API with presets, typed errors,
optional Tokio async support, and expanded documentation.
This commit is contained in:
Кобелев Андрей Андреевич
2026-08-16 11:24:20 +05:00
parent efc53e6ef5
commit 244d907cc2
25 changed files with 3823 additions and 782 deletions
+272 -170
View File
@@ -1,236 +1,338 @@
# VFD — управление дисплеями в Epson-совместимом режиме
# escpos-vfd
Небольшая Rust-библиотека для двухстрочных VFD-дисплеев покупателя, работающих через
serial-порт в Epson/ESC-совместимом режиме. Проект ориентирован на дисплеи семейства
PD-2600/PD-2800 и похожие модели с поддержкой команд позиционирования, яркости и
таблицы символов CP866.
`escpos-vfd` Rust-библиотека для символьных VFD-дисплеев покупателя (customer display),
которые работают через serial-порт и понимают Epson/ESC/POS-совместимые команды.
Библиотека подходит для часов, локальных дашбордов, кассовых приложений, уведомлений,
индикаторов состояния и других проектов, где данные нужно обновлять без мерцания всей
строки.
Настройки порта и дисплея разделены: можно независимо задать скорость, геометрию,
кодировку, аппаратную таблицу символов и яркость. Для проверенного 20×2 дисплея с CP866
есть готовый пресет `Preset::Epson20x2Cp866`.
## Возможности
![VFD-дисплей покупателя с кириллическим текстом](docs/display.jpg)
- вывод текста в первую или вторую строку;
- печать с координаты `(x, y)`;
- обновление только изменившихся диапазонов строки через `print_line_diff`;
- фоновая бегущая строка с регулируемой скоростью и паузой;
- четыре уровня яркости;
- кириллица через CP866;
- потокобезопасный интерфейс `VfdHandle`, который можно клонировать;
- автоматическая обрезка текста по правому краю и дополнение строк пробелами.
## Поддерживаемый протокол
При открытии устройства библиотека использует следующие параметры и команды:
| Параметр | Значение |
| --- | --- |
| Скорость serial-порта | `9600 baud` |
| Таблица символов | CP866, команда `ESC t 6` |
| Инициализация | `ESC @` |
| Очистка | `FF` (`0x0C`) |
| Координаты | `US $ x y`, нумерация от 1 |
| Яркость | `US X n`, где `n = 1..4` |
| Число строк | 2 |
| Допустимая ширина | `1..=255` символов |
Перед запуском убедитесь, что дисплей переведён DIP-переключателями в совместимый
режим. Точные настройки конкретной модели смотрите в документации из каталога
[`docs`](docs/).
## Подключение
Для локального проекта добавьте библиотеку как path-зависимость:
## Установка
```toml
[dependencies]
m = { path = "../vfd" }
anyhow = "1"
escpos-vfd = "0.2"
```
Имя crate сейчас — `m`, поэтому импорт начинается с `m::`.
Tokio API подключается отдельным feature:
```toml
[dependencies]
escpos-vfd = { version = "0.2", features = ["tokio"] }
```
Без feature `tokio` async-зависимости не подключаются.
## Быстрый старт
Для 20×2 дисплея с `9600 8N1`, CP866 и таблицей `ESC t 6` достаточно готового пресета:
```rust,no_run
use anyhow::Result;
use m::vfd::VfdConfig;
use m::worker::VfdWorker;
use std::time::Duration;
use escpos_vfd::{Preset, Vfd, VfdConfig};
fn main() -> Result<()> {
let config = VfdConfig::new("/dev/cu.usbmodem101")
.with_width(20);
fn main() -> escpos_vfd::Result<()> {
let config = VfdConfig::preset(
"/dev/cu.usbmodem101",
Preset::Epson20x2Cp866,
)?;
// Serial-порт открывается до запуска фонового потока,
// поэтому ошибка подключения вернётся из start().
let worker = VfdWorker::start(config)?;
let display = worker.handle();
let mut display = Vfd::open(config)?;
display.clear();
display.set_brightness(2);
display.print_line_diff(1, "Привет!")?;
display.print_line_diff(2, "VFD готов")?;
// Команды выполняются асинхронно: не завершаем программу сразу после отправки.
std::thread::sleep(Duration::from_secs(2));
display.clear()?;
display.set_brightness(2)?;
display.print_line(1, "Привет!")?;
display.print_line(2, "escpos-vfd")?;
Ok(())
}
```
`VfdWorker` последовательно выполняет команды в отдельном потоке. При удалении worker
отправляет команду завершения и дожидается остановки потока. Сохраняйте `worker` в
области видимости, пока дисплей используется.
Полный пример: [examples/preset_sync.rs](examples/preset_sync.rs).
## Основной API
## Что выбрать
### Конфигурация
| Задача | API |
| --- | --- |
| Запустить проверенный 20×2 CP866 дисплей | `VfdConfig::preset(...)` |
| Настроить другой дисплей | `SerialSettings` + `DisplaySettings` |
| Писать напрямую из текущего потока | `Vfd` |
| Отправлять команды из нескольких потоков | `VfdWorker` + `VfdHandle` |
| Писать напрямую из Tokio task | `tokio::AsyncVfd` |
| Отправлять команды из нескольких Tokio tasks | `tokio::AsyncVfdWorker` |
| Отправить нестандартную команду | `write_raw(...)` |
## Настройка другого дисплея
Пресет не обязателен. Параметры serial-порта и дисплея можно задать вручную:
```rust,no_run
use m::vfd::VfdConfig;
use escpos_vfd::{DisplaySettings, SerialSettings, TextEncoding, Vfd, VfdConfig};
use serialport::{DataBits, FlowControl, Parity, StopBits};
use std::time::Duration;
let config = VfdConfig::new("/dev/ttyUSB0")
.with_width(20)
.with_timeout(Duration::from_millis(250));
fn main() -> escpos_vfd::Result<()> {
let mut serial = SerialSettings::new("/dev/ttyUSB0", 9_600);
serial.data_bits = DataBits::Eight;
serial.parity = Parity::None;
serial.stop_bits = StopBits::One;
serial.flow_control = FlowControl::None;
serial.timeout = Duration::from_millis(100);
let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
display.code_table = Some(6);
display.brightness = Some(1..=4);
let config = VfdConfig::new(serial, display)?;
let mut vfd = Vfd::open(config)?;
vfd.print_line(1, "Ручной режим")?;
vfd.print_at(1, 2, "20x2, CP866")?;
Ok(())
}
```
Ширина автоматически ограничивается диапазоном `1..=255`, потому что координаты
протокола передаются одним байтом.
Полный пример: [examples/manual_sync.rs](examples/manual_sync.rs).
### Полная и дифференциальная запись
### Serial-порт
```rust,ignore
display.print_line(1, "Полная перезапись")?;
display.print_line_diff(2, "Изменились цифры: 42")?;
`SerialSettings::new(port, baud_rate)` использует обычные значения `8N1`, без flow
control, с тайм-аутом `100 ms`. На Unix порт по умолчанию открывается эксклюзивно.
Все эти параметры можно изменить через поля `SerialSettings`.
### Дисплей
`DisplaySettings::new(columns, rows, encoding)` задаёт геометрию и текстовую кодировку.
Размеры должны быть в диапазоне `1..=255`.
Дополнительно можно настроить:
- `code_table` — аппаратную таблицу символов через `ESC t n`;
- `reset_on_open` — отправку `ESC @` при открытии;
- `brightness` — допустимый диапазон яркости;
- `brightness_settle` — паузу после изменения яркости.
`VfdConfig` также содержит `queue_capacity` для worker API. По умолчанию очередь вмещает
32 команды; значение `0` запрещено.
## Кодировка и таблица символов
Кодировка текста и таблица символов самого дисплея — разные настройки.
`TextEncoding` определяет, как Rust-строка превращается в байты:
| Значение | Назначение |
| --- | --- |
| `Cp866` | CP866 / IBM866 |
| `Windows1251` | Windows-1251 / CP1251 |
| `Ascii` | только ASCII, остальные символы заменяются на `?` |
| `Utf8` | UTF-8 без перекодирования |
`code_table` отвечает за команду `ESC t n`. Например, конкретному CP866-дисплею могут
одновременно понадобиться:
```rust
let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
display.code_table = Some(6);
```
`print_line_diff` хранит последний кадр каждой строки и группирует соседние изменения.
Если текст не изменился, serial-команда не отправляется. Это полезно для часов и
дашбордов, которые обновляются часто.
Если таблица уже выбрана DIP-переключателями или устройство использует другой механизм,
оставьте `code_table = None`.
### Печать по координатам
Перед обычным выводом библиотека заменяет некоторые типографские символы на более
безопасные для символьного VFD варианты: например, длинное тире на `-`, `` на `#`, а
табуляцию на пробел.
```rust,ignore
display.print_at(7, 1, "21.50")?;
display.print_at(16, 1, "1200")?;
## Вывод текста
Все публичные координаты начинаются с **1**:
```rust
vfd.print_line(1, "Первая строка")?;
vfd.print_at(6, 2, "текст")?;
```
Координаты начинаются с единицы. Допустимы строки `1` и `2`; текст, выходящий за правую
границу, обрезается по символам без повреждения UTF-8.
`print_line()` обрезает слишком длинный текст и дополняет короткий пробелами до ширины
дисплея. Это позволяет полностью перезаписать строку без остатка предыдущего текста.
`print_at()` пишет с указанной позиции и обрезает текст по правому краю. Автоматического
переноса на следующую строку нет.
Неверные координаты возвращают `VfdError::InvalidLine` или
`VfdError::InvalidCoordinate`.
## Worker и частичные обновления
`VfdWorker` владеет дисплеем в отдельном потоке, а `VfdHandle` можно клонировать и
передавать между потоками.
Очередь ограничена по размеру: если она заполнена, отправитель ждёт свободное место вместо
неограниченного накопления команд. Вызов метода handle завершается после фактического
выполнения команды или ошибки I/O.
```rust,no_run
use escpos_vfd::{Preset, VfdConfig, VfdWorker};
fn main() -> escpos_vfd::Result<()> {
let config = VfdConfig::preset(
"/dev/cu.usbmodem101",
Preset::Epson20x2Cp866,
)?;
let worker = VfdWorker::start(config)?;
let display = worker.handle();
display.print_line_diff(1, "Temp: 21.5 C")?;
display.print_line_diff(2, "Humidity: 42%")?;
worker.shutdown()?;
Ok(())
}
```
`print_line_diff()` хранит кэш последнего содержимого строк. Если текст не изменился,
запись не выполняется; если изменился только фрагмент, worker отправляет только изменённые
смежные диапазоны. Это удобно для часов, статусов и других часто обновляемых значений.
Для нормального завершения используйте `VfdWorker::shutdown()`.
### Бегущая строка
```rust,ignore
Worker также умеет обновлять marquee по таймеру:
```rust
use std::time::Duration;
display.set_marquee_text("Длинное сообщение для посетителя");
display.start_marquee(
2, // строка
8, // символов в секунду
Duration::from_millis(1500), // пауза после полного прохода
);
display.set_marquee_text("Длинный текст для бегущей строки")?;
display.start_marquee(1, 8, Duration::from_millis(1500))?;
// При необходимости:
display.stop_marquee();
// ...
display.stop_marquee()?;
```
Обычные команды записи в строку, занятую marquee, игнорируются, чтобы два источника
не перезаписывали друг друга.
`cps` задаёт скорость в символах в секунду, `end_pause` — паузу после полного прохода.
`stop_marquee()` останавливает анимацию, но не очищает уже отображённый текст.
## Tokio
Feature `tokio` добавляет два варианта API:
- `AsyncVfd` — прямой драйвер поверх `AsyncWrite`;
- `AsyncVfdWorker` — одна задача-писатель, bounded queue и клонируемый
`AsyncVfdHandle`.
```rust,no_run
use escpos_vfd::tokio::AsyncVfdWorker;
use escpos_vfd::{Preset, VfdConfig};
#[tokio::main]
async fn main() -> escpos_vfd::Result<()> {
let config = VfdConfig::preset(
"/dev/cu.usbmodem101",
Preset::Epson20x2Cp866,
)?;
let worker = AsyncVfdWorker::start(config).await?;
let display = worker.handle();
display.print_line_diff(1, "Tokio worker").await?;
display.print_line_diff(2, "async I/O").await?;
worker.shutdown().await?;
Ok(())
}
```
После попадания команды в очередь отмена ожидающего future не отменяет уже поставленную
запись в устройство. Для корректного завершения используйте
`AsyncVfdWorker::shutdown().await`.
## Низкоуровневая запись
Если нужной команды нет в типизированном API, байты можно отправить напрямую:
```rust
vfd.write_raw(&[0x1b, 0x40])?;
```
`write_raw()` не кодирует и не интерпретирует данные. В worker API такая запись также не
обновляет строковый кэш, поэтому после raw-команд, меняющих текст на экране, лучше
выполнить `clear()` или `print_line()`.
Для нестандартных транспортов и диагностики доступен публичный
`escpos_vfd::codec::EpsonCodec`, который формирует байты команд без открытия serial-порта.
## Ошибки
Библиотека использует `escpos_vfd::Result<T> = Result<T, VfdError>` и разделяет ошибки по
смыслу:
| Ошибка | Когда возникает |
| --- | --- |
| `VfdError::Config(...)` | неверная конфигурация |
| `VfdError::Serial(...)` | ошибка открытия или настройки serial-порта |
| `VfdError::Io(...)` | ошибка записи или `flush` |
| `VfdError::InvalidCoordinate` | координата вне дисплея |
| `VfdError::InvalidLine` | строка вне `1..=rows` |
| `VfdError::UnsupportedBrightness` | неподдерживаемый уровень яркости |
| `VfdError::QueueClosed` | очередь worker-а закрыта |
| `VfdError::WorkerStopped` | worker остановился до подтверждения команды |
| `VfdError::WorkerPanicked` | sync worker завершился с panic |
| `VfdError::WorkerCancelled` | Tokio worker был отменён |
## Примеры
Все примеры принимают два общих аргумента:
В репозитории есть небольшие аппаратные примеры для основных сценариев:
1. serial-порт, по умолчанию `/dev/cu.usbmodem101`;
2. ширина дисплея, по умолчанию `20`.
| Пример | Что показывает |
| --- | --- |
| `preset_sync` | запуск с готовым пресетом |
| `manual_sync` | ручную конфигурацию |
| `tokio_worker` | Tokio worker |
| `clock` | частые diff-обновления |
| `marquee` | бегущую строку |
| `brightness` | яркость |
| `position` | позиционирование |
| `update_at` | частичные обновления |
| `blink` | повторную запись в позицию |
Дополнительные аргументы зависят от примера:
| Пример | Что демонстрирует | Дополнительные аргументы |
| --- | --- | --- |
| `clock` | Дату, время, день недели и часть суток | `[brightness=2]` |
| `marquee` | Фоновую бегущую строку | `[cps=8] [pause_ms=1500] [brightness=2]` |
| `brightness` | Перебор уровней яркости `1..=4` | `[delay_ms=800]` |
| `position` | Движение символа по второй строке | `[delay_ms=80]` |
| `update_at` | Частичное обновление полей дашборда | `[delay_ms=200]` |
| `blink` | Мигание текста в заданной позиции | `[delay_ms=400] [x=1] [y=1] [text=BLINK]` |
Примеры запуска:
Например:
```bash
cargo run --example clock -- /dev/cu.usbmodem101 20 2
cargo run --example preset_sync -- /dev/cu.usbmodem101
cargo run --example marquee -- /dev/cu.usbmodem101 20 8 1500 2
cargo run --example brightness -- /dev/cu.usbmodem101 20 800
cargo run --example position -- /dev/cu.usbmodem101 20 80
cargo run --example update_at -- /dev/cu.usbmodem101 20 200
cargo run --example blink -- /dev/cu.usbmodem101 20 400 1 2 BLINK
cargo run --features tokio --example tokio_worker -- /dev/cu.usbmodem101 20
```
`update_at` использует фиксированную 20-символьную раскладку и завершится с понятной
ошибкой, если передать меньшую ширину.
## Проверка на реальном дисплее
Те же программы доступны через [Task](https://taskfile.dev/):
Автоматические тесты проверяют формирование команд, кодировки, координаты, кэш строк,
worker queue, marquee и async-поведение на тестовых транспортах. Конкретное устройство
всё равно стоит проверить отдельно:
```bash
task vfd:clock
task vfd:marquee
task vfd:brightness
task vfd:position
task vfd:update_at
task vfd:blink
```
1. Выставьте правильный serial/DIP-режим.
2. Запустите `cargo run --example preset_sync -- <port>`.
3. Проверьте очистку, строки, кириллицу и яркость.
4. Проверьте `update_at` и `marquee`.
5. При использовании Tokio запустите пример `tokio_worker`.
Значения можно переопределять переменными, например:
```bash
task vfd:marquee VFD_PORT=/dev/ttyUSB0 VFD_WIDTH=20 VFD_CPS=10
```
## Кодировка текста
Перед записью библиотека заменяет несколько типографских символов на совместимые
аналоги:
- `` → `.`;
- `` и `` → `-`;
- `` → `#`;
- типографские кавычки → обычные кавычки;
- табуляция → пробел.
Остальной текст кодируется с помощью `encoding_rs::IBM866`.
## Структура проекта
```text
src/vfd.rs низкоуровневые команды и CP866
src/worker.rs очередь команд, diff и marquee
examples/common/mod.rs общие CLI-утилиты примеров
examples/*.rs демонстрационные программы
docs/ руководства для дисплеев
taskfile.yml команды запуска примеров
```
## Разработка и проверка
## Разработка
```bash
cargo fmt --all -- --check
cargo clippy --all-targets --no-default-features --locked -- -D warnings
cargo clippy --all-targets --all-features --locked -- -D warnings
cargo test --all-targets --locked
cargo check --examples --locked
cargo test --all-targets --no-default-features --locked
cargo test --all-targets --all-features --locked
RUSTDOCFLAGS='-D warnings -D missing_docs -D rustdoc::broken_intra_doc_links' \
cargo doc --all-features --no-deps --locked
```
Автоматические тесты проверяют Unicode, CP866-санацию, ширину строк, обновление кэша,
группировку diff-диапазонов, таймер marquee и функции примера часов. Для полной проверки
нужен отдельный smoke-test с физическим дисплеем и фактическим serial-портом.
Подробная документация публичного API: [docs.rs/escpos-vfd](https://docs.rs/escpos-vfd).
## Ограничения
## Лицензия
- поддерживаются только две строки;
- скорость подключения фиксирована на `9600 baud`;
- очередь команд в worker не ограничена по размеру;
- аппаратные ошибки после запуска worker выводятся в stderr из фонового потока;
- пример `clock` использует системную команду `date` и рассчитан на Unix-подобную среду;
- библиотека не определяет serial-порт автоматически.
`escpos-vfd` распространяется под двойной лицензией `MIT OR Apache-2.0`.