escpos-vfd 0.3.0

ESC/POS-compatible VFD customer display driver with sync and optional Tokio APIs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! Типизированная конфигурация 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;

/// Максимальное число символов в тексте бегущей строки worker-а.
pub const MAX_MARQUEE_CHARS: usize = 4_096;

/// Максимальный размер одной raw-команды, помещаемой в очередь worker-а.
pub const MAX_QUEUED_RAW_BYTES: usize = 64 * 1024;

/// Максимальная скорость бегущей строки в кадрах/символах в секунду.
pub const MAX_MARQUEE_CPS: u32 = 100;

/// Преднастроенные профили известных 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)
        ));
    }
}