//! Синхронный низкоуровневый драйвер поверх любого [`std::io::Write`]. //! //! `Vfd` полезен, когда приложение само управляет потоками и хочет немедленно выполнять //! команды на конкретном транспорте. Каждый метод выполняет запись до возврата из //! функции. Для фоновой сериализации команд из разных частей приложения используйте //! [`crate::VfdWorker`] или `escpos_vfd::tokio::AsyncVfdWorker`. use crate::codec::{EpsonCodec, fit_to_width, truncate_chars}; use crate::config::{DisplaySettings, VfdConfig}; use crate::error::{Result, VfdError}; use serialport::SerialPort; use std::io::Write; /// Низкоуровневое соединение с дисплеем, владеющее транспортом записи. /// /// Тип транспорта параметризован, поэтому в тестах можно использовать `Vec`, а в /// приложении - serial-порт. Все координаты в публичных методах задаются от единицы. /// Тип не синхронизирует доступ между потоками; если один дисплей используют несколько /// producer-ов, берите [`crate::VfdWorker`]. pub struct Vfd> { transport: T, codec: EpsonCodec, } impl Vfd> { /// Открывает serial-порт и инициализирует дисплей. /// /// Перед открытием выполняется полная проверка [`VfdConfig`]. После успешного /// открытия драйвер отправляет команды инициализации из [`DisplaySettings`]: /// опциональный `ESC @` и опциональный `ESC t n`. /// /// # Блокировка /// /// Метод блокирует текущий поток на открытии serial-порта и записи init-команд. /// /// # Ошибки /// /// Возвращает [`VfdError::Config`] при неверной конфигурации, /// [`VfdError::Serial`] при ошибке открытия порта и [`VfdError::Io`] при ошибке /// записи init-последовательности. pub fn open(cfg: VfdConfig) -> Result { cfg.validate()?; let serial = cfg.serial; let display = cfg.display; 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 Vfd { /// Создаёт драйвер поверх произвольного транспорта. /// /// Метод сразу записывает 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::::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 { 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 = self.codec.fit_line(text); self.write_text(&fitted) } /// Выводит подготовленный кадр с начала строки без предварительной очистки. /// /// В отличие от [`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.columns()); self.write_text(&fitted) } /// Печатает текст с координаты `(x, y)`, выполняя санацию и обрезку по правому краю. /// /// Координаты задаются от единицы. Если текст длиннее оставшегося места в строке, /// лишние символы отбрасываются, а следующая строка не затрагивается. /// /// # Ошибки /// /// Возвращает [`VfdError::InvalidCoordinate`] или [`VfdError::Io`]. pub fn print_at(&mut self, x: u8, y: u8, text: &str) -> Result<()> { self.print_at_prepared(x, y, text) } /// Печатает уже подготовленный текст; управляющие символы нейтрализуются codec-ом. 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_text(&text) } /// Записывает байты напрямую без кодировки и проверки содержимого. /// /// Используйте этот escape hatch только для команд, которых нет в типизированном API, /// или для нестандартных таблиц символов конкретного устройства. /// /// # Ошибки /// /// Возвращает [`VfdError::Io`] при ошибке записи. pub fn write_raw(&mut self, bytes: &[u8]) -> Result<()> { self.transport.write_all(bytes)?; Ok(()) } /// Устанавливает яркость. /// /// Команда кодируется как `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(()) } /// Завершает буферизированные записи транспорта. /// /// # Ошибки /// /// Возвращает [`VfdError::Io`], если внутренний транспорт не смог выполнить flush. pub fn flush(&mut self) -> Result<()> { self.transport.flush()?; Ok(()) } /// Возвращает внутренний транспорт. /// /// Метод потребляет драйвер. Это удобно в тестах, где внутренним транспортом служит /// `Vec`, или при передаче 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(()) } } impl From for VfdError { fn from(value: std::convert::Infallible) -> Self { match value {} } } #[cfg(test)] mod tests { use super::*; use crate::config::{DisplaySettings, Preset, TextEncoding, VfdConfig}; #[test] fn mock_transport_gets_legacy_preset_bytes() { let cfg = VfdConfig::preset("test", Preset::Epson20x2Cp866).unwrap(); let mut vfd = Vfd::from_transport(Vec::::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 raw_write_bypasses_encoding() { let display = DisplaySettings::new(20, 2, TextEncoding::Ascii); let mut vfd = Vfd::from_transport(Vec::::new(), display).unwrap(); vfd.write_raw(&[0x1B, b'?', 0xFF]).unwrap(); assert_eq!(vfd.into_inner(), vec![0x1B, 0x40, 0x1B, b'?', 0xFF]); } #[test] fn invalid_coordinates_and_brightness_are_errors() { let display = DisplaySettings::new(20, 3, TextEncoding::Cp866); let mut vfd = Vfd::from_transport(Vec::::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 alternate_encodings_are_selectable_manually() { let display = DisplaySettings::new(4, 1, TextEncoding::Windows1251); let mut vfd = Vfd::from_transport(Vec::::new(), display).unwrap(); vfd.print_line(1, "т").unwrap(); assert_eq!( vfd.into_inner(), vec![0x1B, 0x40, 0x1F, 0x24, 1, 1, 0xF2, b' ', b' ', b' '] ); } #[test] fn high_level_text_neutralizes_protocol_controls() { let display = DisplaySettings::new(8, 1, TextEncoding::Ascii); let mut vfd = Vfd::from_transport(Vec::::new(), display).unwrap(); vfd.print_line(1, "A\u{1b}@B\u{0c}C").unwrap(); assert_eq!(&vfd.into_inner()[6..], b"A @B C "); } }