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}