escpos-vfd 0.3.0

ESC/POS-compatible VFD customer display driver with sync and optional Tokio APIs
Documentation
//! Синхронный низкоуровневый драйвер поверх любого [`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<u8>`, а в
/// приложении - serial-порт. Все координаты в публичных методах задаются от единицы.
/// Тип не синхронизирует доступ между потоками; если один дисплей используют несколько
/// producer-ов, берите [`crate::VfdWorker`].
pub struct Vfd<T: Write = Box<dyn SerialPort>> {
    transport: T,
    codec: EpsonCodec,
}

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> {
        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<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 = 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<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(())
    }
}

impl From<std::convert::Infallible> 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::<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 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 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 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' ']
        );
    }

    #[test]
    fn high_level_text_neutralizes_protocol_controls() {
        let display = DisplaySettings::new(8, 1, TextEncoding::Ascii);
        let mut vfd = Vfd::from_transport(Vec::<u8>::new(), display).unwrap();

        vfd.print_line(1, "A\u{1b}@B\u{0c}C").unwrap();

        assert_eq!(&vfd.into_inner()[6..], b"A @B C  ");
    }
}