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
+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' ']
);
}
}