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:
+343
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user