Skip to main content

escpos_vfd/
config.rs

1//! Типизированная конфигурация serial-транспорта и параметров дисплея.
2//!
3//! Вручную созданный [`VfdConfig`] не содержит скрытых значений baud rate, кодовой
4//! таблицы или размера экрана. Единственный путь с заранее выбранными настройками -
5//! [`VfdConfig::preset`].
6//!
7//! Главная идея конфигурации: `SerialSettings` описывает только способ подключиться к
8//! порту, а `DisplaySettings` описывает геометрию и ESC/POS-поведение устройства. Это
9//! позволяет использовать один и тот же дисплей на разных портах или один serial-режим
10//! с разными моделями дисплеев без глобальных констант.
11
12use crate::error::ConfigError;
13use serialport::{DataBits, FlowControl, Parity, StopBits};
14use std::time::Duration;
15
16/// Максимальное число символов в тексте бегущей строки worker-а.
17pub const MAX_MARQUEE_CHARS: usize = 4_096;
18
19/// Максимальный размер одной raw-команды, помещаемой в очередь worker-а.
20pub const MAX_QUEUED_RAW_BYTES: usize = 64 * 1024;
21
22/// Максимальная скорость бегущей строки в кадрах/символах в секунду.
23pub const MAX_MARQUEE_CPS: u32 = 100;
24
25/// Преднастроенные профили известных ESC/POS-совместимых дисплеев.
26///
27/// Пресеты нужны для сохранения проверенных наборов настроек, но не ограничивают ручную
28/// конфигурацию. Если устройство отличается хотя бы одним параметром, используйте
29/// [`SerialSettings`], [`DisplaySettings`] и [`VfdConfig::new`].
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Preset {
32    /// Старое поведение этой библиотеки: 20x2, 9600 8N1, CP866, `ESC t 6`.
33    ///
34    /// Пресет подходит для Epson/ESC/POS-совместимых VFD, которые ожидают кириллицу в
35    /// CP866 и выбирают нужную аппаратную таблицу командой `ESC t 6`.
36    Epson20x2Cp866,
37}
38
39/// Настройки serial-подключения.
40///
41/// Все поля публичные, чтобы приложение могло точно выставить режим конкретного
42/// устройства. Значения проверяются перед открытием порта через [`SerialSettings::validate`].
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct SerialSettings {
45    /// Имя serial-порта, например `/dev/cu.usbmodem101`, `/dev/ttyUSB0` или `COM3`.
46    pub port_name: String,
47    /// Скорость serial-порта в бодах.
48    ///
49    /// Значение должно быть больше нуля. Типичные значения для VFD: `9600`, `19200`,
50    /// `38400`, но библиотека не ограничивает список скоростей.
51    pub baud_rate: u32,
52    /// Количество бит данных.
53    pub data_bits: DataBits,
54    /// Проверка чётности.
55    pub parity: Parity,
56    /// Количество stop bits.
57    pub stop_bits: StopBits,
58    /// Управление потоком.
59    pub flow_control: FlowControl,
60    /// Тайм-аут операций serial-порта.
61    ///
62    /// Значение передаётся в `serialport`; для worker это время ожидания одной
63    /// блокирующей операции на устройстве, а не timeout всей очереди команд.
64    pub timeout: Duration,
65    /// Эксклюзивное открытие serial-порта на Unix.
66    ///
67    /// По умолчанию включено, чтобы второй процесс не смог случайно писать в тот же
68    /// дисплей. Поле доступно только на Unix-платформах.
69    #[cfg(unix)]
70    pub exclusive: bool,
71}
72
73impl SerialSettings {
74    /// Создаёт настройки serial-порта с явным baud rate.
75    ///
76    /// Остальные параметры получают распространённые значения `8N1`, без flow control,
77    /// timeout `100 ms` и exclusive mode на Unix. Их можно изменить напрямую в полях
78    /// структуры до создания [`VfdConfig`].
79    ///
80    /// # Ошибки
81    ///
82    /// Метод сам не возвращает ошибку. Проверка выполняется в [`SerialSettings::validate`]
83    /// или при создании [`VfdConfig`].
84    ///
85    /// # Примеры
86    ///
87    /// ```
88    /// use escpos_vfd::SerialSettings;
89    /// use std::time::Duration;
90    ///
91    /// let mut serial = SerialSettings::new("/dev/ttyUSB0", 9_600);
92    /// serial.timeout = Duration::from_millis(250);
93    /// assert!(serial.validate().is_ok());
94    /// ```
95    pub fn new(port_name: impl Into<String>, baud_rate: u32) -> Self {
96        Self {
97            port_name: port_name.into(),
98            baud_rate,
99            data_bits: DataBits::Eight,
100            parity: Parity::None,
101            stop_bits: StopBits::One,
102            flow_control: FlowControl::None,
103            timeout: Duration::from_millis(100),
104            #[cfg(unix)]
105            exclusive: true,
106        }
107    }
108
109    /// Проверяет, что настройки можно применить к serial-порту.
110    ///
111    /// Метод не открывает устройство. Он только ловит ошибки, которые библиотека может
112    /// определить заранее: пустое имя порта и нулевой baud rate.
113    ///
114    /// # Ошибки
115    ///
116    /// Возвращает [`ConfigError::EmptyPortName`] или [`ConfigError::ZeroBaudRate`].
117    pub fn validate(&self) -> std::result::Result<(), ConfigError> {
118        if self.port_name.trim().is_empty() {
119            return Err(ConfigError::EmptyPortName);
120        }
121        if self.baud_rate == 0 {
122            return Err(ConfigError::ZeroBaudRate);
123        }
124        Ok(())
125    }
126}
127
128/// Текстовая кодировка, используемая для байтов дисплея.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum TextEncoding {
131    /// IBM866 / CP866.
132    ///
133    /// Частый выбор для русской кириллицы на Epson/АТОЛ-совместимых дисплеях.
134    Cp866,
135    /// Windows-1251.
136    ///
137    /// Используйте, если руководство дисплея явно указывает Windows-1251 или CP1251.
138    Windows1251,
139    /// Только ASCII, всё вне ASCII заменяется на `?`.
140    Ascii,
141    /// UTF-8 без перекодирования.
142    ///
143    /// Подходит только устройствам, которые действительно принимают UTF-8 байты.
144    Utf8,
145}
146
147/// Настройки геометрии и ESC/POS-команд дисплея.
148///
149/// `columns` и `rows` задают координатную сетку, используемую для проверки `print_line`
150/// и `print_at`. `code_table` управляет только аппаратной командой `ESC t n`, а
151/// `encoding` определяет программное преобразование текста в байты.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct DisplaySettings {
154    /// Количество символов в строке.
155    ///
156    /// Допустимый диапазон: `1..=255`. Значение используется для обрезки, заполнения
157    /// пробелами и проверки координаты `x`.
158    pub columns: usize,
159    /// Количество строк.
160    ///
161    /// Допустимый диапазон: `1..=255`. Значение используется для проверки `line` и `y`
162    /// во всех методах печати.
163    pub rows: usize,
164    /// Кодировка текстовых байтов.
165    ///
166    /// Это программное преобразование Rust-строки в байты транспорта. Оно не выбирает
167    /// аппаратную таблицу дисплея.
168    pub encoding: TextEncoding,
169    /// Таблица символов для `ESC t n`; `None` отключает отправку команды.
170    ///
171    /// Это отдельная аппаратная команда протокола. Для многих дисплеев CP866 работает
172    /// только когда одновременно выбраны `encoding = TextEncoding::Cp866` и нужное
173    /// значение `code_table`.
174    pub code_table: Option<u8>,
175    /// Отправлять `ESC @` при открытии.
176    ///
177    /// Сброс полезен для predictable startup, но его можно выключить, если приложение
178    /// намеренно сохраняет состояние дисплея между открытиями.
179    pub reset_on_open: bool,
180    /// Поддерживаемый диапазон яркости для `US X n`.
181    ///
182    /// `None` означает, что типизированная установка яркости запрещена и
183    /// [`crate::Vfd::set_brightness`] вернёт [`crate::VfdError::UnsupportedBrightness`].
184    pub brightness: Option<std::ops::RangeInclusive<u8>>,
185    /// Задержка после изменения яркости.
186    ///
187    /// Некоторые дисплеи требуют короткую паузу после `US X n`. Sync API блокирует
188    /// текущий поток на это время; Tokio API ждёт через async sleep.
189    pub brightness_settle: Duration,
190}
191
192impl DisplaySettings {
193    /// Создаёт ручные настройки дисплея.
194    ///
195    /// По умолчанию включён `ESC @` при открытии, яркость `1..=4` и короткая задержка
196    /// после её изменения. Аппаратная таблица символов не выбирается автоматически:
197    /// задайте `code_table = Some(n)`, если вашему дисплею нужна команда `ESC t n`.
198    ///
199    /// # Ошибки
200    ///
201    /// Метод сам не возвращает ошибку. Проверка выполняется в
202    /// [`DisplaySettings::validate`] или при создании [`VfdConfig`].
203    ///
204    /// # Примеры
205    ///
206    /// ```
207    /// use escpos_vfd::{DisplaySettings, TextEncoding};
208    ///
209    /// let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
210    /// display.code_table = Some(6);
211    /// assert!(display.validate().is_ok());
212    /// ```
213    pub fn new(columns: usize, rows: usize, encoding: TextEncoding) -> Self {
214        Self {
215            columns,
216            rows,
217            encoding,
218            code_table: None,
219            reset_on_open: true,
220            brightness: Some(1..=4),
221            brightness_settle: Duration::from_millis(2),
222        }
223    }
224
225    /// Проверяет геометрию и диапазоны настроек дисплея.
226    ///
227    /// # Ошибки
228    ///
229    /// Возвращает [`ConfigError::InvalidColumns`], [`ConfigError::InvalidRows`] или
230    /// [`ConfigError::InvalidBrightnessRange`].
231    pub fn validate(&self) -> std::result::Result<(), ConfigError> {
232        if !(1..=u8::MAX as usize).contains(&self.columns) {
233            return Err(ConfigError::InvalidColumns(self.columns));
234        }
235        if !(1..=u8::MAX as usize).contains(&self.rows) {
236            return Err(ConfigError::InvalidRows(self.rows));
237        }
238        if let Some(range) = &self.brightness {
239            let min = *range.start();
240            let max = *range.end();
241            if min == 0 || min > max {
242                return Err(ConfigError::InvalidBrightnessRange { min, max });
243            }
244        }
245        Ok(())
246    }
247}
248
249/// Полная конфигурация VFD: serial transport плюс геометрия/протокол дисплея.
250///
251/// Один и тот же `VfdConfig` используется sync и Tokio API. `queue_capacity` влияет
252/// только на worker-обёртки; низкоуровневый [`crate::Vfd`] открывает порт напрямую.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct VfdConfig {
255    /// Настройки serial-порта.
256    pub serial: SerialSettings,
257    /// Настройки дисплея.
258    pub display: DisplaySettings,
259    /// Ёмкость очереди фонового worker.
260    ///
261    /// Используется только [`crate::VfdWorker`] и `escpos_vfd::tokio::AsyncVfdWorker`.
262    /// Значение `32` по умолчанию даёт backpressure без бесконтрольного роста памяти.
263    pub queue_capacity: usize,
264}
265
266impl VfdConfig {
267    /// Создаёт полностью ручную конфигурацию.
268    ///
269    /// Метод сразу валидирует serial и display настройки, поэтому ошибки конфигурации
270    /// возвращаются до попытки открыть устройство.
271    ///
272    /// # Ошибки
273    ///
274    /// Возвращает [`ConfigError`], если serial, display или `queue_capacity` содержат
275    /// недопустимые значения.
276    pub fn new(
277        serial: SerialSettings,
278        display: DisplaySettings,
279    ) -> std::result::Result<Self, ConfigError> {
280        let cfg = Self {
281            serial,
282            display,
283            queue_capacity: 32,
284        };
285        cfg.validate()?;
286        Ok(cfg)
287    }
288
289    /// Создаёт конфигурацию из пресета.
290    ///
291    /// `Preset::Epson20x2Cp866` воспроизводит прежние настройки библиотеки: 20x2,
292    /// 9600 baud, CP866, `ESC @`, `ESC t 6` и яркость `1..=4`.
293    ///
294    /// # Ошибки
295    ///
296    /// Возвращает [`ConfigError::EmptyPortName`], если имя порта пустое.
297    pub fn preset(
298        port_name: impl Into<String>,
299        preset: Preset,
300    ) -> std::result::Result<Self, ConfigError> {
301        match preset {
302            Preset::Epson20x2Cp866 => {
303                let serial = SerialSettings::new(port_name, 9600);
304                let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
305                display.code_table = Some(6);
306                display.reset_on_open = true;
307                display.brightness = Some(1..=4);
308                display.brightness_settle = Duration::from_millis(2);
309                Self::new(serial, display)
310            }
311        }
312    }
313
314    /// Устанавливает ёмкость bounded-очереди worker.
315    ///
316    /// Значение `0` запрещено: такая очередь не смогла бы принять даже команду shutdown.
317    ///
318    /// # Ошибки
319    ///
320    /// Возвращает [`ConfigError::ZeroQueueCapacity`] при `capacity = 0`.
321    pub fn with_queue_capacity(
322        mut self,
323        capacity: usize,
324    ) -> std::result::Result<Self, ConfigError> {
325        self.queue_capacity = capacity;
326        self.validate()?;
327        Ok(self)
328    }
329
330    /// Проверяет serial и display настройки.
331    ///
332    /// # Ошибки
333    ///
334    /// Возвращает первый найденный [`ConfigError`].
335    pub fn validate(&self) -> std::result::Result<(), ConfigError> {
336        self.serial.validate()?;
337        self.display.validate()?;
338        if self.queue_capacity == 0 {
339            return Err(ConfigError::ZeroQueueCapacity);
340        }
341        Ok(())
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn epson_preset_matches_legacy_settings() {
351        let cfg = VfdConfig::preset("COM1", Preset::Epson20x2Cp866).unwrap();
352
353        assert_eq!(cfg.serial.port_name, "COM1");
354        assert_eq!(cfg.serial.baud_rate, 9600);
355        assert_eq!(cfg.serial.data_bits, DataBits::Eight);
356        assert_eq!(cfg.serial.parity, Parity::None);
357        assert_eq!(cfg.serial.stop_bits, StopBits::One);
358        assert_eq!(cfg.serial.flow_control, FlowControl::None);
359        assert_eq!(cfg.serial.timeout, Duration::from_millis(100));
360        assert_eq!(cfg.display.columns, 20);
361        assert_eq!(cfg.display.rows, 2);
362        assert_eq!(cfg.display.encoding, TextEncoding::Cp866);
363        assert_eq!(cfg.display.code_table, Some(6));
364        assert!(cfg.display.reset_on_open);
365        assert_eq!(cfg.display.brightness, Some(1..=4));
366    }
367
368    #[test]
369    fn manual_config_has_no_hidden_code_table_or_baud() {
370        let serial = SerialSettings::new("/tmp/tty", 19_200);
371        let display = DisplaySettings::new(16, 4, TextEncoding::Windows1251);
372        let cfg = VfdConfig::new(serial, display).unwrap();
373
374        assert_eq!(cfg.serial.baud_rate, 19_200);
375        assert_eq!(cfg.display.columns, 16);
376        assert_eq!(cfg.display.rows, 4);
377        assert_eq!(cfg.display.code_table, None);
378    }
379
380    #[test]
381    fn invalid_config_values_are_rejected() {
382        assert!(matches!(
383            SerialSettings::new("", 9600).validate(),
384            Err(ConfigError::EmptyPortName)
385        ));
386        assert!(matches!(
387            SerialSettings::new("p", 0).validate(),
388            Err(ConfigError::ZeroBaudRate)
389        ));
390        assert!(matches!(
391            DisplaySettings::new(0, 2, TextEncoding::Cp866).validate(),
392            Err(ConfigError::InvalidColumns(0))
393        ));
394        assert!(matches!(
395            DisplaySettings::new(20, 256, TextEncoding::Cp866).validate(),
396            Err(ConfigError::InvalidRows(256))
397        ));
398
399        let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
400        let min = 4;
401        let max = 1;
402        display.brightness = Some(min..=max);
403        assert!(matches!(
404            display.validate(),
405            Err(ConfigError::InvalidBrightnessRange { min: 4, max: 1 })
406        ));
407
408        let cfg = VfdConfig::preset("COM1", Preset::Epson20x2Cp866).unwrap();
409        assert!(matches!(
410            cfg.with_queue_capacity(0),
411            Err(ConfigError::ZeroQueueCapacity)
412        ));
413    }
414}