Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ba5275957 | ||
|
|
37cdba4898 |
@@ -0,0 +1,49 @@
|
|||||||
|
name: Rust
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@1.85
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Format
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: Check
|
||||||
|
run: cargo check --all-targets --all-features
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: cargo test --all-features
|
||||||
|
|
||||||
|
- name: Documentation
|
||||||
|
env:
|
||||||
|
RUSTDOCFLAGS: "-D warnings"
|
||||||
|
run: cargo doc --no-deps --all-features
|
||||||
|
|
||||||
|
- name: Package
|
||||||
|
run: cargo package --allow-dirty
|
||||||
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
Все заметные изменения проекта фиксируются в этом файле.
|
Все заметные изменения проекта фиксируются в этом файле.
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-08-20
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- BREAKING: `EpsonCodec::new` теперь валидирует `DisplaySettings` и возвращает
|
||||||
|
`Result<EpsonCodec, ConfigError>`.
|
||||||
|
- Текстовые API нейтрализуют управляющие символы; произвольные ESC/POS-команды
|
||||||
|
отправляются только через `write_raw`.
|
||||||
|
- Worker ограничивает queued raw payload, длину marquee и максимальную скорость marquee
|
||||||
|
публичными константами `MAX_QUEUED_RAW_BYTES`, `MAX_MARQUEE_CHARS` и
|
||||||
|
`MAX_MARQUEE_CPS`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Неподдерживаемые символы CP866/Windows-1251 заменяются одним байтом `?`, не нарушая
|
||||||
|
ширину строки числовыми character references.
|
||||||
|
- Некорректная геометрия codec-а возвращает `ConfigError` вместо позднего panic.
|
||||||
|
|
||||||
## [0.2.0] - 2026-08-16
|
## [0.2.0] - 2026-08-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+1
-1
@@ -59,7 +59,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "escpos-vfd"
|
name = "escpos-vfd"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"serialport",
|
"serialport",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "escpos-vfd"
|
name = "escpos-vfd"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "ESC/POS-compatible VFD customer display driver with sync and optional Tokio APIs"
|
description = "ESC/POS-compatible VFD customer display driver with sync and optional Tokio APIs"
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# escpos-vfd
|
# escpos-vfd
|
||||||
|
|
||||||
|
[](https://crates.io/crates/escpos-vfd)
|
||||||
|
[](https://docs.rs/escpos-vfd)
|
||||||
|
[](https://www.rust-lang.org)
|
||||||
|
[](https://git.belvedersky.ru/belvedersky/vfd/src/branch/master/LICENSE-MIT)
|
||||||
|
|
||||||
`escpos-vfd` — Rust-библиотека для символьных VFD-дисплеев покупателя (customer display),
|
`escpos-vfd` — Rust-библиотека для символьных VFD-дисплеев покупателя (customer display),
|
||||||
которые работают через serial-порт и понимают Epson/ESC/POS-совместимые команды.
|
которые работают через serial-порт и понимают Epson/ESC/POS-совместимые команды.
|
||||||
|
|
||||||
@@ -13,14 +18,14 @@
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
escpos-vfd = "0.2"
|
escpos-vfd = "0.3"
|
||||||
```
|
```
|
||||||
|
|
||||||
Tokio API подключается отдельным feature:
|
Tokio API подключается отдельным feature:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
escpos-vfd = { version = "0.2", features = ["tokio"] }
|
escpos-vfd = { version = "0.3", features = ["tokio"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Без feature `tokio` async-зависимости не подключаются.
|
Без feature `tokio` async-зависимости не подключаются.
|
||||||
@@ -53,15 +58,15 @@ fn main() -> escpos_vfd::Result<()> {
|
|||||||
|
|
||||||
## Что выбрать
|
## Что выбрать
|
||||||
|
|
||||||
| Задача | API |
|
| Задача | API |
|
||||||
| --- | --- |
|
| -------------------------------------------- | ------------------------------------ |
|
||||||
| Запустить проверенный 20×2 CP866 дисплей | `VfdConfig::preset(...)` |
|
| Запустить проверенный 20×2 CP866 дисплей | `VfdConfig::preset(...)` |
|
||||||
| Настроить другой дисплей | `SerialSettings` + `DisplaySettings` |
|
| Настроить другой дисплей | `SerialSettings` + `DisplaySettings` |
|
||||||
| Писать напрямую из текущего потока | `Vfd` |
|
| Писать напрямую из текущего потока | `Vfd` |
|
||||||
| Отправлять команды из нескольких потоков | `VfdWorker` + `VfdHandle` |
|
| Отправлять команды из нескольких потоков | `VfdWorker` + `VfdHandle` |
|
||||||
| Писать напрямую из Tokio task | `tokio::AsyncVfd` |
|
| Писать напрямую из Tokio task | `tokio::AsyncVfd` |
|
||||||
| Отправлять команды из нескольких Tokio tasks | `tokio::AsyncVfdWorker` |
|
| Отправлять команды из нескольких Tokio tasks | `tokio::AsyncVfdWorker` |
|
||||||
| Отправить нестандартную команду | `write_raw(...)` |
|
| Отправить нестандартную команду | `write_raw(...)` |
|
||||||
|
|
||||||
## Настройка другого дисплея
|
## Настройка другого дисплея
|
||||||
|
|
||||||
@@ -123,12 +128,12 @@ control, с тайм-аутом `100 ms`. На Unix порт по умолчан
|
|||||||
|
|
||||||
`TextEncoding` определяет, как Rust-строка превращается в байты:
|
`TextEncoding` определяет, как Rust-строка превращается в байты:
|
||||||
|
|
||||||
| Значение | Назначение |
|
| Значение | Назначение |
|
||||||
| --- | --- |
|
| ------------- | ------------------------------------------------- |
|
||||||
| `Cp866` | CP866 / IBM866 |
|
| `Cp866` | CP866 / IBM866 |
|
||||||
| `Windows1251` | Windows-1251 / CP1251 |
|
| `Windows1251` | Windows-1251 / CP1251 |
|
||||||
| `Ascii` | только ASCII, остальные символы заменяются на `?` |
|
| `Ascii` | только ASCII, остальные символы заменяются на `?` |
|
||||||
| `Utf8` | UTF-8 без перекодирования |
|
| `Utf8` | UTF-8 без перекодирования |
|
||||||
|
|
||||||
`code_table` отвечает за команду `ESC t n`. Например, конкретному CP866-дисплею могут
|
`code_table` отвечает за команду `ESC t n`. Например, конкретному CP866-дисплею могут
|
||||||
одновременно понадобиться:
|
одновременно понадобиться:
|
||||||
@@ -160,6 +165,11 @@ vfd.print_at(6, 2, "текст")?;
|
|||||||
`print_at()` пишет с указанной позиции и обрезает текст по правому краю. Автоматического
|
`print_at()` пишет с указанной позиции и обрезает текст по правому краю. Автоматического
|
||||||
переноса на следующую строку нет.
|
переноса на следующую строку нет.
|
||||||
|
|
||||||
|
Текстовые методы заменяют управляющие символы, включая `NUL`, `ESC`, `US`, `CR`, `LF`
|
||||||
|
и form feed, пробелами. Для намеренной отправки команд используется только `write_raw()`.
|
||||||
|
В CP866 и Windows-1251 каждый неподдерживаемый Unicode-символ заменяется одним `?`,
|
||||||
|
поэтому кодирование не нарушает настроенную ширину строки.
|
||||||
|
|
||||||
Неверные координаты возвращают `VfdError::InvalidLine` или
|
Неверные координаты возвращают `VfdError::InvalidLine` или
|
||||||
`VfdError::InvalidCoordinate`.
|
`VfdError::InvalidCoordinate`.
|
||||||
|
|
||||||
@@ -213,7 +223,9 @@ display.start_marquee(1, 8, Duration::from_millis(1500))?;
|
|||||||
display.stop_marquee()?;
|
display.stop_marquee()?;
|
||||||
```
|
```
|
||||||
|
|
||||||
`cps` задаёт скорость в символах в секунду, `end_pause` — паузу после полного прохода.
|
`cps` задаёт скорость в символах в секунду и ограничен значением
|
||||||
|
`MAX_MARQUEE_CPS`; `end_pause` — паузу после полного прохода. Текст ограничен
|
||||||
|
`MAX_MARQUEE_CHARS` символами.
|
||||||
`stop_marquee()` останавливает анимацию, но не очищает уже отображённый текст.
|
`stop_marquee()` останавливает анимацию, но не очищает уже отображённый текст.
|
||||||
|
|
||||||
## Tokio
|
## Tokio
|
||||||
@@ -260,44 +272,47 @@ vfd.write_raw(&[0x1b, 0x40])?;
|
|||||||
|
|
||||||
`write_raw()` не кодирует и не интерпретирует данные. В worker API такая запись также не
|
`write_raw()` не кодирует и не интерпретирует данные. В worker API такая запись также не
|
||||||
обновляет строковый кэш, поэтому после raw-команд, меняющих текст на экране, лучше
|
обновляет строковый кэш, поэтому после raw-команд, меняющих текст на экране, лучше
|
||||||
выполнить `clear()` или `print_line()`.
|
выполнить `clear()` или `print_line()`. Размер одной queued raw-команды ограничен
|
||||||
|
`MAX_QUEUED_RAW_BYTES`; низкоуровневые `Vfd::write_raw()` и `AsyncVfd::write_raw()`
|
||||||
|
пишут предоставленный slice напрямую без промежуточной очереди.
|
||||||
|
|
||||||
Для нестандартных транспортов и диагностики доступен публичный
|
Для нестандартных транспортов и диагностики доступен публичный
|
||||||
`escpos_vfd::codec::EpsonCodec`, который формирует байты команд без открытия serial-порта.
|
`escpos_vfd::codec::EpsonCodec`, который формирует байты команд без открытия serial-порта.
|
||||||
|
`EpsonCodec::new(display)` валидирует геометрию и возвращает `Result`.
|
||||||
|
|
||||||
## Ошибки
|
## Ошибки
|
||||||
|
|
||||||
Библиотека использует `escpos_vfd::Result<T> = Result<T, VfdError>` и разделяет ошибки по
|
Библиотека использует `escpos_vfd::Result<T> = Result<T, VfdError>` и разделяет ошибки по
|
||||||
смыслу:
|
смыслу:
|
||||||
|
|
||||||
| Ошибка | Когда возникает |
|
| Ошибка | Когда возникает |
|
||||||
| --- | --- |
|
| --------------------------------- | ------------------------------------------- |
|
||||||
| `VfdError::Config(...)` | неверная конфигурация |
|
| `VfdError::Config(...)` | неверная конфигурация |
|
||||||
| `VfdError::Serial(...)` | ошибка открытия или настройки serial-порта |
|
| `VfdError::Serial(...)` | ошибка открытия или настройки serial-порта |
|
||||||
| `VfdError::Io(...)` | ошибка записи или `flush` |
|
| `VfdError::Io(...)` | ошибка записи или `flush` |
|
||||||
| `VfdError::InvalidCoordinate` | координата вне дисплея |
|
| `VfdError::InvalidCoordinate` | координата вне дисплея |
|
||||||
| `VfdError::InvalidLine` | строка вне `1..=rows` |
|
| `VfdError::InvalidLine` | строка вне `1..=rows` |
|
||||||
| `VfdError::UnsupportedBrightness` | неподдерживаемый уровень яркости |
|
| `VfdError::UnsupportedBrightness` | неподдерживаемый уровень яркости |
|
||||||
| `VfdError::QueueClosed` | очередь worker-а закрыта |
|
| `VfdError::QueueClosed` | очередь worker-а закрыта |
|
||||||
| `VfdError::WorkerStopped` | worker остановился до подтверждения команды |
|
| `VfdError::WorkerStopped` | worker остановился до подтверждения команды |
|
||||||
| `VfdError::WorkerPanicked` | sync worker завершился с panic |
|
| `VfdError::WorkerPanicked` | sync worker завершился с panic |
|
||||||
| `VfdError::WorkerCancelled` | Tokio worker был отменён |
|
| `VfdError::WorkerCancelled` | Tokio worker был отменён |
|
||||||
|
|
||||||
## Примеры
|
## Примеры
|
||||||
|
|
||||||
В репозитории есть небольшие аппаратные примеры для основных сценариев:
|
В репозитории есть небольшие аппаратные примеры для основных сценариев:
|
||||||
|
|
||||||
| Пример | Что показывает |
|
| Пример | Что показывает |
|
||||||
| --- | --- |
|
| -------------- | -------------------------- |
|
||||||
| `preset_sync` | запуск с готовым пресетом |
|
| `preset_sync` | запуск с готовым пресетом |
|
||||||
| `manual_sync` | ручную конфигурацию |
|
| `manual_sync` | ручную конфигурацию |
|
||||||
| `tokio_worker` | Tokio worker |
|
| `tokio_worker` | Tokio worker |
|
||||||
| `clock` | частые diff-обновления |
|
| `clock` | частые diff-обновления |
|
||||||
| `marquee` | бегущую строку |
|
| `marquee` | бегущую строку |
|
||||||
| `brightness` | яркость |
|
| `brightness` | яркость |
|
||||||
| `position` | позиционирование |
|
| `position` | позиционирование |
|
||||||
| `update_at` | частичные обновления |
|
| `update_at` | частичные обновления |
|
||||||
| `blink` | повторную запись в позицию |
|
| `blink` | повторную запись в позицию |
|
||||||
|
|
||||||
Например:
|
Например:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -90,7 +90,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
// 2) width - ширина строки, нужна для подгонки текста;
|
// 2) width - ширина строки, нужна для подгонки текста;
|
||||||
// 3) brightness - яркость из диапазона пресета 1..=4.
|
// 3) brightness - яркость из диапазона пресета 1..=4.
|
||||||
let mut args = common::ExampleArgs::from_env()?;
|
let mut args = common::ExampleArgs::from_env()?;
|
||||||
let brightness: u8 = args.parse_or(2);
|
let brightness: u8 = args.parse_or(4);
|
||||||
let columns = args.columns;
|
let columns = args.columns;
|
||||||
|
|
||||||
// Worker остаётся жить до конца процесса. Handle можно клонировать, но сам worker
|
// Worker остаётся жить до конца процесса. Handle можно клонировать, но сам worker
|
||||||
|
|||||||
+89
-30
@@ -6,8 +6,8 @@
|
|||||||
//! остаётся публичным для диагностики и интеграции с собственным транспортом.
|
//! остаётся публичным для диагностики и интеграции с собственным транспортом.
|
||||||
|
|
||||||
use crate::config::{DisplaySettings, TextEncoding};
|
use crate::config::{DisplaySettings, TextEncoding};
|
||||||
use crate::error::{Result, VfdError};
|
use crate::error::{ConfigError, Result, VfdError};
|
||||||
use encoding_rs::{IBM866, WINDOWS_1251};
|
use encoding_rs::{Encoding, IBM866, WINDOWS_1251};
|
||||||
|
|
||||||
/// Кодировщик Epson/ESC/POS-команд для выбранной геометрии и таблицы символов.
|
/// Кодировщик Epson/ESC/POS-команд для выбранной геометрии и таблицы символов.
|
||||||
///
|
///
|
||||||
@@ -24,10 +24,14 @@ impl EpsonCodec {
|
|||||||
/// Значение `display.code_table` влияет только на байты инициализации `ESC t n`;
|
/// Значение `display.code_table` влияет только на байты инициализации `ESC t n`;
|
||||||
/// кодировка текста берётся из `display.encoding`.
|
/// кодировка текста берётся из `display.encoding`.
|
||||||
///
|
///
|
||||||
/// Метод не валидирует настройки. Если codec создаётся не через [`crate::Vfd`],
|
/// Метод валидирует геометрию и диапазоны до сохранения настроек.
|
||||||
/// вызовите [`DisplaySettings::validate`] самостоятельно.
|
///
|
||||||
pub fn new(display: DisplaySettings) -> Self {
|
/// # Ошибки
|
||||||
Self { display }
|
///
|
||||||
|
/// Возвращает [`ConfigError`], если настройки дисплея некорректны.
|
||||||
|
pub fn new(display: DisplaySettings) -> std::result::Result<Self, ConfigError> {
|
||||||
|
display.validate()?;
|
||||||
|
Ok(Self { display })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Настройки дисплея, для которых работает codec.
|
/// Настройки дисплея, для которых работает codec.
|
||||||
@@ -95,18 +99,19 @@ impl EpsonCodec {
|
|||||||
|
|
||||||
/// Кодирует текст в настроенной кодировке.
|
/// Кодирует текст в настроенной кодировке.
|
||||||
///
|
///
|
||||||
/// Для `Ascii` символы вне ASCII заменяются на `?`. Для CP866 и Windows-1251
|
/// Для `Ascii`, CP866 и Windows-1251 неподдерживаемый символ заменяется ровно одним
|
||||||
/// используется `encoding_rs`, поэтому неподдерживаемые символы проходят стандартную
|
/// байтом `?`. Управляющие символы заменяются пробелами во всех кодировках, поэтому
|
||||||
/// замену этой библиотеки.
|
/// произвольные команды следует отправлять только через `write_raw` API.
|
||||||
pub fn encode_text(&self, text: &str) -> Vec<u8> {
|
pub fn encode_text(&self, text: &str) -> Vec<u8> {
|
||||||
match self.display.encoding {
|
match self.display.encoding {
|
||||||
TextEncoding::Cp866 => IBM866.encode(text).0.into_owned(),
|
TextEncoding::Cp866 => encode_single_byte(IBM866, text),
|
||||||
TextEncoding::Windows1251 => WINDOWS_1251.encode(text).0.into_owned(),
|
TextEncoding::Windows1251 => encode_single_byte(WINDOWS_1251, text),
|
||||||
TextEncoding::Ascii => text
|
TextEncoding::Ascii => text
|
||||||
.chars()
|
.chars()
|
||||||
|
.map(sanitize_char)
|
||||||
.map(|ch| if ch.is_ascii() { ch as u8 } else { b'?' })
|
.map(|ch| if ch.is_ascii() { ch as u8 } else { b'?' })
|
||||||
.collect(),
|
.collect(),
|
||||||
TextEncoding::Utf8 => text.as_bytes().to_vec(),
|
TextEncoding::Utf8 => sanitize_text(text).into_bytes(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +120,8 @@ impl EpsonCodec {
|
|||||||
/// Метод сначала применяет [`sanitize_text`], затем обрезает по числу символов и
|
/// Метод сначала применяет [`sanitize_text`], затем обрезает по числу символов и
|
||||||
/// дополняет пробелами до `display.columns`.
|
/// дополняет пробелами до `display.columns`.
|
||||||
pub fn fit_line(&self, text: &str) -> String {
|
pub fn fit_line(&self, text: &str) -> String {
|
||||||
fit_to_width(&sanitize_text(text), self.display.columns)
|
let text = sanitize_to_width(text, self.display.columns);
|
||||||
|
fit_to_width(&text, self.display.columns)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Обрезает текст по правому краю от координаты `x`.
|
/// Обрезает текст по правому краю от координаты `x`.
|
||||||
@@ -129,7 +135,7 @@ impl EpsonCodec {
|
|||||||
pub fn clip_from(&self, x: u8, text: &str) -> Result<String> {
|
pub fn clip_from(&self, x: u8, text: &str) -> Result<String> {
|
||||||
self.validate_xy(x, 1)?;
|
self.validate_xy(x, 1)?;
|
||||||
let remaining = self.display.columns - usize::from(x) + 1;
|
let remaining = self.display.columns - usize::from(x) + 1;
|
||||||
Ok(truncate_chars(&sanitize_text(text), remaining).to_string())
|
Ok(sanitize_to_width(text, remaining))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Проверяет координаты относительно геометрии.
|
/// Проверяет координаты относительно геометрии.
|
||||||
@@ -188,20 +194,43 @@ pub fn truncate_chars(s: &str, max_chars: usize) -> &str {
|
|||||||
|
|
||||||
/// Заменяет типографские символы на безопасные аналоги для однобайтовых таблиц.
|
/// Заменяет типографские символы на безопасные аналоги для однобайтовых таблиц.
|
||||||
///
|
///
|
||||||
/// Функция намеренно не выбирает кодировку. Она только убирает символы вроде длинного
|
/// Функция намеренно не выбирает кодировку. Она заменяет управляющие символы пробелами и
|
||||||
/// тире, табуляции и `№`, которые часто плохо представлены на VFD-дисплеях.
|
/// нормализует символы вроде длинного тире и `№`, плохо представленные на VFD-дисплеях.
|
||||||
pub fn sanitize_text(s: &str) -> String {
|
pub fn sanitize_text(s: &str) -> String {
|
||||||
s.chars()
|
s.chars().map(sanitize_char).collect()
|
||||||
.map(|c| match c {
|
}
|
||||||
'…' => '.',
|
|
||||||
'—' | '–' => '-',
|
pub(crate) fn sanitize_to_width(s: &str, width: usize) -> String {
|
||||||
'№' => '#',
|
s.chars().take(width).map(sanitize_char).collect()
|
||||||
'\t' => ' ',
|
}
|
||||||
'“' | '”' => '"',
|
|
||||||
'‘' | '’' => '\'',
|
fn sanitize_char(c: char) -> char {
|
||||||
_ => c,
|
if c.is_control() {
|
||||||
})
|
return ' ';
|
||||||
.collect()
|
}
|
||||||
|
match c {
|
||||||
|
'…' => '.',
|
||||||
|
'—' | '–' => '-',
|
||||||
|
'№' => '#',
|
||||||
|
'“' | '”' => '"',
|
||||||
|
'‘' | '’' => '\'',
|
||||||
|
_ => c,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_single_byte(encoding: &'static Encoding, text: &str) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(text.chars().count());
|
||||||
|
let mut utf8 = [0; 4];
|
||||||
|
for ch in text.chars().map(sanitize_char) {
|
||||||
|
let encoded_char = ch.encode_utf8(&mut utf8);
|
||||||
|
let (encoded, _, had_errors) = encoding.encode(encoded_char);
|
||||||
|
if had_errors || encoded.len() != 1 {
|
||||||
|
out.push(b'?');
|
||||||
|
} else {
|
||||||
|
out.push(encoded[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Совместимый алиас для старого helper.
|
/// Совместимый алиас для старого helper.
|
||||||
@@ -281,7 +310,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn preset_init_matches_legacy_reset_and_cp866_table() {
|
fn preset_init_matches_legacy_reset_and_cp866_table() {
|
||||||
let cfg = VfdConfig::preset("test", Preset::Epson20x2Cp866).unwrap();
|
let cfg = VfdConfig::preset("test", Preset::Epson20x2Cp866).unwrap();
|
||||||
let codec = EpsonCodec::new(cfg.display);
|
let codec = EpsonCodec::new(cfg.display).unwrap();
|
||||||
|
|
||||||
assert_eq!(codec.init(), vec![0x1B, 0x40, 0x1B, 0x74, 6]);
|
assert_eq!(codec.init(), vec![0x1B, 0x40, 0x1B, 0x74, 6]);
|
||||||
}
|
}
|
||||||
@@ -289,14 +318,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn optional_code_table_can_be_omitted() {
|
fn optional_code_table_can_be_omitted() {
|
||||||
let display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
|
let display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
|
||||||
let codec = EpsonCodec::new(display);
|
let codec = EpsonCodec::new(display).unwrap();
|
||||||
|
|
||||||
assert_eq!(codec.init(), vec![0x1B, 0x40]);
|
assert_eq!(codec.init(), vec![0x1B, 0x40]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validates_geometry_instead_of_ignoring_invalid_coordinates() {
|
fn validates_geometry_instead_of_ignoring_invalid_coordinates() {
|
||||||
let codec = EpsonCodec::new(DisplaySettings::new(20, 4, TextEncoding::Cp866));
|
let codec = EpsonCodec::new(DisplaySettings::new(20, 4, TextEncoding::Cp866)).unwrap();
|
||||||
|
|
||||||
assert!(codec.goto_xy(1, 4).is_ok());
|
assert!(codec.goto_xy(1, 4).is_ok());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -340,4 +369,34 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(changed_runs("без перемен", "без перемен").is_empty());
|
assert!(changed_runs("без перемен", "без перемен").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_sanitization_neutralizes_protocol_controls() {
|
||||||
|
assert_eq!(sanitize_text("A\0\u{1b}\u{1f}\r\nB"), "A B");
|
||||||
|
for encoding in [
|
||||||
|
TextEncoding::Cp866,
|
||||||
|
TextEncoding::Windows1251,
|
||||||
|
TextEncoding::Ascii,
|
||||||
|
TextEncoding::Utf8,
|
||||||
|
] {
|
||||||
|
let codec = EpsonCodec::new(DisplaySettings::new(8, 1, encoding)).unwrap();
|
||||||
|
assert_eq!(codec.encode_text("\0\u{1b}\u{1f}\r\n"), b" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_encodings_use_one_byte_for_unmappable_characters() {
|
||||||
|
for encoding in [TextEncoding::Cp866, TextEncoding::Windows1251] {
|
||||||
|
let codec = EpsonCodec::new(DisplaySettings::new(1, 1, encoding)).unwrap();
|
||||||
|
assert_eq!(codec.encode_text("😀"), vec![b'?']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codec_constructor_rejects_invalid_geometry() {
|
||||||
|
assert!(matches!(
|
||||||
|
EpsonCodec::new(DisplaySettings::new(usize::MAX, 1, TextEncoding::Ascii)),
|
||||||
|
Err(ConfigError::InvalidColumns(usize::MAX))
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ use crate::error::ConfigError;
|
|||||||
use serialport::{DataBits, FlowControl, Parity, StopBits};
|
use serialport::{DataBits, FlowControl, Parity, StopBits};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Максимальное число символов в тексте бегущей строки worker-а.
|
||||||
|
pub const MAX_MARQUEE_CHARS: usize = 4_096;
|
||||||
|
|
||||||
|
/// Максимальный размер одной raw-команды, помещаемой в очередь worker-а.
|
||||||
|
pub const MAX_QUEUED_RAW_BYTES: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// Максимальная скорость бегущей строки в кадрах/символах в секунду.
|
||||||
|
pub const MAX_MARQUEE_CPS: u32 = 100;
|
||||||
|
|
||||||
/// Преднастроенные профили известных ESC/POS-совместимых дисплеев.
|
/// Преднастроенные профили известных ESC/POS-совместимых дисплеев.
|
||||||
///
|
///
|
||||||
/// Пресеты нужны для сохранения проверенных наборов настроек, но не ограничивают ручную
|
/// Пресеты нужны для сохранения проверенных наборов настроек, но не ограничивают ручную
|
||||||
|
|||||||
@@ -73,6 +73,25 @@ pub enum VfdError {
|
|||||||
/// Максимальный поддерживаемый уровень.
|
/// Максимальный поддерживаемый уровень.
|
||||||
max: u8,
|
max: u8,
|
||||||
},
|
},
|
||||||
|
/// Текст бегущей строки превышает безопасный предел очереди worker-а.
|
||||||
|
TextTooLong {
|
||||||
|
/// Максимально допустимое число символов.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// Raw-команда превышает безопасный предел очереди worker-а.
|
||||||
|
RawPayloadTooLarge {
|
||||||
|
/// Фактический размер команды в байтах.
|
||||||
|
length: usize,
|
||||||
|
/// Максимально допустимый размер в байтах.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// Скорость бегущей строки превышает поддерживаемый предел.
|
||||||
|
InvalidMarqueeSpeed {
|
||||||
|
/// Запрошенная скорость в символах/кадрах в секунду.
|
||||||
|
cps: u32,
|
||||||
|
/// Максимально допустимая скорость.
|
||||||
|
max: u32,
|
||||||
|
},
|
||||||
/// Очередь фонового worker закрыта.
|
/// Очередь фонового worker закрыта.
|
||||||
QueueClosed,
|
QueueClosed,
|
||||||
/// Worker остановлен до подтверждения команды.
|
/// Worker остановлен до подтверждения команды.
|
||||||
@@ -130,6 +149,15 @@ impl std::fmt::Display for VfdError {
|
|||||||
"brightness {level} is outside supported range {min}..={max}"
|
"brightness {level} is outside supported range {min}..={max}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Self::TextTooLong { max } => {
|
||||||
|
write!(f, "marquee text exceeds the limit of {max} characters")
|
||||||
|
}
|
||||||
|
Self::RawPayloadTooLarge { length, max } => {
|
||||||
|
write!(f, "raw payload is {length} bytes, maximum is {max}")
|
||||||
|
}
|
||||||
|
Self::InvalidMarqueeSpeed { cps, max } => {
|
||||||
|
write!(f, "marquee speed {cps} exceeds maximum {max}")
|
||||||
|
}
|
||||||
Self::QueueClosed => f.write_str("VFD worker queue is closed"),
|
Self::QueueClosed => f.write_str("VFD worker queue is closed"),
|
||||||
Self::WorkerStopped => f.write_str("VFD worker stopped before acknowledging command"),
|
Self::WorkerStopped => f.write_str("VFD worker stopped before acknowledging command"),
|
||||||
Self::WorkerPanicked => f.write_str("VFD worker thread panicked"),
|
Self::WorkerPanicked => f.write_str("VFD worker thread panicked"),
|
||||||
|
|||||||
+4
-1
@@ -46,7 +46,10 @@ pub mod worker;
|
|||||||
pub mod tokio;
|
pub mod tokio;
|
||||||
|
|
||||||
pub use codec::{fit_to_width, sanitize_for_cp866, sanitize_text};
|
pub use codec::{fit_to_width, sanitize_for_cp866, sanitize_text};
|
||||||
pub use config::{DisplaySettings, Preset, SerialSettings, TextEncoding, VfdConfig};
|
pub use config::{
|
||||||
|
DisplaySettings, MAX_MARQUEE_CHARS, MAX_MARQUEE_CPS, MAX_QUEUED_RAW_BYTES, Preset,
|
||||||
|
SerialSettings, TextEncoding, VfdConfig,
|
||||||
|
};
|
||||||
pub use error::{ConfigError, Result, VfdError};
|
pub use error::{ConfigError, Result, VfdError};
|
||||||
pub use vfd::Vfd;
|
pub use vfd::Vfd;
|
||||||
pub use worker::{VfdHandle, VfdWorker};
|
pub use worker::{VfdHandle, VfdWorker};
|
||||||
|
|||||||
+99
-12
@@ -6,8 +6,12 @@
|
|||||||
//! `AsyncWrite`, поэтому тесты и нестандартные транспорты не требуют настоящего
|
//! `AsyncWrite`, поэтому тесты и нестандартные транспорты не требуют настоящего
|
||||||
//! serial-порта.
|
//! serial-порта.
|
||||||
|
|
||||||
use crate::codec::{EpsonCodec, changed_runs, fit_to_width, replace_cached_range, sanitize_text};
|
use crate::codec::{
|
||||||
use crate::config::{DisplaySettings, VfdConfig};
|
EpsonCodec, changed_runs, fit_to_width, replace_cached_range, sanitize_text, sanitize_to_width,
|
||||||
|
};
|
||||||
|
use crate::config::{
|
||||||
|
DisplaySettings, MAX_MARQUEE_CHARS, MAX_MARQUEE_CPS, MAX_QUEUED_RAW_BYTES, VfdConfig,
|
||||||
|
};
|
||||||
use crate::error::{ConfigError, Result, VfdError};
|
use crate::error::{ConfigError, Result, VfdError};
|
||||||
use ::tokio::io::{AsyncWrite, AsyncWriteExt};
|
use ::tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||||
use ::tokio::sync::{mpsc, oneshot};
|
use ::tokio::sync::{mpsc, oneshot};
|
||||||
@@ -133,7 +137,7 @@ impl<T: AsyncWrite + Unpin> AsyncVfd<T> {
|
|||||||
/// ```
|
/// ```
|
||||||
pub async fn from_transport(mut transport: T, display: DisplaySettings) -> Result<Self> {
|
pub async fn from_transport(mut transport: T, display: DisplaySettings) -> Result<Self> {
|
||||||
display.validate()?;
|
display.validate()?;
|
||||||
let codec = EpsonCodec::new(display);
|
let codec = EpsonCodec::new(display)?;
|
||||||
let init = codec.init();
|
let init = codec.init();
|
||||||
if !init.is_empty() {
|
if !init.is_empty() {
|
||||||
transport.write_all(&init).await?;
|
transport.write_all(&init).await?;
|
||||||
@@ -182,7 +186,8 @@ impl<T: AsyncWrite + Unpin> AsyncVfd<T> {
|
|||||||
|
|
||||||
/// Выводит подготовленный кадр.
|
/// Выводит подготовленный кадр.
|
||||||
///
|
///
|
||||||
/// Метод не выполняет санацию текста. Используйте его для уже подготовленных кадров.
|
/// Метод не выполняет типографскую нормализацию до подгонки ширины. Управляющие
|
||||||
|
/// символы всё равно нейтрализуются на этапе кодирования.
|
||||||
///
|
///
|
||||||
/// # Ошибки
|
/// # Ошибки
|
||||||
///
|
///
|
||||||
@@ -203,8 +208,7 @@ impl<T: AsyncWrite + Unpin> AsyncVfd<T> {
|
|||||||
///
|
///
|
||||||
/// Возвращает [`VfdError::InvalidCoordinate`] или [`VfdError::Io`].
|
/// Возвращает [`VfdError::InvalidCoordinate`] или [`VfdError::Io`].
|
||||||
pub async fn print_at(&mut self, x: u8, y: u8, text: &str) -> Result<()> {
|
pub async fn print_at(&mut self, x: u8, y: u8, text: &str) -> Result<()> {
|
||||||
let text = sanitize_text(text);
|
self.print_at_prepared(x, y, text).await
|
||||||
self.print_at_prepared(x, y, &text).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> Result<()> {
|
async fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> Result<()> {
|
||||||
@@ -282,6 +286,7 @@ impl<T: AsyncWrite + Unpin> AsyncVfd<T> {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AsyncVfdHandle {
|
pub struct AsyncVfdHandle {
|
||||||
tx: mpsc::Sender<Cmd>,
|
tx: mpsc::Sender<Cmd>,
|
||||||
|
columns: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncVfdHandle {
|
impl AsyncVfdHandle {
|
||||||
@@ -312,6 +317,7 @@ impl AsyncVfdHandle {
|
|||||||
/// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O.
|
/// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O.
|
||||||
pub async fn print_line(&self, line: u8, text: impl Into<String>) -> Result<()> {
|
pub async fn print_line(&self, line: u8, text: impl Into<String>) -> Result<()> {
|
||||||
let text = text.into();
|
let text = text.into();
|
||||||
|
let text = prepare_text_for_queue(&text, self.columns);
|
||||||
self.call(|ack| Cmd::PrintLine { line, text, ack }).await
|
self.call(|ack| Cmd::PrintLine { line, text, ack }).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,6 +331,7 @@ impl AsyncVfdHandle {
|
|||||||
/// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O.
|
/// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O.
|
||||||
pub async fn print_line_diff(&self, line: u8, text: impl Into<String>) -> Result<()> {
|
pub async fn print_line_diff(&self, line: u8, text: impl Into<String>) -> Result<()> {
|
||||||
let text = text.into();
|
let text = text.into();
|
||||||
|
let text = prepare_text_for_queue(&text, self.columns);
|
||||||
self.call(|ack| Cmd::PrintLineDiff { line, text, ack })
|
self.call(|ack| Cmd::PrintLineDiff { line, text, ack })
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -336,43 +343,61 @@ impl AsyncVfdHandle {
|
|||||||
/// Возвращает [`VfdError::InvalidCoordinate`], ошибки очереди или I/O.
|
/// Возвращает [`VfdError::InvalidCoordinate`], ошибки очереди или I/O.
|
||||||
pub async fn print_at(&self, x: u8, y: u8, text: impl Into<String>) -> Result<()> {
|
pub async fn print_at(&self, x: u8, y: u8, text: impl Into<String>) -> Result<()> {
|
||||||
let text = text.into();
|
let text = text.into();
|
||||||
|
let remaining = if x == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.columns
|
||||||
|
.checked_sub(usize::from(x))
|
||||||
|
.map_or(0, |remaining| remaining + 1)
|
||||||
|
};
|
||||||
|
let text = prepare_text_for_queue(&text, remaining);
|
||||||
self.call(|ack| Cmd::PrintAt { x, y, text, ack }).await
|
self.call(|ack| Cmd::PrintAt { x, y, text, ack }).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Записывает байты напрямую.
|
/// Записывает байты напрямую.
|
||||||
///
|
///
|
||||||
/// Raw-байты не обновляют строковый кэш worker.
|
/// Raw-байты не обновляют строковый кэш worker.
|
||||||
|
/// Одна queued-команда ограничена [`MAX_QUEUED_RAW_BYTES`] байтами.
|
||||||
///
|
///
|
||||||
/// # Ошибки
|
/// # Ошибки
|
||||||
///
|
///
|
||||||
/// Возвращает ошибки очереди или [`VfdError::Io`].
|
/// Возвращает ошибки очереди или [`VfdError::Io`].
|
||||||
pub async fn write_raw(&self, bytes: impl Into<Vec<u8>>) -> Result<()> {
|
pub async fn write_raw(&self, bytes: impl Into<Vec<u8>>) -> Result<()> {
|
||||||
let bytes = bytes.into();
|
let bytes = bytes.into();
|
||||||
|
if bytes.len() > MAX_QUEUED_RAW_BYTES {
|
||||||
|
return Err(VfdError::RawPayloadTooLarge {
|
||||||
|
length: bytes.len(),
|
||||||
|
max: MAX_QUEUED_RAW_BYTES,
|
||||||
|
});
|
||||||
|
}
|
||||||
self.call(|ack| Cmd::WriteRaw { bytes, ack }).await
|
self.call(|ack| Cmd::WriteRaw { bytes, ack }).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Заменяет текст бегущей строки.
|
/// Заменяет текст бегущей строки.
|
||||||
///
|
///
|
||||||
/// Если marquee уже активна, поток символов перестраивается сразу. Сам метод не
|
/// Если marquee уже активна, поток символов перестраивается сразу. Сам метод не
|
||||||
/// пишет кадр синхронно; запись произойдёт по таймеру worker-а.
|
/// пишет кадр синхронно; запись произойдёт по таймеру worker-а. Длина текста
|
||||||
|
/// ограничена [`MAX_MARQUEE_CHARS`] символами.
|
||||||
///
|
///
|
||||||
/// # Ошибки
|
/// # Ошибки
|
||||||
///
|
///
|
||||||
/// Возвращает ошибки очереди или остановленного worker-а.
|
/// Возвращает ошибки очереди или остановленного worker-а.
|
||||||
pub async fn set_marquee_text(&self, text: impl Into<String>) -> Result<()> {
|
pub async fn set_marquee_text(&self, text: impl Into<String>) -> Result<()> {
|
||||||
let text = text.into();
|
let text = text.into();
|
||||||
|
let text = prepare_marquee_text(&text)?;
|
||||||
self.call(|ack| Cmd::SetMarqueeText { text, ack }).await
|
self.call(|ack| Cmd::SetMarqueeText { text, ack }).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Запускает бегущую строку.
|
/// Запускает бегущую строку.
|
||||||
///
|
///
|
||||||
/// `cps` - скорость в символах в секунду; `0` приводится к `1`. `end_pause` задаёт
|
/// `cps` - скорость в символах в секунду; `0` приводится к `1`, а значения выше
|
||||||
/// async-паузу в конце полного прохода текста.
|
/// [`MAX_MARQUEE_CPS`] отклоняются. `end_pause` задаёт async-паузу в конце прохода.
|
||||||
///
|
///
|
||||||
/// # Ошибки
|
/// # Ошибки
|
||||||
///
|
///
|
||||||
/// Возвращает [`VfdError::InvalidLine`] или ошибки очереди.
|
/// Возвращает [`VfdError::InvalidLine`] или ошибки очереди.
|
||||||
pub async fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) -> Result<()> {
|
pub async fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) -> Result<()> {
|
||||||
|
validate_marquee_speed(cps)?;
|
||||||
self.call(|ack| Cmd::StartMarquee {
|
self.call(|ack| Cmd::StartMarquee {
|
||||||
line,
|
line,
|
||||||
cps,
|
cps,
|
||||||
@@ -452,7 +477,8 @@ impl<T: AsyncWrite + Unpin + Send + 'static> AsyncVfdWorker<T> {
|
|||||||
return Err(ConfigError::ZeroQueueCapacity.into());
|
return Err(ConfigError::ZeroQueueCapacity.into());
|
||||||
}
|
}
|
||||||
let (tx, rx) = mpsc::channel(queue_capacity);
|
let (tx, rx) = mpsc::channel(queue_capacity);
|
||||||
let handle = AsyncVfdHandle { tx };
|
let columns = vfd.columns();
|
||||||
|
let handle = AsyncVfdHandle { tx, columns };
|
||||||
let join = ::tokio::spawn(async move { writer_loop(vfd, rx).await });
|
let join = ::tokio::spawn(async move { writer_loop(vfd, rx).await });
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -562,7 +588,7 @@ impl MarqueeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn step_interval(&self) -> Duration {
|
fn step_interval(&self) -> Duration {
|
||||||
let cps = u64::from(self.cps.max(1));
|
let cps = u64::from(self.cps.clamp(1, MAX_MARQUEE_CPS));
|
||||||
Duration::from_nanos((1_000_000_000 / cps).max(1))
|
Duration::from_nanos((1_000_000_000 / cps).max(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,7 +694,12 @@ async fn handle_command<T: AsyncWrite + Unpin>(
|
|||||||
end_pause,
|
end_pause,
|
||||||
ack,
|
ack,
|
||||||
} => {
|
} => {
|
||||||
let result = if line == 0 || usize::from(line) > rows {
|
let result = if cps > MAX_MARQUEE_CPS {
|
||||||
|
Err(VfdError::InvalidMarqueeSpeed {
|
||||||
|
cps,
|
||||||
|
max: MAX_MARQUEE_CPS,
|
||||||
|
})
|
||||||
|
} else if line == 0 || usize::from(line) > rows {
|
||||||
Err(VfdError::InvalidLine { line, rows })
|
Err(VfdError::InvalidLine { line, rows })
|
||||||
} else {
|
} else {
|
||||||
last_lines[(line - 1) as usize].clear();
|
last_lines[(line - 1) as usize].clear();
|
||||||
@@ -807,6 +838,29 @@ fn send_ack(ack: Ack, result: Result<()>) {
|
|||||||
let _ = ack.send(result);
|
let _ = ack.send(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prepare_text_for_queue(text: &str, max_chars: usize) -> String {
|
||||||
|
sanitize_to_width(text, max_chars)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_marquee_text(text: &str) -> Result<String> {
|
||||||
|
if text.chars().nth(MAX_MARQUEE_CHARS).is_some() {
|
||||||
|
return Err(VfdError::TextTooLong {
|
||||||
|
max: MAX_MARQUEE_CHARS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(sanitize_to_width(text, MAX_MARQUEE_CHARS))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_marquee_speed(cps: u32) -> Result<()> {
|
||||||
|
if cps > MAX_MARQUEE_CPS {
|
||||||
|
return Err(VfdError::InvalidMarqueeSpeed {
|
||||||
|
cps,
|
||||||
|
max: MAX_MARQUEE_CPS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -972,4 +1026,37 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(vfd.rows(), 2);
|
assert_eq!(vfd.rows(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn async_text_and_queue_limits_match_sync_behavior() {
|
||||||
|
let display = DisplaySettings::new(8, 1, TextEncoding::Ascii);
|
||||||
|
let mut vfd = AsyncVfd::from_transport(Vec::<u8>::new(), display)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
vfd.print_line(1, "A\u{1b}@B\u{0c}C").await.unwrap();
|
||||||
|
assert_eq!(&vfd.into_inner()[6..], b"A @B C ");
|
||||||
|
|
||||||
|
let display = DisplaySettings::new(20, 2, TextEncoding::Ascii);
|
||||||
|
let worker = AsyncVfdWorker::from_transport(Vec::<u8>::new(), display, 2)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let handle = worker.handle();
|
||||||
|
assert!(matches!(
|
||||||
|
handle
|
||||||
|
.set_marquee_text("x".repeat(MAX_MARQUEE_CHARS + 1))
|
||||||
|
.await,
|
||||||
|
Err(VfdError::TextTooLong { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
handle.write_raw(vec![0; MAX_QUEUED_RAW_BYTES + 1]).await,
|
||||||
|
Err(VfdError::RawPayloadTooLarge { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
handle
|
||||||
|
.start_marquee(1, MAX_MARQUEE_CPS + 1, Duration::ZERO)
|
||||||
|
.await,
|
||||||
|
Err(VfdError::InvalidMarqueeSpeed { .. })
|
||||||
|
));
|
||||||
|
worker.shutdown().await.unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-8
@@ -5,7 +5,7 @@
|
|||||||
//! функции. Для фоновой сериализации команд из разных частей приложения используйте
|
//! функции. Для фоновой сериализации команд из разных частей приложения используйте
|
||||||
//! [`crate::VfdWorker`] или `escpos_vfd::tokio::AsyncVfdWorker`.
|
//! [`crate::VfdWorker`] или `escpos_vfd::tokio::AsyncVfdWorker`.
|
||||||
|
|
||||||
use crate::codec::{EpsonCodec, fit_to_width, sanitize_text, truncate_chars};
|
use crate::codec::{EpsonCodec, fit_to_width, truncate_chars};
|
||||||
use crate::config::{DisplaySettings, VfdConfig};
|
use crate::config::{DisplaySettings, VfdConfig};
|
||||||
use crate::error::{Result, VfdError};
|
use crate::error::{Result, VfdError};
|
||||||
use serialport::SerialPort;
|
use serialport::SerialPort;
|
||||||
@@ -84,7 +84,7 @@ impl<T: Write> Vfd<T> {
|
|||||||
/// ```
|
/// ```
|
||||||
pub fn from_transport(mut transport: T, display: DisplaySettings) -> Result<Self> {
|
pub fn from_transport(mut transport: T, display: DisplaySettings) -> Result<Self> {
|
||||||
display.validate()?;
|
display.validate()?;
|
||||||
let codec = EpsonCodec::new(display);
|
let codec = EpsonCodec::new(display)?;
|
||||||
let init = codec.init();
|
let init = codec.init();
|
||||||
if !init.is_empty() {
|
if !init.is_empty() {
|
||||||
transport.write_all(&init)?;
|
transport.write_all(&init)?;
|
||||||
@@ -138,9 +138,8 @@ impl<T: Write> Vfd<T> {
|
|||||||
|
|
||||||
/// Выводит подготовленный кадр с начала строки без предварительной очистки.
|
/// Выводит подготовленный кадр с начала строки без предварительной очистки.
|
||||||
///
|
///
|
||||||
/// В отличие от [`Vfd::print_line`], метод не выполняет санацию текста. Используйте
|
/// В отличие от [`Vfd::print_line`], метод не выполняет типографскую нормализацию до
|
||||||
/// его для заранее подготовленных кадров бегущей строки или тестовых байтовых
|
/// подгонки ширины. Управляющие символы всё равно нейтрализуются на этапе кодирования.
|
||||||
/// сценариев, когда содержимое уже нормализовано вызывающим кодом.
|
|
||||||
///
|
///
|
||||||
/// # Ошибки
|
/// # Ошибки
|
||||||
///
|
///
|
||||||
@@ -161,11 +160,10 @@ impl<T: Write> Vfd<T> {
|
|||||||
///
|
///
|
||||||
/// Возвращает [`VfdError::InvalidCoordinate`] или [`VfdError::Io`].
|
/// Возвращает [`VfdError::InvalidCoordinate`] или [`VfdError::Io`].
|
||||||
pub fn print_at(&mut self, x: u8, y: u8, text: &str) -> Result<()> {
|
pub fn print_at(&mut self, x: u8, y: u8, text: &str) -> Result<()> {
|
||||||
let text = sanitize_text(text);
|
self.print_at_prepared(x, y, text)
|
||||||
self.print_at_prepared(x, y, &text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Печатает уже подготовленный текст без повторной санации.
|
/// Печатает уже подготовленный текст; управляющие символы нейтрализуются codec-ом.
|
||||||
pub(crate) fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> Result<()> {
|
pub(crate) fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> Result<()> {
|
||||||
self.codec.validate_xy(x, y)?;
|
self.codec.validate_xy(x, y)?;
|
||||||
let remaining = self.columns() - usize::from(x) + 1;
|
let remaining = self.columns() - usize::from(x) + 1;
|
||||||
@@ -310,4 +308,14 @@ mod tests {
|
|||||||
vec![0x1B, 0x40, 0x1F, 0x24, 1, 1, 0xF2, b' ', b' ', b' ']
|
vec![0x1B, 0x40, 0x1F, 0x24, 1, 1, 0xF2, b' ', b' ', b' ']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn high_level_text_neutralizes_protocol_controls() {
|
||||||
|
let display = DisplaySettings::new(8, 1, TextEncoding::Ascii);
|
||||||
|
let mut vfd = Vfd::from_transport(Vec::<u8>::new(), display).unwrap();
|
||||||
|
|
||||||
|
vfd.print_line(1, "A\u{1b}@B\u{0c}C").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(&vfd.into_inner()[6..], b"A @B C ");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-9
@@ -5,8 +5,12 @@
|
|||||||
//! Очередь команд ограничена `queue_capacity`, поэтому быстрые producer-ы получают
|
//! Очередь команд ограничена `queue_capacity`, поэтому быстрые producer-ы получают
|
||||||
//! backpressure вместо неограниченного роста памяти.
|
//! backpressure вместо неограниченного роста памяти.
|
||||||
|
|
||||||
use crate::codec::{changed_runs, fit_to_width, replace_cached_range, sanitize_text};
|
use crate::codec::{
|
||||||
use crate::config::{DisplaySettings, VfdConfig};
|
changed_runs, fit_to_width, replace_cached_range, sanitize_text, sanitize_to_width,
|
||||||
|
};
|
||||||
|
use crate::config::{
|
||||||
|
DisplaySettings, MAX_MARQUEE_CHARS, MAX_MARQUEE_CPS, MAX_QUEUED_RAW_BYTES, VfdConfig,
|
||||||
|
};
|
||||||
use crate::error::{ConfigError, Result, VfdError};
|
use crate::error::{ConfigError, Result, VfdError};
|
||||||
use crate::vfd::Vfd;
|
use crate::vfd::Vfd;
|
||||||
use serialport::SerialPort;
|
use serialport::SerialPort;
|
||||||
@@ -73,6 +77,7 @@ enum Cmd {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct VfdHandle {
|
pub struct VfdHandle {
|
||||||
tx: SyncSender<Cmd>,
|
tx: SyncSender<Cmd>,
|
||||||
|
columns: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VfdHandle {
|
impl VfdHandle {
|
||||||
@@ -108,6 +113,7 @@ impl VfdHandle {
|
|||||||
/// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O.
|
/// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O.
|
||||||
pub fn print_line(&self, line: u8, text: impl Into<String>) -> Result<()> {
|
pub fn print_line(&self, line: u8, text: impl Into<String>) -> Result<()> {
|
||||||
let text = text.into();
|
let text = text.into();
|
||||||
|
let text = prepare_text_for_queue(&text, self.columns);
|
||||||
self.call(|ack| Cmd::PrintLine { line, text, ack })
|
self.call(|ack| Cmd::PrintLine { line, text, ack })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +131,7 @@ impl VfdHandle {
|
|||||||
/// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O.
|
/// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O.
|
||||||
pub fn print_line_diff(&self, line: u8, text: impl Into<String>) -> Result<()> {
|
pub fn print_line_diff(&self, line: u8, text: impl Into<String>) -> Result<()> {
|
||||||
let text = text.into();
|
let text = text.into();
|
||||||
|
let text = prepare_text_for_queue(&text, self.columns);
|
||||||
self.call(|ack| Cmd::PrintLineDiff { line, text, ack })
|
self.call(|ack| Cmd::PrintLineDiff { line, text, ack })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +145,14 @@ impl VfdHandle {
|
|||||||
/// Возвращает [`VfdError::InvalidCoordinate`], ошибки очереди или I/O.
|
/// Возвращает [`VfdError::InvalidCoordinate`], ошибки очереди или I/O.
|
||||||
pub fn print_at(&self, x: u8, y: u8, text: impl Into<String>) -> Result<()> {
|
pub fn print_at(&self, x: u8, y: u8, text: impl Into<String>) -> Result<()> {
|
||||||
let text = text.into();
|
let text = text.into();
|
||||||
|
let remaining = if x == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.columns
|
||||||
|
.checked_sub(usize::from(x))
|
||||||
|
.map_or(0, |remaining| remaining + 1)
|
||||||
|
};
|
||||||
|
let text = prepare_text_for_queue(&text, remaining);
|
||||||
self.call(|ack| Cmd::PrintAt { x, y, text, ack })
|
self.call(|ack| Cmd::PrintAt { x, y, text, ack })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,39 +160,49 @@ impl VfdHandle {
|
|||||||
///
|
///
|
||||||
/// Байты не кодируются и не отражаются в строковом кэше worker. После raw-команд,
|
/// Байты не кодируются и не отражаются в строковом кэше worker. После raw-команд,
|
||||||
/// которые меняют видимый текст, лучше выполнить обычный `print_line` или `clear`,
|
/// которые меняют видимый текст, лучше выполнить обычный `print_line` или `clear`,
|
||||||
/// чтобы синхронизировать кэш с дисплеем.
|
/// чтобы синхронизировать кэш с дисплеем. Одна queued-команда ограничена
|
||||||
|
/// [`MAX_QUEUED_RAW_BYTES`] байтами.
|
||||||
///
|
///
|
||||||
/// # Ошибки
|
/// # Ошибки
|
||||||
///
|
///
|
||||||
/// Возвращает ошибки очереди или [`VfdError::Io`].
|
/// Возвращает ошибки очереди или [`VfdError::Io`].
|
||||||
pub fn write_raw(&self, bytes: impl Into<Vec<u8>>) -> Result<()> {
|
pub fn write_raw(&self, bytes: impl Into<Vec<u8>>) -> Result<()> {
|
||||||
let bytes = bytes.into();
|
let bytes = bytes.into();
|
||||||
|
if bytes.len() > MAX_QUEUED_RAW_BYTES {
|
||||||
|
return Err(VfdError::RawPayloadTooLarge {
|
||||||
|
length: bytes.len(),
|
||||||
|
max: MAX_QUEUED_RAW_BYTES,
|
||||||
|
});
|
||||||
|
}
|
||||||
self.call(|ack| Cmd::WriteRaw { bytes, ack })
|
self.call(|ack| Cmd::WriteRaw { bytes, ack })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Заменяет текст бегущей строки.
|
/// Заменяет текст бегущей строки.
|
||||||
///
|
///
|
||||||
/// Если marquee уже активна, поток символов перестраивается сразу. Метод сам ничего
|
/// Если marquee уже активна, поток символов перестраивается сразу. Метод сам ничего
|
||||||
/// не пишет в дисплей; следующий кадр будет записан по таймеру worker.
|
/// не пишет в дисплей; следующий кадр будет записан по таймеру worker. Длина текста
|
||||||
|
/// ограничена [`MAX_MARQUEE_CHARS`] символами.
|
||||||
///
|
///
|
||||||
/// # Ошибки
|
/// # Ошибки
|
||||||
///
|
///
|
||||||
/// Возвращает ошибки очереди, если worker закрыт или остановлен.
|
/// Возвращает ошибки очереди, если worker закрыт или остановлен.
|
||||||
pub fn set_marquee_text(&self, text: impl Into<String>) -> Result<()> {
|
pub fn set_marquee_text(&self, text: impl Into<String>) -> Result<()> {
|
||||||
let text = text.into();
|
let text = text.into();
|
||||||
|
let text = prepare_marquee_text(&text)?;
|
||||||
self.call(|ack| Cmd::SetMarqueeText { text, ack })
|
self.call(|ack| Cmd::SetMarqueeText { text, ack })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Запускает бегущую строку на выбранной линии.
|
/// Запускает бегущую строку на выбранной линии.
|
||||||
///
|
///
|
||||||
/// `cps` - скорость в символах в секунду. Значение `0` безопасно приводится к `1`,
|
/// `cps` - скорость в символах в секунду. Значение `0` безопасно приводится к `1`,
|
||||||
/// чтобы таймер не схлопнулся в нулевой интервал. `end_pause` - пауза после полного
|
/// значения выше [`MAX_MARQUEE_CPS`] отклоняются. `end_pause` - пауза после полного
|
||||||
/// прохода текста перед следующим циклом.
|
/// прохода текста перед следующим циклом.
|
||||||
///
|
///
|
||||||
/// # Ошибки
|
/// # Ошибки
|
||||||
///
|
///
|
||||||
/// Возвращает [`VfdError::InvalidLine`], если строка вне дисплея, или ошибки очереди.
|
/// Возвращает [`VfdError::InvalidLine`], если строка вне дисплея, или ошибки очереди.
|
||||||
pub fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) -> Result<()> {
|
pub fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) -> Result<()> {
|
||||||
|
validate_marquee_speed(cps)?;
|
||||||
self.call(|ack| Cmd::StartMarquee {
|
self.call(|ack| Cmd::StartMarquee {
|
||||||
line,
|
line,
|
||||||
cps,
|
cps,
|
||||||
@@ -261,7 +286,8 @@ impl<T: Write + Send + 'static> VfdWorker<T> {
|
|||||||
return Err(ConfigError::ZeroQueueCapacity.into());
|
return Err(ConfigError::ZeroQueueCapacity.into());
|
||||||
}
|
}
|
||||||
let (tx, rx) = mpsc::sync_channel::<Cmd>(queue_capacity);
|
let (tx, rx) = mpsc::sync_channel::<Cmd>(queue_capacity);
|
||||||
let handle = VfdHandle { tx };
|
let columns = vfd.columns();
|
||||||
|
let handle = VfdHandle { tx, columns };
|
||||||
let join = thread::spawn(move || writer_loop(vfd, rx));
|
let join = thread::spawn(move || writer_loop(vfd, rx));
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -373,7 +399,7 @@ impl MarqueeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn step_interval(&self) -> Duration {
|
fn step_interval(&self) -> Duration {
|
||||||
let cps = u64::from(self.cps.max(1));
|
let cps = u64::from(self.cps.clamp(1, MAX_MARQUEE_CPS));
|
||||||
Duration::from_nanos((1_000_000_000 / cps).max(1))
|
Duration::from_nanos((1_000_000_000 / cps).max(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +501,12 @@ fn handle_command<T: Write>(
|
|||||||
end_pause,
|
end_pause,
|
||||||
ack,
|
ack,
|
||||||
} => {
|
} => {
|
||||||
let result = if line == 0 || usize::from(line) > rows {
|
let result = if cps > MAX_MARQUEE_CPS {
|
||||||
|
Err(VfdError::InvalidMarqueeSpeed {
|
||||||
|
cps,
|
||||||
|
max: MAX_MARQUEE_CPS,
|
||||||
|
})
|
||||||
|
} else if line == 0 || usize::from(line) > rows {
|
||||||
Err(VfdError::InvalidLine { line, rows })
|
Err(VfdError::InvalidLine { line, rows })
|
||||||
} else {
|
} else {
|
||||||
last_lines[(line - 1) as usize].clear();
|
last_lines[(line - 1) as usize].clear();
|
||||||
@@ -612,6 +643,29 @@ fn render_marquee<T: Write>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prepare_text_for_queue(text: &str, max_chars: usize) -> String {
|
||||||
|
sanitize_to_width(text, max_chars)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_marquee_text(text: &str) -> Result<String> {
|
||||||
|
if text.chars().nth(MAX_MARQUEE_CHARS).is_some() {
|
||||||
|
return Err(VfdError::TextTooLong {
|
||||||
|
max: MAX_MARQUEE_CHARS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(sanitize_to_width(text, MAX_MARQUEE_CHARS))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_marquee_speed(cps: u32) -> Result<()> {
|
||||||
|
if cps > MAX_MARQUEE_CPS {
|
||||||
|
return Err(VfdError::InvalidMarqueeSpeed {
|
||||||
|
cps,
|
||||||
|
max: MAX_MARQUEE_CPS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -646,7 +700,10 @@ mod tests {
|
|||||||
fn marquee_interval_never_collapses_to_zero() {
|
fn marquee_interval_never_collapses_to_zero() {
|
||||||
let mut marquee = MarqueeState::new();
|
let mut marquee = MarqueeState::new();
|
||||||
marquee.cps = u32::MAX;
|
marquee.cps = u32::MAX;
|
||||||
assert!(!marquee.step_interval().is_zero());
|
assert_eq!(
|
||||||
|
marquee.step_interval(),
|
||||||
|
Duration::from_nanos(1_000_000_000 / u64::from(MAX_MARQUEE_CPS))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -715,4 +772,31 @@ mod tests {
|
|||||||
|
|
||||||
assert!(matches!(worker.shutdown(), Err(VfdError::Io(_))));
|
assert!(matches!(worker.shutdown(), Err(VfdError::Io(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_rejects_oversized_queued_payloads_and_marquee_rate() {
|
||||||
|
let display = DisplaySettings::new(20, 2, TextEncoding::Ascii);
|
||||||
|
let worker = VfdWorker::from_transport(Vec::<u8>::new(), display, 2).unwrap();
|
||||||
|
let handle = worker.handle();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
handle.set_marquee_text("x".repeat(MAX_MARQUEE_CHARS + 1)),
|
||||||
|
Err(VfdError::TextTooLong { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
handle.write_raw(vec![0; MAX_QUEUED_RAW_BYTES + 1]),
|
||||||
|
Err(VfdError::RawPayloadTooLarge { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
handle.start_marquee(1, MAX_MARQUEE_CPS + 1, Duration::ZERO),
|
||||||
|
Err(VfdError::InvalidMarqueeSpeed { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
worker.shutdown().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queued_line_text_is_prepared_to_display_width() {
|
||||||
|
assert_eq!(prepare_text_for_queue("ab\u{1b}@long", 4), "ab @");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -25,7 +25,7 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- cargo run --example clock -- {{.VFD_PORT}} {{.VFD_WIDTH}} {{.VFD_BRIGHTNESS | default "2"}}
|
- cargo run --example clock -- {{.VFD_PORT}} {{.VFD_WIDTH}} {{.VFD_BRIGHTNESS | default "2"}}
|
||||||
vars:
|
vars:
|
||||||
VFD_BRIGHTNESS: "2"
|
VFD_BRIGHTNESS: "4"
|
||||||
|
|
||||||
vfd:marquee:
|
vfd:marquee:
|
||||||
desc: Run VFD marquee example
|
desc: Run VFD marquee example
|
||||||
@@ -34,7 +34,7 @@ tasks:
|
|||||||
vars:
|
vars:
|
||||||
VFD_CPS: "8"
|
VFD_CPS: "8"
|
||||||
VFD_END_PAUSE_MS: "1500"
|
VFD_END_PAUSE_MS: "1500"
|
||||||
VFD_BRIGHTNESS: "3"
|
VFD_BRIGHTNESS: "4"
|
||||||
|
|
||||||
vfd:brightness:
|
vfd:brightness:
|
||||||
desc: Run VFD brightness example
|
desc: Run VFD brightness example
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
use escpos_vfd::codec::EpsonCodec;
|
||||||
|
use escpos_vfd::{
|
||||||
|
ConfigError, DisplaySettings, MAX_MARQUEE_CHARS, MAX_MARQUEE_CPS, MAX_QUEUED_RAW_BYTES,
|
||||||
|
TextEncoding, Vfd, VfdError, VfdWorker,
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_text_api_neutralizes_controls_and_legacy_fallback_is_one_byte() {
|
||||||
|
let display = DisplaySettings::new(8, 1, TextEncoding::Ascii);
|
||||||
|
let mut vfd = Vfd::from_transport(Vec::<u8>::new(), display).unwrap();
|
||||||
|
vfd.print_line(1, "A\u{1b}@B\u{0c}C").unwrap();
|
||||||
|
assert_eq!(&vfd.into_inner()[6..], b"A @B C ");
|
||||||
|
|
||||||
|
for encoding in [TextEncoding::Cp866, TextEncoding::Windows1251] {
|
||||||
|
let codec = EpsonCodec::new(DisplaySettings::new(1, 1, encoding)).unwrap();
|
||||||
|
assert_eq!(codec.encode_text("😀"), vec![b'?']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_codec_constructor_returns_geometry_errors() {
|
||||||
|
assert!(matches!(
|
||||||
|
EpsonCodec::new(DisplaySettings::new(usize::MAX, 1, TextEncoding::Ascii)),
|
||||||
|
Err(ConfigError::InvalidColumns(usize::MAX))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_worker_enforces_payload_and_rate_boundaries() {
|
||||||
|
let display = DisplaySettings::new(20, 2, TextEncoding::Ascii);
|
||||||
|
let worker = VfdWorker::from_transport(Vec::<u8>::new(), display, 2).unwrap();
|
||||||
|
let handle = worker.handle();
|
||||||
|
|
||||||
|
handle.print_line(1, "x".repeat(1_000_000)).unwrap();
|
||||||
|
handle
|
||||||
|
.set_marquee_text("m".repeat(MAX_MARQUEE_CHARS))
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
handle.set_marquee_text("m".repeat(MAX_MARQUEE_CHARS + 1)),
|
||||||
|
Err(VfdError::TextTooLong { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
handle.write_raw(vec![0; MAX_QUEUED_RAW_BYTES]).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
handle.write_raw(vec![0; MAX_QUEUED_RAW_BYTES + 1]),
|
||||||
|
Err(VfdError::RawPayloadTooLarge { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
handle
|
||||||
|
.start_marquee(2, MAX_MARQUEE_CPS, Duration::ZERO)
|
||||||
|
.unwrap();
|
||||||
|
handle.stop_marquee().unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
handle.start_marquee(2, MAX_MARQUEE_CPS + 1, Duration::ZERO),
|
||||||
|
Err(VfdError::InvalidMarqueeSpeed { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let bytes = worker.shutdown().unwrap().into_inner();
|
||||||
|
assert!(bytes.windows(20).any(|window| window == [b'x'; 20]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "tokio")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tokio_public_api_matches_security_boundaries() {
|
||||||
|
use escpos_vfd::tokio::{AsyncVfd, AsyncVfdWorker};
|
||||||
|
|
||||||
|
let display = DisplaySettings::new(8, 1, TextEncoding::Ascii);
|
||||||
|
let mut vfd = AsyncVfd::from_transport(Vec::<u8>::new(), display)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
vfd.print_line(1, "A\u{1b}@B\u{0c}C").await.unwrap();
|
||||||
|
assert_eq!(&vfd.into_inner()[6..], b"A @B C ");
|
||||||
|
|
||||||
|
let display = DisplaySettings::new(20, 2, TextEncoding::Ascii);
|
||||||
|
let worker = AsyncVfdWorker::from_transport(Vec::<u8>::new(), display, 2)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let handle = worker.handle();
|
||||||
|
assert!(matches!(
|
||||||
|
handle
|
||||||
|
.set_marquee_text("m".repeat(MAX_MARQUEE_CHARS + 1))
|
||||||
|
.await,
|
||||||
|
Err(VfdError::TextTooLong { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
handle.write_raw(vec![0; MAX_QUEUED_RAW_BYTES + 1]).await,
|
||||||
|
Err(VfdError::RawPayloadTooLarge { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
handle
|
||||||
|
.start_marquee(1, MAX_MARQUEE_CPS + 1, Duration::ZERO)
|
||||||
|
.await,
|
||||||
|
Err(VfdError::InvalidMarqueeSpeed { .. })
|
||||||
|
));
|
||||||
|
worker.shutdown().await.unwrap();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user