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
+343
View File
@@ -0,0 +1,343 @@
//! Формирование байтов Epson/ESC/POS-команд без привязки к конкретному serial-порту.
//!
//! Этот модуль полезен для тестов, нестандартных транспортов и проверки того, какие
//! байты будут отправлены устройству при выбранных [`DisplaySettings`]. Обычным
//! приложениям чаще достаточно [`crate::Vfd`] или [`crate::VfdWorker`], но codec
//! остаётся публичным для диагностики и интеграции с собственным транспортом.
use crate::config::{DisplaySettings, TextEncoding};
use crate::error::{Result, VfdError};
use encoding_rs::{IBM866, WINDOWS_1251};
/// Кодировщик Epson/ESC/POS-команд для выбранной геометрии и таблицы символов.
///
/// `EpsonCodec` не открывает порт и не выполняет I/O. Он только валидирует координаты,
/// собирает управляющие последовательности и кодирует текст в выбранный [`TextEncoding`].
#[derive(Debug, Clone)]
pub struct EpsonCodec {
display: DisplaySettings,
}
impl EpsonCodec {
/// Создаёт codec для заданных настроек дисплея.
///
/// Значение `display.code_table` влияет только на байты инициализации `ESC t n`;
/// кодировка текста берётся из `display.encoding`.
///
/// Метод не валидирует настройки. Если codec создаётся не через [`crate::Vfd`],
/// вызовите [`DisplaySettings::validate`] самостоятельно.
pub fn new(display: DisplaySettings) -> Self {
Self { display }
}
/// Настройки дисплея, для которых работает codec.
pub fn display(&self) -> &DisplaySettings {
&self.display
}
/// Команды инициализации при открытии.
///
/// Возвращает `ESC @`, если включён `reset_on_open`, и `ESC t n`, если задана
/// аппаратная таблица символов. При полностью ручной конфигурации `code_table = None`
/// команда выбора таблицы не добавляется.
pub fn init(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(5);
if self.display.reset_on_open {
out.extend_from_slice(&[0x1B, 0x40]);
}
if let Some(table) = self.display.code_table {
out.extend_from_slice(&[0x1B, 0x74, table]);
}
out
}
/// Команда очистки дисплея.
///
/// Возвращает один байт `0x0C`. Запись в транспорт выполняет вызывающий код.
pub fn clear(&self) -> [u8; 1] {
[0x0C]
}
/// Команда позиционирования курсора в координатах от единицы.
///
/// # Ошибки
///
/// Возвращает [`VfdError::InvalidCoordinate`], если `x` или `y` равны нулю либо
/// выходят за `columns`/`rows`.
pub fn goto_xy(&self, x: u8, y: u8) -> Result<[u8; 4]> {
self.validate_xy(x, y)?;
Ok([0x1F, 0x24, x, y])
}
/// Команда установки яркости.
///
/// # Ошибки
///
/// Возвращает [`VfdError::UnsupportedBrightness`], если яркость выключена в
/// конфигурации (`brightness = None`) или уровень вне настроенного диапазона.
pub fn brightness(&self, level: u8) -> Result<[u8; 3]> {
if let Some(range) = &self.display.brightness {
if range.contains(&level) {
return Ok([0x1F, 0x58, level]);
}
return Err(VfdError::UnsupportedBrightness {
level,
min: *range.start(),
max: *range.end(),
});
}
Err(VfdError::UnsupportedBrightness {
level,
min: 0,
max: 0,
})
}
/// Кодирует текст в настроенной кодировке.
///
/// Для `Ascii` символы вне ASCII заменяются на `?`. Для CP866 и Windows-1251
/// используется `encoding_rs`, поэтому неподдерживаемые символы проходят стандартную
/// замену этой библиотеки.
pub fn encode_text(&self, text: &str) -> Vec<u8> {
match self.display.encoding {
TextEncoding::Cp866 => IBM866.encode(text).0.into_owned(),
TextEncoding::Windows1251 => WINDOWS_1251.encode(text).0.into_owned(),
TextEncoding::Ascii => text
.chars()
.map(|ch| if ch.is_ascii() { ch as u8 } else { b'?' })
.collect(),
TextEncoding::Utf8 => text.as_bytes().to_vec(),
}
}
/// Нормализует строку до фиксированной ширины.
///
/// Метод сначала применяет [`sanitize_text`], затем обрезает по числу символов и
/// дополняет пробелами до `display.columns`.
pub fn fit_line(&self, text: &str) -> String {
fit_to_width(&sanitize_text(text), self.display.columns)
}
/// Обрезает текст по правому краю от координаты `x`.
///
/// Проверяется только колонка `x`; строка для этой операции не нужна, поэтому для
/// проверки используется первая строка.
///
/// # Ошибки
///
/// Возвращает [`VfdError::InvalidCoordinate`], если `x` вне дисплея.
pub fn clip_from(&self, x: u8, text: &str) -> Result<String> {
self.validate_xy(x, 1)?;
let remaining = self.display.columns - usize::from(x) + 1;
Ok(truncate_chars(&sanitize_text(text), remaining).to_string())
}
/// Проверяет координаты относительно геометрии.
///
/// Координаты задаются от единицы. Значение `0` всегда ошибка.
///
/// # Ошибки
///
/// Возвращает [`VfdError::InvalidCoordinate`] с запрошенными координатами и текущей
/// геометрией дисплея.
pub fn validate_xy(&self, x: u8, y: u8) -> Result<()> {
if x == 0
|| y == 0
|| usize::from(x) > self.display.columns
|| usize::from(y) > self.display.rows
{
return Err(VfdError::InvalidCoordinate {
x,
y,
columns: self.display.columns,
rows: self.display.rows,
});
}
Ok(())
}
/// Проверяет строку относительно геометрии.
///
/// # Ошибки
///
/// Возвращает [`VfdError::InvalidLine`], если `line = 0` или строка больше
/// настроенного `rows`.
pub fn validate_line(&self, line: u8) -> Result<()> {
if line == 0 || usize::from(line) > self.display.rows {
return Err(VfdError::InvalidLine {
line,
rows: self.display.rows,
});
}
Ok(())
}
}
/// Возвращает срез не длиннее заданного числа символов, не разрывая UTF-8.
///
/// # Примеры
///
/// ```
/// assert_eq!(escpos_vfd::codec::truncate_chars("ёжик", 2), "ёж");
/// ```
pub fn truncate_chars(s: &str, max_chars: usize) -> &str {
s.char_indices()
.nth(max_chars)
.map_or(s, |(byte_index, _)| &s[..byte_index])
}
/// Заменяет типографские символы на безопасные аналоги для однобайтовых таблиц.
///
/// Функция намеренно не выбирает кодировку. Она только убирает символы вроде длинного
/// тире, табуляции и `№`, которые часто плохо представлены на VFD-дисплеях.
pub fn sanitize_text(s: &str) -> String {
s.chars()
.map(|c| match c {
'…' => '.',
'—' | '' => '-',
'№' => '#',
'\t' => ' ',
'“' | '”' => '"',
'' | '' => '\'',
_ => c,
})
.collect()
}
/// Совместимый алиас для старого helper.
///
/// Новому коду лучше использовать [`sanitize_text`]: санация теперь общая для разных
/// однобайтовых кодировок, а не только для CP866.
pub fn sanitize_for_cp866(s: &str) -> String {
sanitize_text(s)
}
/// Обрезает строку по числу символов и дополняет пробелами до заданной ширины.
///
/// Функция считает Unicode scalar values, а не байты. Это важно для кириллицы:
/// строка не будет обрезана посередине UTF-8 последовательности.
///
/// # Примеры
///
/// ```
/// use escpos_vfd::fit_to_width;
///
/// assert_eq!(fit_to_width("Привет", 4), "Прив");
/// assert_eq!(fit_to_width("да", 4), "да ");
/// ```
pub fn fit_to_width(s: &str, width: usize) -> String {
let mut out = String::with_capacity(width);
let mut len = 0;
for ch in s.chars().take(width) {
out.push(ch);
len += 1;
}
out.extend(std::iter::repeat_n(' ', width.saturating_sub(len)));
out
}
pub(crate) fn changed_runs(current: &str, next: &str) -> Vec<(u8, String)> {
let mut runs = Vec::new();
let mut run_start = None;
let mut run_text = String::new();
for (index, (old, new)) in current.chars().zip(next.chars()).enumerate() {
if old != new {
run_start.get_or_insert((index + 1) as u8);
run_text.push(new);
} else if let Some(start) = run_start.take() {
runs.push((start, std::mem::take(&mut run_text)));
}
}
if let Some(start) = run_start {
runs.push((start, run_text));
}
runs
}
pub(crate) fn replace_cached_range(line: &mut String, x: u8, text: &str, width: usize) {
if x == 0 || usize::from(x) > width || text.is_empty() {
return;
}
let mut chars: Vec<char> = line.chars().take(width).collect();
chars.resize(width, ' ');
let start = usize::from(x) - 1;
for (slot, ch) in chars[start..].iter_mut().zip(text.chars()) {
*slot = ch;
}
line.clear();
line.extend(chars);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Preset, VfdConfig};
#[test]
fn preset_init_matches_legacy_reset_and_cp866_table() {
let cfg = VfdConfig::preset("test", Preset::Epson20x2Cp866).unwrap();
let codec = EpsonCodec::new(cfg.display);
assert_eq!(codec.init(), vec![0x1B, 0x40, 0x1B, 0x74, 6]);
}
#[test]
fn optional_code_table_can_be_omitted() {
let display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
let codec = EpsonCodec::new(display);
assert_eq!(codec.init(), vec![0x1B, 0x40]);
}
#[test]
fn validates_geometry_instead_of_ignoring_invalid_coordinates() {
let codec = EpsonCodec::new(DisplaySettings::new(20, 4, TextEncoding::Cp866));
assert!(codec.goto_xy(1, 4).is_ok());
assert!(matches!(
codec.goto_xy(21, 1),
Err(VfdError::InvalidCoordinate { .. })
));
assert!(matches!(
codec.validate_line(5),
Err(VfdError::InvalidLine { .. })
));
}
#[test]
fn sanitizes_and_fits_unicode_by_characters() {
assert_eq!(sanitize_for_cp866("№1\t“тест”—‘да’…"), "#1 \"тест\"-'да'.");
assert_eq!(fit_to_width("Привет", 4), "Прив");
assert_eq!(fit_to_width("да", 4), "да ");
assert_eq!(fit_to_width("text", 0), "");
assert_eq!(truncate_chars("ёжик", 3), "ёжи");
}
#[test]
fn cache_update_replaces_the_complete_fragment() {
let mut line = "Temp: 00.00C ".to_string();
replace_cached_range(&mut line, 7, "21.50", 20);
assert_eq!(line, "Temp: 21.50C ");
}
#[test]
fn cache_update_handles_unicode_and_clips_at_the_right_edge() {
let mut line = String::new();
replace_cached_range(&mut line, 3, "ёжик", 5);
assert_eq!(line, " ёжи");
}
#[test]
fn diff_groups_adjacent_changes_into_minimal_runs() {
assert_eq!(
changed_runs("abcd efgh", "abXY eZZh"),
vec![(3, "XY".to_string()), (7, "ZZ".to_string())]
);
assert!(changed_runs("без перемен", "без перемен").is_empty());
}
}
+405
View File
@@ -0,0 +1,405 @@
//! Типизированная конфигурация serial-транспорта и параметров дисплея.
//!
//! Вручную созданный [`VfdConfig`] не содержит скрытых значений baud rate, кодовой
//! таблицы или размера экрана. Единственный путь с заранее выбранными настройками -
//! [`VfdConfig::preset`].
//!
//! Главная идея конфигурации: `SerialSettings` описывает только способ подключиться к
//! порту, а `DisplaySettings` описывает геометрию и ESC/POS-поведение устройства. Это
//! позволяет использовать один и тот же дисплей на разных портах или один serial-режим
//! с разными моделями дисплеев без глобальных констант.
use crate::error::ConfigError;
use serialport::{DataBits, FlowControl, Parity, StopBits};
use std::time::Duration;
/// Преднастроенные профили известных ESC/POS-совместимых дисплеев.
///
/// Пресеты нужны для сохранения проверенных наборов настроек, но не ограничивают ручную
/// конфигурацию. Если устройство отличается хотя бы одним параметром, используйте
/// [`SerialSettings`], [`DisplaySettings`] и [`VfdConfig::new`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Preset {
/// Старое поведение этой библиотеки: 20x2, 9600 8N1, CP866, `ESC t 6`.
///
/// Пресет подходит для Epson/ESC/POS-совместимых VFD, которые ожидают кириллицу в
/// CP866 и выбирают нужную аппаратную таблицу командой `ESC t 6`.
Epson20x2Cp866,
}
/// Настройки serial-подключения.
///
/// Все поля публичные, чтобы приложение могло точно выставить режим конкретного
/// устройства. Значения проверяются перед открытием порта через [`SerialSettings::validate`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SerialSettings {
/// Имя serial-порта, например `/dev/cu.usbmodem101`, `/dev/ttyUSB0` или `COM3`.
pub port_name: String,
/// Скорость serial-порта в бодах.
///
/// Значение должно быть больше нуля. Типичные значения для VFD: `9600`, `19200`,
/// `38400`, но библиотека не ограничивает список скоростей.
pub baud_rate: u32,
/// Количество бит данных.
pub data_bits: DataBits,
/// Проверка чётности.
pub parity: Parity,
/// Количество stop bits.
pub stop_bits: StopBits,
/// Управление потоком.
pub flow_control: FlowControl,
/// Тайм-аут операций serial-порта.
///
/// Значение передаётся в `serialport`; для worker это время ожидания одной
/// блокирующей операции на устройстве, а не timeout всей очереди команд.
pub timeout: Duration,
/// Эксклюзивное открытие serial-порта на Unix.
///
/// По умолчанию включено, чтобы второй процесс не смог случайно писать в тот же
/// дисплей. Поле доступно только на Unix-платформах.
#[cfg(unix)]
pub exclusive: bool,
}
impl SerialSettings {
/// Создаёт настройки serial-порта с явным baud rate.
///
/// Остальные параметры получают распространённые значения `8N1`, без flow control,
/// timeout `100 ms` и exclusive mode на Unix. Их можно изменить напрямую в полях
/// структуры до создания [`VfdConfig`].
///
/// # Ошибки
///
/// Метод сам не возвращает ошибку. Проверка выполняется в [`SerialSettings::validate`]
/// или при создании [`VfdConfig`].
///
/// # Примеры
///
/// ```
/// use escpos_vfd::SerialSettings;
/// use std::time::Duration;
///
/// let mut serial = SerialSettings::new("/dev/ttyUSB0", 9_600);
/// serial.timeout = Duration::from_millis(250);
/// assert!(serial.validate().is_ok());
/// ```
pub fn new(port_name: impl Into<String>, baud_rate: u32) -> Self {
Self {
port_name: port_name.into(),
baud_rate,
data_bits: DataBits::Eight,
parity: Parity::None,
stop_bits: StopBits::One,
flow_control: FlowControl::None,
timeout: Duration::from_millis(100),
#[cfg(unix)]
exclusive: true,
}
}
/// Проверяет, что настройки можно применить к serial-порту.
///
/// Метод не открывает устройство. Он только ловит ошибки, которые библиотека может
/// определить заранее: пустое имя порта и нулевой baud rate.
///
/// # Ошибки
///
/// Возвращает [`ConfigError::EmptyPortName`] или [`ConfigError::ZeroBaudRate`].
pub fn validate(&self) -> std::result::Result<(), ConfigError> {
if self.port_name.trim().is_empty() {
return Err(ConfigError::EmptyPortName);
}
if self.baud_rate == 0 {
return Err(ConfigError::ZeroBaudRate);
}
Ok(())
}
}
/// Текстовая кодировка, используемая для байтов дисплея.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextEncoding {
/// IBM866 / CP866.
///
/// Частый выбор для русской кириллицы на Epson/АТОЛ-совместимых дисплеях.
Cp866,
/// Windows-1251.
///
/// Используйте, если руководство дисплея явно указывает Windows-1251 или CP1251.
Windows1251,
/// Только ASCII, всё вне ASCII заменяется на `?`.
Ascii,
/// UTF-8 без перекодирования.
///
/// Подходит только устройствам, которые действительно принимают UTF-8 байты.
Utf8,
}
/// Настройки геометрии и ESC/POS-команд дисплея.
///
/// `columns` и `rows` задают координатную сетку, используемую для проверки `print_line`
/// и `print_at`. `code_table` управляет только аппаратной командой `ESC t n`, а
/// `encoding` определяет программное преобразование текста в байты.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisplaySettings {
/// Количество символов в строке.
///
/// Допустимый диапазон: `1..=255`. Значение используется для обрезки, заполнения
/// пробелами и проверки координаты `x`.
pub columns: usize,
/// Количество строк.
///
/// Допустимый диапазон: `1..=255`. Значение используется для проверки `line` и `y`
/// во всех методах печати.
pub rows: usize,
/// Кодировка текстовых байтов.
///
/// Это программное преобразование Rust-строки в байты транспорта. Оно не выбирает
/// аппаратную таблицу дисплея.
pub encoding: TextEncoding,
/// Таблица символов для `ESC t n`; `None` отключает отправку команды.
///
/// Это отдельная аппаратная команда протокола. Для многих дисплеев CP866 работает
/// только когда одновременно выбраны `encoding = TextEncoding::Cp866` и нужное
/// значение `code_table`.
pub code_table: Option<u8>,
/// Отправлять `ESC @` при открытии.
///
/// Сброс полезен для predictable startup, но его можно выключить, если приложение
/// намеренно сохраняет состояние дисплея между открытиями.
pub reset_on_open: bool,
/// Поддерживаемый диапазон яркости для `US X n`.
///
/// `None` означает, что типизированная установка яркости запрещена и
/// [`crate::Vfd::set_brightness`] вернёт [`crate::VfdError::UnsupportedBrightness`].
pub brightness: Option<std::ops::RangeInclusive<u8>>,
/// Задержка после изменения яркости.
///
/// Некоторые дисплеи требуют короткую паузу после `US X n`. Sync API блокирует
/// текущий поток на это время; Tokio API ждёт через async sleep.
pub brightness_settle: Duration,
}
impl DisplaySettings {
/// Создаёт ручные настройки дисплея.
///
/// По умолчанию включён `ESC @` при открытии, яркость `1..=4` и короткая задержка
/// после её изменения. Аппаратная таблица символов не выбирается автоматически:
/// задайте `code_table = Some(n)`, если вашему дисплею нужна команда `ESC t n`.
///
/// # Ошибки
///
/// Метод сам не возвращает ошибку. Проверка выполняется в
/// [`DisplaySettings::validate`] или при создании [`VfdConfig`].
///
/// # Примеры
///
/// ```
/// use escpos_vfd::{DisplaySettings, TextEncoding};
///
/// let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
/// display.code_table = Some(6);
/// assert!(display.validate().is_ok());
/// ```
pub fn new(columns: usize, rows: usize, encoding: TextEncoding) -> Self {
Self {
columns,
rows,
encoding,
code_table: None,
reset_on_open: true,
brightness: Some(1..=4),
brightness_settle: Duration::from_millis(2),
}
}
/// Проверяет геометрию и диапазоны настроек дисплея.
///
/// # Ошибки
///
/// Возвращает [`ConfigError::InvalidColumns`], [`ConfigError::InvalidRows`] или
/// [`ConfigError::InvalidBrightnessRange`].
pub fn validate(&self) -> std::result::Result<(), ConfigError> {
if !(1..=u8::MAX as usize).contains(&self.columns) {
return Err(ConfigError::InvalidColumns(self.columns));
}
if !(1..=u8::MAX as usize).contains(&self.rows) {
return Err(ConfigError::InvalidRows(self.rows));
}
if let Some(range) = &self.brightness {
let min = *range.start();
let max = *range.end();
if min == 0 || min > max {
return Err(ConfigError::InvalidBrightnessRange { min, max });
}
}
Ok(())
}
}
/// Полная конфигурация VFD: serial transport плюс геометрия/протокол дисплея.
///
/// Один и тот же `VfdConfig` используется sync и Tokio API. `queue_capacity` влияет
/// только на worker-обёртки; низкоуровневый [`crate::Vfd`] открывает порт напрямую.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VfdConfig {
/// Настройки serial-порта.
pub serial: SerialSettings,
/// Настройки дисплея.
pub display: DisplaySettings,
/// Ёмкость очереди фонового worker.
///
/// Используется только [`crate::VfdWorker`] и `escpos_vfd::tokio::AsyncVfdWorker`.
/// Значение `32` по умолчанию даёт backpressure без бесконтрольного роста памяти.
pub queue_capacity: usize,
}
impl VfdConfig {
/// Создаёт полностью ручную конфигурацию.
///
/// Метод сразу валидирует serial и display настройки, поэтому ошибки конфигурации
/// возвращаются до попытки открыть устройство.
///
/// # Ошибки
///
/// Возвращает [`ConfigError`], если serial, display или `queue_capacity` содержат
/// недопустимые значения.
pub fn new(
serial: SerialSettings,
display: DisplaySettings,
) -> std::result::Result<Self, ConfigError> {
let cfg = Self {
serial,
display,
queue_capacity: 32,
};
cfg.validate()?;
Ok(cfg)
}
/// Создаёт конфигурацию из пресета.
///
/// `Preset::Epson20x2Cp866` воспроизводит прежние настройки библиотеки: 20x2,
/// 9600 baud, CP866, `ESC @`, `ESC t 6` и яркость `1..=4`.
///
/// # Ошибки
///
/// Возвращает [`ConfigError::EmptyPortName`], если имя порта пустое.
pub fn preset(
port_name: impl Into<String>,
preset: Preset,
) -> std::result::Result<Self, ConfigError> {
match preset {
Preset::Epson20x2Cp866 => {
let serial = SerialSettings::new(port_name, 9600);
let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
display.code_table = Some(6);
display.reset_on_open = true;
display.brightness = Some(1..=4);
display.brightness_settle = Duration::from_millis(2);
Self::new(serial, display)
}
}
}
/// Устанавливает ёмкость bounded-очереди worker.
///
/// Значение `0` запрещено: такая очередь не смогла бы принять даже команду shutdown.
///
/// # Ошибки
///
/// Возвращает [`ConfigError::ZeroQueueCapacity`] при `capacity = 0`.
pub fn with_queue_capacity(
mut self,
capacity: usize,
) -> std::result::Result<Self, ConfigError> {
self.queue_capacity = capacity;
self.validate()?;
Ok(self)
}
/// Проверяет serial и display настройки.
///
/// # Ошибки
///
/// Возвращает первый найденный [`ConfigError`].
pub fn validate(&self) -> std::result::Result<(), ConfigError> {
self.serial.validate()?;
self.display.validate()?;
if self.queue_capacity == 0 {
return Err(ConfigError::ZeroQueueCapacity);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epson_preset_matches_legacy_settings() {
let cfg = VfdConfig::preset("COM1", Preset::Epson20x2Cp866).unwrap();
assert_eq!(cfg.serial.port_name, "COM1");
assert_eq!(cfg.serial.baud_rate, 9600);
assert_eq!(cfg.serial.data_bits, DataBits::Eight);
assert_eq!(cfg.serial.parity, Parity::None);
assert_eq!(cfg.serial.stop_bits, StopBits::One);
assert_eq!(cfg.serial.flow_control, FlowControl::None);
assert_eq!(cfg.serial.timeout, Duration::from_millis(100));
assert_eq!(cfg.display.columns, 20);
assert_eq!(cfg.display.rows, 2);
assert_eq!(cfg.display.encoding, TextEncoding::Cp866);
assert_eq!(cfg.display.code_table, Some(6));
assert!(cfg.display.reset_on_open);
assert_eq!(cfg.display.brightness, Some(1..=4));
}
#[test]
fn manual_config_has_no_hidden_code_table_or_baud() {
let serial = SerialSettings::new("/tmp/tty", 19_200);
let display = DisplaySettings::new(16, 4, TextEncoding::Windows1251);
let cfg = VfdConfig::new(serial, display).unwrap();
assert_eq!(cfg.serial.baud_rate, 19_200);
assert_eq!(cfg.display.columns, 16);
assert_eq!(cfg.display.rows, 4);
assert_eq!(cfg.display.code_table, None);
}
#[test]
fn invalid_config_values_are_rejected() {
assert!(matches!(
SerialSettings::new("", 9600).validate(),
Err(ConfigError::EmptyPortName)
));
assert!(matches!(
SerialSettings::new("p", 0).validate(),
Err(ConfigError::ZeroBaudRate)
));
assert!(matches!(
DisplaySettings::new(0, 2, TextEncoding::Cp866).validate(),
Err(ConfigError::InvalidColumns(0))
));
assert!(matches!(
DisplaySettings::new(20, 256, TextEncoding::Cp866).validate(),
Err(ConfigError::InvalidRows(256))
));
let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
let min = 4;
let max = 1;
display.brightness = Some(min..=max);
assert!(matches!(
display.validate(),
Err(ConfigError::InvalidBrightnessRange { min: 4, max: 1 })
));
let cfg = VfdConfig::preset("COM1", Preset::Epson20x2Cp866).unwrap();
assert!(matches!(
cfg.with_queue_capacity(0),
Err(ConfigError::ZeroQueueCapacity)
));
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Ошибки конфигурации, I/O и жизненного цикла worker.
//!
//! Библиотека возвращает типизированные ошибки вместо `anyhow`, чтобы вызывающий код
//! мог отдельно обработать неверные настройки, недоступный serial-порт, ошибку записи,
//! неправильные координаты и остановленный worker. Текст [`std::fmt::Display`]
//! ориентирован на диагностику, а варианты enum - на машинную обработку.
use std::sync::mpsc;
/// Ошибки проверки конфигурации до открытия serial-порта.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError {
/// Имя serial-порта не задано или состоит только из пробельных символов.
EmptyPortName,
/// Скорость serial-порта равна нулю.
///
/// Значение `baud_rate` задаётся в бодах, например `9600` или `115200`.
ZeroBaudRate,
/// Ширина дисплея не входит в диапазон `1..=255`.
///
/// Поле содержит переданное количество колонок.
InvalidColumns(usize),
/// Высота дисплея не входит в диапазон `1..=255`.
///
/// Поле содержит переданное количество строк.
InvalidRows(usize),
/// Диапазон яркости задан некорректно.
InvalidBrightnessRange {
/// Нижняя граница диапазона яркости.
min: u8,
/// Верхняя граница диапазона яркости.
max: u8,
},
/// Ёмкость очереди worker равна нулю.
///
/// Worker использует bounded queue, поэтому ему нужна ёмкость хотя бы `1`.
ZeroQueueCapacity,
}
/// Ошибки выполнения команд дисплея.
#[derive(Debug)]
pub enum VfdError {
/// Конфигурация не прошла проверку до открытия транспорта.
Config(ConfigError),
/// Ошибка `serialport` при открытии или настройке устройства.
Serial(serialport::Error),
/// Ошибка записи или flush в транспорт.
Io(std::io::Error),
/// Координата находится вне геометрии дисплея.
InvalidCoordinate {
/// Запрошенная колонка в координатах от единицы.
x: u8,
/// Запрошенная строка в координатах от единицы.
y: u8,
/// Настроенное количество колонок дисплея.
columns: usize,
/// Настроенное количество строк дисплея.
rows: usize,
},
/// Строка находится вне геометрии дисплея.
InvalidLine {
/// Запрошенная строка в координатах от единицы.
line: u8,
/// Настроенное количество строк дисплея.
rows: usize,
},
/// Запрошенная яркость не поддерживается конфигурацией.
UnsupportedBrightness {
/// Запрошенный уровень яркости.
level: u8,
/// Минимальный поддерживаемый уровень.
min: u8,
/// Максимальный поддерживаемый уровень.
max: u8,
},
/// Очередь фонового worker закрыта.
QueueClosed,
/// Worker остановлен до подтверждения команды.
WorkerStopped,
/// Фоновый поток завершился с panic.
WorkerPanicked,
/// Async task worker был отменён до завершения shutdown.
#[cfg(feature = "tokio")]
WorkerCancelled,
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyPortName => f.write_str("serial port name must not be empty"),
Self::ZeroBaudRate => f.write_str("baud rate must not be zero"),
Self::InvalidColumns(columns) => {
write!(f, "columns must be in 1..=255, got {columns}")
}
Self::InvalidRows(rows) => write!(f, "rows must be in 1..=255, got {rows}"),
Self::InvalidBrightnessRange { min, max } => {
write!(
f,
"brightness range must be ordered and non-zero, got {min}..={max}"
)
}
Self::ZeroQueueCapacity => f.write_str("queue capacity must not be zero"),
}
}
}
impl std::error::Error for ConfigError {}
impl std::fmt::Display for VfdError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Config(error) => error.fmt(f),
Self::Serial(error) => error.fmt(f),
Self::Io(error) => error.fmt(f),
Self::InvalidCoordinate {
x,
y,
columns,
rows,
} => write!(
f,
"coordinate ({x}, {y}) is outside display geometry {columns}x{rows}"
),
Self::InvalidLine { line, rows } => {
write!(f, "line {line} is outside display rows 1..={rows}")
}
Self::UnsupportedBrightness { level, min, max } => {
write!(
f,
"brightness {level} is outside supported range {min}..={max}"
)
}
Self::QueueClosed => f.write_str("VFD worker queue is closed"),
Self::WorkerStopped => f.write_str("VFD worker stopped before acknowledging command"),
Self::WorkerPanicked => f.write_str("VFD worker thread panicked"),
#[cfg(feature = "tokio")]
Self::WorkerCancelled => f.write_str("VFD async worker task was cancelled"),
}
}
}
impl std::error::Error for VfdError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Config(error) => Some(error),
Self::Serial(error) => Some(error),
Self::Io(error) => Some(error),
_ => None,
}
}
}
impl From<ConfigError> for VfdError {
fn from(value: ConfigError) -> Self {
Self::Config(value)
}
}
impl From<serialport::Error> for VfdError {
fn from(value: serialport::Error) -> Self {
Self::Serial(value)
}
}
impl From<std::io::Error> for VfdError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl<T> From<mpsc::SendError<T>> for VfdError {
fn from(_: mpsc::SendError<T>) -> Self {
Self::QueueClosed
}
}
impl From<mpsc::RecvError> for VfdError {
fn from(_: mpsc::RecvError) -> Self {
Self::WorkerStopped
}
}
/// Результат операций VFD.
pub type Result<T> = std::result::Result<T, VfdError>;
+49 -3
View File
@@ -1,6 +1,52 @@
//! Библиотека управления двухстрочными VFD-дисплеями в Epson-совместимом режиме.
#![warn(missing_docs)]
//! ESC/POS-совместимая библиотека управления VFD-дисплеями покупателя.
//!
//! Библиотека отделяет настройки serial-порта от геометрии и протокола дисплея:
//! используйте готовый [`Preset`] для уже проверенного 20x2 CP866 устройства или
//! соберите [`VfdConfig`] вручную через [`SerialSettings`] и [`DisplaySettings`].
//!
//! Sync API представлен низкоуровневым [`Vfd`] и фоновым [`VfdWorker`].
//! Низкоуровневый драйвер пишет в транспорт сразу в вызывающем потоке, а worker
//! владеет транспортом в отдельном потоке и подтверждает команды после выполнения
//! записи. С feature `tokio` доступен модуль [`tokio`] с настоящим `AsyncWrite`,
//! bounded queue и async-подтверждениями после фактической записи в транспорт.
//!
//! Все публичные координаты задаются от единицы: первая колонка - `x = 1`, первая
//! строка - `line = 1` или `y = 1`. Размеры дисплея валидируются в диапазоне
//! `1..=255`, потому что ESC/POS-команды позиционирования передают координаты одним
//! байтом. Ошибки не скрываются: конфигурация, координаты, яркость, serial I/O и
//! состояние worker возвращаются разными вариантами [`VfdError`].
//!
//! Основной путь для старого 20x2 CP866 дисплея:
//!
//! ```no_run
//! use escpos_vfd::{Preset, Vfd, VfdConfig};
//!
//! # fn main() -> escpos_vfd::Result<()> {
//! let cfg = VfdConfig::preset("/dev/cu.usbmodem101", Preset::Epson20x2Cp866)?;
//! let mut vfd = Vfd::open(cfg)?;
//! vfd.print_line(1, "Привет")?;
//! # Ok(())
//! # }
//! ```
/// Низкоуровневая работа с serial-портом, кодировкой и командами дисплея.
/// Кодирование Epson/ESC/POS-команд.
pub mod codec;
/// Конфигурация serial-порта и дисплея.
pub mod config;
/// Типизированные ошибки библиотеки.
pub mod error;
/// Низкоуровневое соединение с serial-портом или произвольным транспортом.
pub mod vfd;
/// Фоновый поток и потокобезопасный интерфейс для обновления дисплея.
/// Фоновый поток и потокобезопасный sync-интерфейс.
pub mod worker;
/// Tokio API поверх настоящего `AsyncWrite`.
#[cfg(feature = "tokio")]
pub mod tokio;
pub use codec::{fit_to_width, sanitize_for_cp866, sanitize_text};
pub use config::{DisplaySettings, Preset, SerialSettings, TextEncoding, VfdConfig};
pub use error::{ConfigError, Result, VfdError};
pub use vfd::Vfd;
pub use worker::{VfdHandle, VfdWorker};
+975
View File
@@ -0,0 +1,975 @@
//! Tokio API поверх настоящего `AsyncWrite`.
//!
//! Модуль доступен только с feature `tokio`. Он повторяет sync API, но использует
//! `tokio-serial`, `tokio::sync::mpsc`, `oneshot`-подтверждения и async sleep для
//! задержек яркости и бегущей строки. Низкоуровневый `AsyncVfd` работает с любым
//! `AsyncWrite`, поэтому тесты и нестандартные транспорты не требуют настоящего
//! serial-порта.
use crate::codec::{EpsonCodec, changed_runs, fit_to_width, replace_cached_range, sanitize_text};
use crate::config::{DisplaySettings, VfdConfig};
use crate::error::{ConfigError, Result, VfdError};
use ::tokio::io::{AsyncWrite, AsyncWriteExt};
use ::tokio::sync::{mpsc, oneshot};
use ::tokio::task::JoinHandle;
use ::tokio::time::{Duration, Instant, sleep, sleep_until};
use serialport::SerialPortBuilder;
use tokio_serial::{SerialPortBuilderExt, SerialStream};
type Ack = oneshot::Sender<Result<()>>;
enum Cmd {
Clear {
ack: Ack,
},
PrintLine {
line: u8,
text: String,
ack: Ack,
},
PrintLineDiff {
line: u8,
text: String,
ack: Ack,
},
PrintAt {
x: u8,
y: u8,
text: String,
ack: Ack,
},
WriteRaw {
bytes: Vec<u8>,
ack: Ack,
},
SetMarqueeText {
text: String,
ack: Ack,
},
StartMarquee {
line: u8,
cps: u32,
end_pause: Duration,
ack: Ack,
},
StopMarquee {
ack: Ack,
},
SetBrightness {
level: u8,
ack: Ack,
},
Shutdown {
ack: Ack,
},
}
/// Async-драйвер VFD поверх `AsyncWrite`.
///
/// Драйвер выполняет запись напрямую в текущем async task и не использует
/// `spawn_blocking`. Для сериализации команд из разных задач используйте
/// [`AsyncVfdWorker`]. Все координаты задаются от единицы.
pub struct AsyncVfd<T: AsyncWrite + Unpin = SerialStream> {
transport: T,
codec: EpsonCodec,
}
impl AsyncVfd<SerialStream> {
/// Открывает serial-порт через `tokio-serial` и инициализирует дисплей.
///
/// Метод проверяет [`VfdConfig`], открывает serial-порт и отправляет init-команды
/// (`ESC @`, `ESC t n`) согласно [`DisplaySettings`].
///
/// # Ошибки
///
/// Возвращает [`VfdError::Config`] при неверной конфигурации,
/// [`VfdError::Serial`] при ошибке открытия порта и [`VfdError::Io`] при ошибке
/// async-записи init-команд.
pub async fn open(cfg: VfdConfig) -> Result<Self> {
cfg.validate()?;
let serial = cfg.serial;
let display = cfg.display;
let mut builder: SerialPortBuilder = tokio_serial::new(&serial.port_name, serial.baud_rate)
.data_bits(serial.data_bits)
.parity(serial.parity)
.stop_bits(serial.stop_bits)
.flow_control(serial.flow_control)
.timeout(serial.timeout);
#[cfg(unix)]
{
builder = builder.exclusive(serial.exclusive);
}
let port = builder.open_native_async()?;
Self::from_transport(port, display).await
}
}
impl<T: AsyncWrite + Unpin> AsyncVfd<T> {
/// Создаёт async-драйвер поверх произвольного async-транспорта.
///
/// Как и sync-вариант, метод отправляет init-последовательность сразу после проверки
/// [`DisplaySettings`].
///
/// # Ошибки
///
/// Возвращает [`VfdError::Config`] для неверных настроек дисплея или
/// [`VfdError::Io`] при ошибке async-записи.
///
/// # Примеры
///
/// ```
/// # #[cfg(feature = "tokio")]
/// # async fn demo() -> escpos_vfd::Result<()> {
/// use escpos_vfd::{DisplaySettings, TextEncoding};
/// use escpos_vfd::tokio::AsyncVfd;
///
/// let display = DisplaySettings::new(4, 1, TextEncoding::Ascii);
/// let mut vfd = AsyncVfd::from_transport(Vec::<u8>::new(), display).await?;
/// vfd.print_line(1, "OK").await?;
/// # Ok(())
/// # }
/// ```
pub async fn from_transport(mut transport: T, display: DisplaySettings) -> Result<Self> {
display.validate()?;
let codec = EpsonCodec::new(display);
let init = codec.init();
if !init.is_empty() {
transport.write_all(&init).await?;
}
Ok(Self { transport, codec })
}
/// Настройки дисплея.
pub fn display(&self) -> &DisplaySettings {
self.codec.display()
}
/// Количество символов в строке.
pub fn columns(&self) -> usize {
self.display().columns
}
/// Количество строк.
pub fn rows(&self) -> usize {
self.display().rows
}
/// Очищает дисплей.
///
/// # Ошибки
///
/// Возвращает [`VfdError::Io`] при ошибке async-записи.
pub async fn clear(&mut self) -> Result<()> {
self.transport.write_all(&self.codec.clear()).await?;
Ok(())
}
/// Полностью перезаписывает строку.
///
/// Текст санитизируется, обрезается и дополняется пробелами до ширины дисплея.
///
/// # Ошибки
///
/// Возвращает [`VfdError::InvalidLine`] или [`VfdError::Io`].
pub async fn print_line(&mut self, line: u8, text: &str) -> Result<()> {
self.codec.validate_line(line)?;
self.goto_xy(1, line).await?;
let fitted = self.codec.fit_line(text);
self.write_text(&fitted).await
}
/// Выводит подготовленный кадр.
///
/// Метод не выполняет санацию текста. Используйте его для уже подготовленных кадров.
///
/// # Ошибки
///
/// Возвращает [`VfdError::InvalidLine`] или [`VfdError::Io`].
pub async fn print_frame(&mut self, line: u8, frame: &str) -> Result<()> {
self.codec.validate_line(line)?;
self.goto_xy(1, line).await?;
let fitted = fit_to_width(frame, self.columns());
self.write_text(&fitted).await
}
/// Печатает текст с координаты `(x, y)`.
///
/// Координаты задаются от единицы. Текст санитизируется и обрезается по правому краю
/// текущей строки.
///
/// # Ошибки
///
/// Возвращает [`VfdError::InvalidCoordinate`] или [`VfdError::Io`].
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
}
async fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> Result<()> {
self.codec.validate_xy(x, y)?;
let remaining = self.columns() - usize::from(x) + 1;
let text: String = text.chars().take(remaining).collect();
self.goto_xy(x, y).await?;
self.write_text(&text).await
}
/// Записывает байты напрямую.
///
/// Байты не кодируются и не интерпретируются библиотекой. Это escape hatch для
/// нестандартных ESC/POS-команд конкретного дисплея.
pub async fn write_raw(&mut self, bytes: &[u8]) -> Result<()> {
self.transport.write_all(bytes).await?;
Ok(())
}
/// Устанавливает яркость.
///
/// После записи команды выполняется async `flush`, затем async sleep на
/// `display.brightness_settle`, если задержка не нулевая.
///
/// # Ошибки
///
/// Возвращает [`VfdError::UnsupportedBrightness`] или [`VfdError::Io`].
pub async fn set_brightness(&mut self, level: u8) -> Result<()> {
let cmd = self.codec.brightness(level)?;
self.transport.write_all(&cmd).await?;
self.transport.flush().await?;
let settle = self.display().brightness_settle;
if !settle.is_zero() {
sleep(settle).await;
}
Ok(())
}
/// Flush async-транспорта.
///
/// # Ошибки
///
/// Возвращает [`VfdError::Io`], если внутренний транспорт не смог выполнить flush.
pub async fn flush(&mut self) -> Result<()> {
self.transport.flush().await?;
Ok(())
}
/// Возвращает внутренний транспорт.
///
/// Метод потребляет драйвер и возвращает ownership транспорта вызывающему коду.
pub fn into_inner(self) -> T {
self.transport
}
async fn goto_xy(&mut self, x: u8, y: u8) -> Result<()> {
let cmd = self.codec.goto_xy(x, y)?;
self.transport.write_all(&cmd).await?;
Ok(())
}
async fn write_text(&mut self, s: &str) -> Result<()> {
let bytes = self.codec.encode_text(s);
self.transport.write_all(&bytes).await?;
Ok(())
}
}
/// Async handle с bounded queue и подтверждением выполнения I/O.
///
/// После того как команда принята очередью, отмена ожидающего future не отменяет уже
/// поставленную запись в устройство. Ошибка I/O возвращается через `Result`.
/// Клоны handle можно передавать в другие async tasks; backpressure создаёт `.await`
/// на отправке, когда очередь заполнена.
#[derive(Clone)]
pub struct AsyncVfdHandle {
tx: mpsc::Sender<Cmd>,
}
impl AsyncVfdHandle {
/// Очищает дисплей.
///
/// # Ошибки
///
/// Возвращает ошибки очереди, остановленного worker-а или async I/O.
pub async fn clear(&self) -> Result<()> {
self.call(|ack| Cmd::Clear { ack }).await
}
/// Устанавливает яркость.
///
/// Future завершается после записи, flush и задержки `brightness_settle`.
///
/// # Ошибки
///
/// Возвращает [`VfdError::UnsupportedBrightness`], ошибки очереди или I/O.
pub async fn set_brightness(&self, level: u8) -> Result<()> {
self.call(|ack| Cmd::SetBrightness { level, ack }).await
}
/// Полностью перезаписывает строку.
///
/// # Ошибки
///
/// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O.
pub async fn print_line(&self, line: u8, text: impl Into<String>) -> Result<()> {
let text = text.into();
self.call(|ack| Cmd::PrintLine { line, text, ack }).await
}
/// Обновляет только изменившиеся диапазоны строки.
///
/// Worker хранит кэш строк и отправляет только изменившиеся смежные диапазоны. На
/// строке с активной marquee команда подтверждается без записи.
///
/// # Ошибки
///
/// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O.
pub async fn print_line_diff(&self, line: u8, text: impl Into<String>) -> Result<()> {
let text = text.into();
self.call(|ack| Cmd::PrintLineDiff { line, text, ack })
.await
}
/// Печатает текст с координаты `(x, y)`.
///
/// # Ошибки
///
/// Возвращает [`VfdError::InvalidCoordinate`], ошибки очереди или I/O.
pub async fn print_at(&self, x: u8, y: u8, text: impl Into<String>) -> Result<()> {
let text = text.into();
self.call(|ack| Cmd::PrintAt { x, y, text, ack }).await
}
/// Записывает байты напрямую.
///
/// Raw-байты не обновляют строковый кэш worker.
///
/// # Ошибки
///
/// Возвращает ошибки очереди или [`VfdError::Io`].
pub async fn write_raw(&self, bytes: impl Into<Vec<u8>>) -> Result<()> {
let bytes = bytes.into();
self.call(|ack| Cmd::WriteRaw { bytes, ack }).await
}
/// Заменяет текст бегущей строки.
///
/// Если marquee уже активна, поток символов перестраивается сразу. Сам метод не
/// пишет кадр синхронно; запись произойдёт по таймеру worker-а.
///
/// # Ошибки
///
/// Возвращает ошибки очереди или остановленного worker-а.
pub async fn set_marquee_text(&self, text: impl Into<String>) -> Result<()> {
let text = text.into();
self.call(|ack| Cmd::SetMarqueeText { text, ack }).await
}
/// Запускает бегущую строку.
///
/// `cps` - скорость в символах в секунду; `0` приводится к `1`. `end_pause` задаёт
/// async-паузу в конце полного прохода текста.
///
/// # Ошибки
///
/// Возвращает [`VfdError::InvalidLine`] или ошибки очереди.
pub async fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) -> Result<()> {
self.call(|ack| Cmd::StartMarquee {
line,
cps,
end_pause,
ack,
})
.await
}
/// Останавливает бегущую строку.
///
/// Останавливает таймер marquee. Видимый текст на дисплее не очищается автоматически.
///
/// # Ошибки
///
/// Возвращает ошибки очереди или остановленного worker-а.
pub async fn stop_marquee(&self) -> Result<()> {
self.call(|ack| Cmd::StopMarquee { ack }).await
}
/// Завершает worker после ранее принятых команд.
///
/// Чтобы дождаться завершения task и получить внутренний драйвер, используйте
/// [`AsyncVfdWorker::shutdown`].
///
/// # Ошибки
///
/// Возвращает ошибки очереди или остановленного worker-а.
pub async fn shutdown(&self) -> Result<()> {
self.call(|ack| Cmd::Shutdown { ack }).await
}
async fn call(&self, build: impl FnOnce(Ack) -> Cmd) -> Result<()> {
let (ack_tx, ack_rx) = oneshot::channel();
self.tx
.send(build(ack_tx))
.await
.map_err(|_| VfdError::QueueClosed)?;
ack_rx.await.map_err(|_| VfdError::WorkerStopped)?
}
}
/// Владеет async task записи в дисплей.
///
/// `AsyncVfdWorker` запускает одну задачу-писатель и возвращает клоны [`AsyncVfdHandle`]
/// для вызывающего кода. При `Drop` незавершённая задача отменяется без блокировки runtime.
pub struct AsyncVfdWorker<T: AsyncWrite + Unpin + Send + 'static = SerialStream> {
handle: AsyncVfdHandle,
join: Option<JoinHandle<Result<AsyncVfd<T>>>>,
}
impl AsyncVfdWorker<SerialStream> {
/// Открывает serial-порт и запускает async worker.
///
/// # Ошибки
///
/// Возвращает ошибки конфигурации, открытия serial-порта или init-записи из
/// [`AsyncVfd::open`].
pub async fn start(cfg: VfdConfig) -> Result<Self> {
cfg.validate()?;
let capacity = cfg.queue_capacity;
let vfd = AsyncVfd::open(cfg).await?;
Self::from_vfd(vfd, capacity)
}
}
impl<T: AsyncWrite + Unpin + Send + 'static> AsyncVfdWorker<T> {
/// Запускает worker поверх готового async-драйвера.
///
/// Worker сначала очищает дисплей в своей task, затем обрабатывает очередь команд.
///
/// # Ошибки
///
/// Возвращает [`VfdError::Config`], если `queue_capacity = 0`.
pub fn from_vfd(vfd: AsyncVfd<T>, queue_capacity: usize) -> Result<Self> {
if queue_capacity == 0 {
return Err(ConfigError::ZeroQueueCapacity.into());
}
let (tx, rx) = mpsc::channel(queue_capacity);
let handle = AsyncVfdHandle { tx };
let join = ::tokio::spawn(async move { writer_loop(vfd, rx).await });
Ok(Self {
handle,
join: Some(join),
})
}
/// Асинхронно создаёт worker поверх произвольного транспорта.
///
/// Init-команды записываются до запуска worker task.
///
/// # Ошибки
///
/// Возвращает ошибки [`AsyncVfd::from_transport`] или
/// [`VfdError::Config`] при `queue_capacity = 0`.
pub async fn from_transport(
transport: T,
display: DisplaySettings,
queue_capacity: usize,
) -> Result<Self> {
if queue_capacity == 0 {
return Err(ConfigError::ZeroQueueCapacity.into());
}
let vfd = AsyncVfd::from_transport(transport, display).await?;
Self::from_vfd(vfd, queue_capacity)
}
/// Возвращает handle.
///
/// Клон можно передать в другие async tasks. Сам [`AsyncVfdWorker`] должен жить до
/// явного [`AsyncVfdWorker::shutdown`], иначе `Drop` отменит task.
pub fn handle(&self) -> AsyncVfdHandle {
self.handle.clone()
}
/// Graceful shutdown с ожиданием task.
///
/// Метод отправляет shutdown-команду, ждёт её подтверждения и затем ожидает join handle,
/// возвращая внутренний [`AsyncVfd`] вместе с транспортом.
///
/// # Ошибки
///
/// Возвращает ошибку отправки shutdown, последнюю ошибку worker-а или
/// [`VfdError::WorkerCancelled`], если task была отменена до завершения.
pub async fn shutdown(mut self) -> Result<AsyncVfd<T>> {
let shutdown_result = self.handle.shutdown().await;
let worker_result = self
.join
.take()
.expect("join handle exists")
.await
.map_err(|_| VfdError::WorkerCancelled)?;
let vfd = worker_result?;
shutdown_result?;
Ok(vfd)
}
}
impl<T: AsyncWrite + Unpin + Send + 'static> Drop for AsyncVfdWorker<T> {
fn drop(&mut self) {
if let Some(join) = self.join.take() {
join.abort();
}
}
}
#[derive(Debug, Clone)]
struct MarqueeState {
active: bool,
line: u8,
cps: u32,
end_pause: Duration,
text: String,
stream: Vec<char>,
offset: usize,
paused_until: Option<Instant>,
next_step: Option<Instant>,
}
impl MarqueeState {
fn new() -> Self {
Self {
active: false,
line: 1,
cps: 5,
end_pause: Duration::from_millis(1500),
text: String::new(),
stream: Vec::new(),
offset: 0,
paused_until: None,
next_step: None,
}
}
fn rebuild_stream(&mut self, width: usize) {
let text = sanitize_text(&self.text);
self.stream.clear();
self.stream.reserve(width * 2 + text.chars().count());
self.stream.extend(std::iter::repeat_n(' ', width));
self.stream.extend(text.chars());
self.stream.extend(std::iter::repeat_n(' ', width));
self.offset = 0;
self.paused_until = None;
self.next_step = Some(Instant::now() + self.step_interval());
}
fn step_interval(&self) -> Duration {
let cps = u64::from(self.cps.max(1));
Duration::from_nanos((1_000_000_000 / cps).max(1))
}
fn next_deadline(&self) -> Option<Instant> {
if !self.active {
return None;
}
self.paused_until.or(self.next_step)
}
}
async fn writer_loop<T: AsyncWrite + Unpin>(
mut vfd: AsyncVfd<T>,
mut rx: mpsc::Receiver<Cmd>,
) -> Result<AsyncVfd<T>> {
vfd.clear().await?;
let rows = vfd.rows();
let mut marquee = MarqueeState::new();
let mut last_lines = vec![String::new(); rows];
loop {
let event = match marquee.next_deadline() {
Some(deadline) => {
::tokio::select! {
cmd = rx.recv() => match cmd {
Some(cmd) => WorkerEvent::Command(cmd),
None => WorkerEvent::Closed,
},
_ = sleep_until(deadline) => WorkerEvent::Timer,
}
}
None => match rx.recv().await {
Some(cmd) => WorkerEvent::Command(cmd),
None => WorkerEvent::Closed,
},
};
match event {
WorkerEvent::Command(cmd) => {
if handle_command(cmd, &mut vfd, &mut marquee, &mut last_lines).await? {
break;
}
}
WorkerEvent::Timer => {
render_marquee(&mut vfd, &mut marquee, &mut last_lines).await?;
}
WorkerEvent::Closed => break,
}
}
Ok(vfd)
}
enum WorkerEvent {
Command(Cmd),
Timer,
Closed,
}
async fn handle_command<T: AsyncWrite + Unpin>(
cmd: Cmd,
vfd: &mut AsyncVfd<T>,
marquee: &mut MarqueeState,
last_lines: &mut [String],
) -> Result<bool> {
let width = vfd.columns();
let rows = vfd.rows();
match cmd {
Cmd::Clear { ack } => {
let result = vfd.clear().await;
if result.is_ok() {
last_lines.fill(String::new());
}
send_ack(ack, result);
}
Cmd::SetBrightness { level, ack } => send_ack(ack, vfd.set_brightness(level).await),
Cmd::PrintLine { line, text, ack } => {
let result = vfd.print_line(line, &text).await;
if result.is_ok() {
last_lines[(line - 1) as usize] = fit_to_width(&sanitize_text(&text), width);
}
send_ack(ack, result);
}
Cmd::PrintLineDiff { line, text, ack } => {
let result = print_line_diff(vfd, marquee, last_lines, line, &text).await;
send_ack(ack, result);
}
Cmd::PrintAt { x, y, text, ack } => {
let result = print_at_cached(vfd, marquee, last_lines, x, y, &text).await;
send_ack(ack, result);
}
Cmd::WriteRaw { bytes, ack } => send_ack(ack, vfd.write_raw(&bytes).await),
Cmd::SetMarqueeText { text, ack } => {
marquee.text = text;
if marquee.active {
marquee.rebuild_stream(width);
}
send_ack(ack, Ok(()));
}
Cmd::StartMarquee {
line,
cps,
end_pause,
ack,
} => {
let result = if line == 0 || usize::from(line) > rows {
Err(VfdError::InvalidLine { line, rows })
} else {
last_lines[(line - 1) as usize].clear();
marquee.active = true;
marquee.line = line;
marquee.cps = cps.max(1);
marquee.end_pause = end_pause;
marquee.rebuild_stream(width);
Ok(())
};
send_ack(ack, result);
}
Cmd::StopMarquee { ack } => {
if marquee.active && usize::from(marquee.line) <= last_lines.len() {
last_lines[(marquee.line - 1) as usize].clear();
}
marquee.active = false;
marquee.paused_until = None;
marquee.next_step = None;
send_ack(ack, Ok(()));
}
Cmd::Shutdown { ack } => {
send_ack(ack, Ok(()));
return Ok(true);
}
}
Ok(false)
}
async fn print_line_diff<T: AsyncWrite + Unpin>(
vfd: &mut AsyncVfd<T>,
marquee: &MarqueeState,
last_lines: &mut [String],
line: u8,
text: &str,
) -> Result<()> {
if line == 0 || usize::from(line) > vfd.rows() {
return Err(VfdError::InvalidLine {
line,
rows: vfd.rows(),
});
}
if marquee.active && marquee.line == line {
return Ok(());
}
let next = fit_to_width(&sanitize_text(text), vfd.columns());
let idx = (line - 1) as usize;
if last_lines[idx] == next {
return Ok(());
}
if last_lines[idx].is_empty() {
vfd.print_line(line, &next).await?;
last_lines[idx] = next;
return Ok(());
}
for (x, text) in changed_runs(&last_lines[idx], &next) {
vfd.print_at_prepared(x, line, &text).await?;
}
last_lines[idx] = next;
Ok(())
}
async fn print_at_cached<T: AsyncWrite + Unpin>(
vfd: &mut AsyncVfd<T>,
marquee: &MarqueeState,
last_lines: &mut [String],
x: u8,
y: u8,
text: &str,
) -> Result<()> {
if x == 0 || y == 0 || usize::from(x) > vfd.columns() || usize::from(y) > vfd.rows() {
return Err(VfdError::InvalidCoordinate {
x,
y,
columns: vfd.columns(),
rows: vfd.rows(),
});
}
if marquee.active && marquee.line == y {
return Ok(());
}
let remaining = vfd.columns() - usize::from(x) + 1;
let text: String = sanitize_text(text).chars().take(remaining).collect();
vfd.print_at_prepared(x, y, &text).await?;
replace_cached_range(&mut last_lines[(y - 1) as usize], x, &text, vfd.columns());
Ok(())
}
async fn render_marquee<T: AsyncWrite + Unpin>(
vfd: &mut AsyncVfd<T>,
marquee: &mut MarqueeState,
last_lines: &mut [String],
) -> Result<()> {
if !marquee.active {
return Ok(());
}
let now = Instant::now();
if let Some(until) = marquee.paused_until {
if now < until {
return Ok(());
}
marquee.paused_until = None;
marquee.next_step = Some(now + marquee.step_interval());
return Ok(());
}
if marquee.next_step.is_some_and(|deadline| now < deadline) {
return Ok(());
}
let width = vfd.columns();
if marquee.stream.len() < width {
marquee.rebuild_stream(width);
}
let max_off = marquee.stream.len().saturating_sub(width);
let start = marquee.offset.min(max_off);
let end = (start + width).min(marquee.stream.len());
let frame: String = marquee.stream[start..end].iter().collect();
vfd.print_at_prepared(1, marquee.line, &frame).await?;
if usize::from(marquee.line) <= last_lines.len() {
last_lines[(marquee.line - 1) as usize] = frame;
}
if marquee.offset >= max_off {
marquee.offset = 0;
marquee.paused_until = Some(now + marquee.end_pause);
marquee.next_step = None;
} else {
marquee.offset += 1;
marquee.next_step = Some(now + marquee.step_interval());
}
Ok(())
}
fn send_ack(ack: Ack, result: Result<()>) {
let _ = ack.send(result);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{DisplaySettings, TextEncoding};
use std::io;
use std::pin::Pin;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::task::{Context, Poll};
struct AsyncFailsAfterWrites {
writes_left: usize,
failed: Arc<AtomicBool>,
}
impl AsyncWrite for AsyncFailsAfterWrites {
fn poll_write(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
if self.writes_left == 0 {
self.failed.store(true, Ordering::SeqCst);
return Poll::Ready(Err(io::Error::other("forced write failure")));
}
self.writes_left -= 1;
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}
#[tokio::test]
async fn async_driver_writes_same_core_bytes() {
let display = DisplaySettings::new(6, 2, TextEncoding::Ascii);
let mut vfd = AsyncVfd::from_transport(Vec::<u8>::new(), display)
.await
.unwrap();
vfd.print_line(2, "abc").await.unwrap();
vfd.write_raw(&[0xAA]).await.unwrap();
assert_eq!(
vfd.into_inner(),
vec![
0x1B, 0x40, 0x1F, 0x24, 1, 2, b'a', b'b', b'c', b' ', b' ', b' ', 0xAA
]
);
}
#[tokio::test(start_paused = true)]
async fn async_worker_ack_and_marquee_scheduler() {
let display = DisplaySettings::new(5, 2, TextEncoding::Ascii);
let worker = AsyncVfdWorker::from_transport(Vec::<u8>::new(), display, 2)
.await
.unwrap();
let handle = worker.handle();
handle.set_marquee_text("abc").await.unwrap();
handle
.start_marquee(2, 10, Duration::from_millis(100))
.await
.unwrap();
::tokio::time::advance(Duration::from_millis(100)).await;
::tokio::task::yield_now().await;
handle.stop_marquee().await.unwrap();
let vfd = worker.shutdown().await.unwrap();
assert!(
vfd.into_inner()
.windows(4)
.any(|window| window == [0x1F, 0x24, 1, 2])
);
}
#[tokio::test]
async fn async_worker_rejects_zero_queue_capacity() {
let display = DisplaySettings::new(5, 2, TextEncoding::Ascii);
assert!(matches!(
AsyncVfdWorker::from_transport(Vec::<u8>::new(), display, 0).await,
Err(VfdError::Config(ConfigError::ZeroQueueCapacity))
));
}
#[tokio::test]
async fn async_worker_print_at_validates_coordinates_before_marquee_skip() {
let display = DisplaySettings::new(5, 2, TextEncoding::Ascii);
let worker = AsyncVfdWorker::from_transport(Vec::<u8>::new(), display, 2)
.await
.unwrap();
let handle = worker.handle();
handle
.start_marquee(2, 10, Duration::from_millis(100))
.await
.unwrap();
assert!(matches!(
handle.print_at(0, 2, "bad").await,
Err(VfdError::InvalidCoordinate { x: 0, y: 2, .. })
));
assert!(matches!(
handle.print_at(6, 2, "bad").await,
Err(VfdError::InvalidCoordinate { x: 6, y: 2, .. })
));
handle.print_at(1, 2, "skipped").await.unwrap();
worker.shutdown().await.unwrap();
}
#[tokio::test]
async fn async_worker_shutdown_prefers_startup_io_error_over_closed_queue() {
let display = DisplaySettings::new(5, 2, TextEncoding::Ascii);
let failed = Arc::new(AtomicBool::new(false));
let transport = AsyncFailsAfterWrites {
writes_left: 1,
failed: Arc::clone(&failed),
};
let worker = AsyncVfdWorker::from_transport(transport, display, 2)
.await
.unwrap();
while !failed.load(Ordering::SeqCst) {
::tokio::task::yield_now().await;
}
assert!(matches!(worker.shutdown().await, Err(VfdError::Io(_))));
}
#[tokio::test(start_paused = true)]
async fn async_worker_exits_when_channel_closes_with_active_marquee() {
let display = DisplaySettings::new(5, 2, TextEncoding::Ascii);
let worker = AsyncVfdWorker::from_transport(Vec::<u8>::new(), display, 2)
.await
.unwrap();
let handle = worker.handle();
handle.set_marquee_text("abc").await.unwrap();
handle
.start_marquee(2, 10, Duration::from_millis(100))
.await
.unwrap();
drop(handle);
let mut worker = worker;
let join = worker.join.take().expect("join handle exists");
drop(worker);
let vfd = ::tokio::time::timeout(Duration::from_millis(1), async {
join.await.map_err(|_| VfdError::WorkerCancelled)?
})
.await
.expect("closed worker channel should terminate task")
.unwrap();
assert_eq!(vfd.rows(), 2);
}
}
+264 -154
View File
@@ -1,203 +1,313 @@
use anyhow::Result;
use encoding_rs::IBM866;
//! Синхронный низкоуровневый драйвер поверх любого [`std::io::Write`].
//!
//! `Vfd` полезен, когда приложение само управляет потоками и хочет немедленно выполнять
//! команды на конкретном транспорте. Каждый метод выполняет запись до возврата из
//! функции. Для фоновой сериализации команд из разных частей приложения используйте
//! [`crate::VfdWorker`] или `escpos_vfd::tokio::AsyncVfdWorker`.
use crate::codec::{EpsonCodec, fit_to_width, sanitize_text, truncate_chars};
use crate::config::{DisplaySettings, VfdConfig};
use crate::error::{Result, VfdError};
use serialport::SerialPort;
use std::io::Write;
use std::time::Duration;
const TABLE_CYR: u8 = 6; // PD-2600: your Cyrillic table (ESC t 6)
const FIXED_BAUD: u32 = 9600;
const MAX_WIDTH: usize = u8::MAX as usize;
#[derive(Debug, Clone)]
/// Настройки serial-подключения и геометрии VFD-дисплея.
pub struct VfdConfig {
/// Имя serial-порта, например `/dev/cu.usbmodem101`.
pub port_name: String,
/// Число символов в строке; ограничивается диапазоном `1..=255`.
pub width: usize,
/// Тайм-аут операций чтения и записи serial-порта.
pub timeout: Duration,
/// Низкоуровневое соединение с дисплеем, владеющее транспортом записи.
///
/// Тип транспорта параметризован, поэтому в тестах можно использовать `Vec<u8>`, а в
/// приложении - serial-порт. Все координаты в публичных методах задаются от единицы.
/// Тип не синхронизирует доступ между потоками; если один дисплей используют несколько
/// producer-ов, берите [`crate::VfdWorker`].
pub struct Vfd<T: Write = Box<dyn SerialPort>> {
transport: T,
codec: EpsonCodec,
}
impl VfdConfig {
/// Создаёт конфигурацию с шириной 20 символов и тайм-аутом 100 мс.
pub fn new(port_name: impl Into<String>) -> Self {
Self {
port_name: port_name.into(),
width: 20,
timeout: Duration::from_millis(100),
}
}
/// Устанавливает ширину строки с учётом диапазона координат протокола.
pub fn with_width(mut self, width: usize) -> Self {
self.width = width.clamp(1, MAX_WIDTH);
self
}
/// Устанавливает тайм-аут операций serial-порта.
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
/// Низкоуровневое соединение с дисплеем, владеющее serial-портом.
pub struct Vfd {
port: Box<dyn SerialPort>,
pub width: usize,
}
impl Vfd {
/// Открывает serial-порт и инициализирует дисплей с таблицей CP866.
impl Vfd<Box<dyn SerialPort>> {
/// Открывает serial-порт и инициализирует дисплей.
///
/// Перед открытием выполняется полная проверка [`VfdConfig`]. После успешного
/// открытия драйвер отправляет команды инициализации из [`DisplaySettings`]:
/// опциональный `ESC @` и опциональный `ESC t n`.
///
/// # Блокировка
///
/// Метод блокирует текущий поток на открытии serial-порта и записи init-команд.
///
/// # Ошибки
///
/// Возвращает [`VfdError::Config`] при неверной конфигурации,
/// [`VfdError::Serial`] при ошибке открытия порта и [`VfdError::Io`] при ошибке
/// записи init-последовательности.
pub fn open(cfg: VfdConfig) -> Result<Self> {
let mut port = serialport::new(cfg.port_name, FIXED_BAUD)
.timeout(cfg.timeout)
.open()?;
cfg.validate()?;
let serial = cfg.serial;
let display = cfg.display;
// ESC @
port.write_all(&[0x1B, 0x40])?;
// ESC t 6
port.write_all(&[0x1B, 0x74, TABLE_CYR])?;
Ok(Self {
port,
width: cfg.width,
})
}
/// Очищает обе строки дисплея стандартной командой очистки.
pub fn clear(&mut self) -> std::io::Result<()> {
self.port.write_all(&[0x0C])
}
/// Полностью перезаписывает первую или вторую строку текстом фиксированной ширины.
pub fn print_line(&mut self, line: u8, text: &str) -> std::io::Result<()> {
if !(1..=2).contains(&line) {
return Ok(());
let mut builder = serialport::new(&serial.port_name, serial.baud_rate)
.data_bits(serial.data_bits)
.parity(serial.parity)
.stop_bits(serial.stop_bits)
.flow_control(serial.flow_control)
.timeout(serial.timeout);
#[cfg(unix)]
{
builder = builder.exclusive(serial.exclusive);
}
// Кадр фиксированной ширины сам перезаписывает остаток предыдущей строки.
let port = builder.open()?;
Self::from_transport(port, display)
}
}
impl<T: Write> Vfd<T> {
/// Создаёт драйвер поверх произвольного транспорта.
///
/// Метод сразу записывает init-последовательность в переданный транспорт. Это удобно
/// для mock transport в тестах и для нестандартных serial-обёрток.
///
/// # Ошибки
///
/// Возвращает [`VfdError::Config`], если настройки дисплея неверны, или
/// [`VfdError::Io`], если транспорт не принял init-байты.
///
/// # Примеры
///
/// ```
/// use escpos_vfd::{DisplaySettings, TextEncoding, Vfd};
///
/// let display = DisplaySettings::new(4, 1, TextEncoding::Ascii);
/// let mut vfd = Vfd::from_transport(Vec::<u8>::new(), display)?;
/// vfd.print_line(1, "OK")?;
/// let bytes = vfd.into_inner();
/// assert!(bytes.starts_with(&[0x1B, 0x40]));
/// # Ok::<(), escpos_vfd::VfdError>(())
/// ```
pub fn from_transport(mut transport: T, display: DisplaySettings) -> Result<Self> {
display.validate()?;
let codec = EpsonCodec::new(display);
let init = codec.init();
if !init.is_empty() {
transport.write_all(&init)?;
}
Ok(Self { transport, codec })
}
/// Возвращает геометрию и настройки дисплея.
pub fn display(&self) -> &DisplaySettings {
self.codec.display()
}
/// Количество символов в строке.
pub fn columns(&self) -> usize {
self.display().columns
}
/// Количество строк дисплея.
pub fn rows(&self) -> usize {
self.display().rows
}
/// Очищает дисплей стандартной командой очистки.
///
/// Метод отправляет байт `0x0C`. Он не обновляет внешние кэши приложения и не
/// вызывает `flush`; при необходимости вызовите [`Vfd::flush`] явно.
///
/// # Ошибки
///
/// Возвращает [`VfdError::Io`] при ошибке записи.
pub fn clear(&mut self) -> Result<()> {
self.transport.write_all(&self.codec.clear())?;
Ok(())
}
/// Полностью перезаписывает строку текстом фиксированной ширины.
///
/// Текст санитизируется, обрезается по числу символов и дополняется пробелами до
/// ширины дисплея, чтобы удалить остаток предыдущего содержимого строки.
///
/// # Ошибки
///
/// Возвращает [`VfdError::InvalidLine`], если `line` вне диапазона `1..=rows`, или
/// [`VfdError::Io`] при ошибке записи.
pub fn print_line(&mut self, line: u8, text: &str) -> Result<()> {
self.codec.validate_line(line)?;
self.goto_xy(1, line)?;
let fitted = fit_to_width(&sanitize_for_cp866(text), self.width);
self.write_cp866(&fitted)
let fitted = self.codec.fit_line(text);
self.write_text(&fitted)
}
/// Выводит подготовленный кадр с начала строки без предварительной очистки.
pub fn print_frame(&mut self, line: u8, frame: &str) -> std::io::Result<()> {
if !(1..=2).contains(&line) {
// Некорректный номер строки игнорируется так же, как в остальных методах печати.
return Ok(());
}
///
/// В отличие от [`Vfd::print_line`], метод не выполняет санацию текста. Используйте
/// его для заранее подготовленных кадров бегущей строки или тестовых байтовых
/// сценариев, когда содержимое уже нормализовано вызывающим кодом.
///
/// # Ошибки
///
/// Возвращает [`VfdError::InvalidLine`] или [`VfdError::Io`].
pub fn print_frame(&mut self, line: u8, frame: &str) -> Result<()> {
self.codec.validate_line(line)?;
self.goto_xy(1, line)?;
// На случай короткого или длинного кадра приводим его к ширине дисплея.
let fitted = fit_to_width(frame, self.width);
self.write_cp866(&fitted)
let fitted = fit_to_width(frame, self.columns());
self.write_text(&fitted)
}
/// Печатает текст с координаты `(x, y)`, выполняя санацию и обрезку по правому краю.
pub fn print_at(&mut self, x: u8, y: u8, text: &str) -> std::io::Result<()> {
let text = sanitize_for_cp866(text);
///
/// Координаты задаются от единицы. Если текст длиннее оставшегося места в строке,
/// лишние символы отбрасываются, а следующая строка не затрагивается.
///
/// # Ошибки
///
/// Возвращает [`VfdError::InvalidCoordinate`] или [`VfdError::Io`].
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)
}
/// Печатает уже подготовленный текст без повторной санации.
pub(crate) fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> std::io::Result<()> {
if x == 0 || usize::from(x) > self.width || !(1..=2).contains(&y) {
return Ok(());
}
let remaining = self.width - usize::from(x) + 1;
let text = truncate_chars(text, remaining);
pub(crate) fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> Result<()> {
self.codec.validate_xy(x, y)?;
let remaining = self.columns() - usize::from(x) + 1;
let text = truncate_chars(text, remaining).to_string();
self.goto_xy(x, y)?;
self.write_cp866(text)
self.write_text(&text)
}
/// Перемещает аппаратный курсор в координаты дисплея, заданные от единицы.
fn goto_xy(&mut self, x: u8, y: u8) -> std::io::Result<()> {
// US $ x y
self.port.write_all(&[0x1F, 0x24, x, y])
/// Записывает байты напрямую без кодировки и проверки содержимого.
///
/// Используйте этот escape hatch только для команд, которых нет в типизированном API,
/// или для нестандартных таблиц символов конкретного устройства.
///
/// # Ошибки
///
/// Возвращает [`VfdError::Io`] при ошибке записи.
pub fn write_raw(&mut self, bytes: &[u8]) -> Result<()> {
self.transport.write_all(bytes)?;
Ok(())
}
/// Кодирует строку в CP866 и целиком записывает байты в serial-порт.
fn write_cp866(&mut self, s: &str) -> std::io::Result<()> {
let (bytes, _, _) = IBM866.encode(s);
self.port.write_all(&bytes)
/// Устанавливает яркость.
///
/// Команда кодируется как `US X n` (`0x1F 0x58 level`). После записи выполняется
/// `flush`, затем sync sleep на `display.brightness_settle`, если задержка не нулевая.
///
/// # Ошибки
///
/// Возвращает [`VfdError::UnsupportedBrightness`], если уровень вне настроенного
/// диапазона или яркость отключена, и [`VfdError::Io`] при ошибке записи/flush.
pub fn set_brightness(&mut self, level: u8) -> Result<()> {
let cmd = self.codec.brightness(level)?;
self.transport.write_all(&cmd)?;
self.transport.flush()?;
let settle = self.display().brightness_settle;
if !settle.is_zero() {
std::thread::sleep(settle);
}
Ok(())
}
/// Устанавливает яркость; значение ограничивается диапазоном `1..=4`.
pub fn set_brightness(&mut self, n: u8) -> Result<()> {
let n = n.clamp(1, 4);
let cmd = [0x1F, 0x58, n]; // US 'X' n
self.port.write_all(&cmd)?;
self.port.flush()?;
std::thread::sleep(std::time::Duration::from_millis(2));
/// Завершает буферизированные записи транспорта.
///
/// # Ошибки
///
/// Возвращает [`VfdError::Io`], если внутренний транспорт не смог выполнить flush.
pub fn flush(&mut self) -> Result<()> {
self.transport.flush()?;
Ok(())
}
/// Возвращает внутренний транспорт.
///
/// Метод потребляет драйвер. Это удобно в тестах, где внутренним транспортом служит
/// `Vec<u8>`, или при передаче ownership обратно вызывающему коду после shutdown.
pub fn into_inner(self) -> T {
self.transport
}
fn goto_xy(&mut self, x: u8, y: u8) -> Result<()> {
let cmd = self.codec.goto_xy(x, y)?;
self.transport.write_all(&cmd)?;
Ok(())
}
fn write_text(&mut self, s: &str) -> Result<()> {
let bytes = self.codec.encode_text(s);
self.transport.write_all(&bytes)?;
Ok(())
}
}
/// Возвращает срез не длиннее заданного числа символов, не разрывая UTF-8.
fn truncate_chars(s: &str, max_chars: usize) -> &str {
s.char_indices()
.nth(max_chars)
.map_or(s, |(byte_index, _)| &s[..byte_index])
}
/// Заменяет типографские символы на безопасные аналоги, представимые в CP866.
pub fn sanitize_for_cp866(s: &str) -> String {
s.chars()
.map(|c| match c {
'…' => '.', //
'—' | '' => '-', //
'№' => '#', //
'\t' => ' ',
'“' | '”' => '"',
'' | '' => '\'',
_ => c,
})
.collect()
}
/// Обрезает строку по числу символов и дополняет пробелами до заданной ширины.
pub fn fit_to_width(s: &str, width: usize) -> String {
let mut out = String::with_capacity(width);
let mut len = 0;
for ch in s.chars().take(width) {
out.push(ch);
len += 1;
impl From<std::convert::Infallible> for VfdError {
fn from(value: std::convert::Infallible) -> Self {
match value {}
}
out.extend(std::iter::repeat_n(' ', width - len));
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{DisplaySettings, Preset, TextEncoding, VfdConfig};
#[test]
fn config_width_stays_within_protocol_coordinate_range() {
assert_eq!(VfdConfig::new("test").with_width(0).width, 1);
assert_eq!(VfdConfig::new("test").with_width(20).width, 20);
assert_eq!(VfdConfig::new("test").with_width(usize::MAX).width, 255);
fn mock_transport_gets_legacy_preset_bytes() {
let cfg = VfdConfig::preset("test", Preset::Epson20x2Cp866).unwrap();
let mut vfd = Vfd::from_transport(Vec::<u8>::new(), cfg.display).unwrap();
vfd.clear().unwrap();
vfd.print_line(1, "Привет").unwrap();
vfd.print_at(20, 2, "X!").unwrap();
vfd.set_brightness(4).unwrap();
let bytes = vfd.into_inner();
let mut expected = vec![0x1B, 0x40, 0x1B, 0x74, 6, 0x0C];
expected.extend_from_slice(&[0x1F, 0x24, 1, 1]);
expected.extend_from_slice(&encoding_rs::IBM866.encode("Привет ").0);
expected.extend_from_slice(&[0x1F, 0x24, 20, 2, b'X']);
expected.extend_from_slice(&[0x1F, 0x58, 4]);
assert_eq!(bytes, expected);
}
#[test]
fn sanitizes_typographic_characters_without_changing_regular_text() {
assert_eq!(sanitize_for_cp866("№1\t“тест”—‘да’…"), "#1 \"тест\"-'да'.");
assert_eq!(sanitize_for_cp866("обычный text"), "обычный text");
fn raw_write_bypasses_encoding() {
let display = DisplaySettings::new(20, 2, TextEncoding::Ascii);
let mut vfd = Vfd::from_transport(Vec::<u8>::new(), display).unwrap();
vfd.write_raw(&[0x1B, b'?', 0xFF]).unwrap();
assert_eq!(vfd.into_inner(), vec![0x1B, 0x40, 0x1B, b'?', 0xFF]);
}
#[test]
fn fits_unicode_by_characters_and_pads_short_input() {
assert_eq!(fit_to_width("Привет", 4), "Прив");
assert_eq!(fit_to_width("да", 4), "да ");
assert_eq!(fit_to_width("text", 0), "");
fn invalid_coordinates_and_brightness_are_errors() {
let display = DisplaySettings::new(20, 3, TextEncoding::Cp866);
let mut vfd = Vfd::from_transport(Vec::<u8>::new(), display).unwrap();
assert!(matches!(
vfd.print_line(4, "bad"),
Err(VfdError::InvalidLine { .. })
));
assert!(matches!(
vfd.print_at(21, 1, "bad"),
Err(VfdError::InvalidCoordinate { .. })
));
assert!(matches!(
vfd.set_brightness(5),
Err(VfdError::UnsupportedBrightness { .. })
));
}
#[test]
fn truncates_without_splitting_utf8_characters() {
assert_eq!(truncate_chars("ёжик", 3), "ёжи");
assert_eq!(truncate_chars("ёжик", 10), "ёжик");
assert_eq!(truncate_chars("ёжик", 0), "");
fn alternate_encodings_are_selectable_manually() {
let display = DisplaySettings::new(4, 1, TextEncoding::Windows1251);
let mut vfd = Vfd::from_transport(Vec::<u8>::new(), display).unwrap();
vfd.print_line(1, "т").unwrap();
assert_eq!(
vfd.into_inner(),
vec![0x1B, 0x40, 0x1F, 0x24, 1, 1, 0xF2, b' ', b' ', b' ']
);
}
}
+560 -309
View File
File diff suppressed because it is too large Load Diff