diff --git a/Cargo.lock b/Cargo.lock index 8007474..da39aa8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "bitflags" @@ -16,9 +16,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "cfg-if" @@ -28,9 +28,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "core-foundation" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.180" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libudev" @@ -118,24 +118,24 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -148,11 +148,11 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serialport" -version = "4.8.1" +version = "4.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21f60a586160667241d7702c420fc223939fb3c0bb8d3fac84f78768e8970dee" +checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "core-foundation", "core-foundation-sys", @@ -160,7 +160,6 @@ dependencies = [ "libudev", "mach2", "nix", - "quote", "scopeguard", "unescaper", "windows-sys", @@ -168,9 +167,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -179,18 +178,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -199,18 +198,18 @@ dependencies = [ [[package]] name = "unescaper" -version = "0.1.8" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" dependencies = [ "thiserror", ] [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "windows-sys" diff --git a/README.md b/README.md index fd280fb..7e4ffe0 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,236 @@ -# VFD — библиотека управления VFD-дисплеями (Epson mode) +# VFD — управление дисплеями в Epson-совместимом режиме -Небольшая Rust-библиотека для управления **VFD-дисплеями Epson/ESC-совместимого режима** -(например PD-2600 / PD-2800 и похожие). +Небольшая Rust-библиотека для двухстрочных VFD-дисплеев покупателя, работающих через +serial-порт в Epson/ESC-совместимом режиме. Проект ориентирован на дисплеи семейства +PD-2600/PD-2800 и похожие модели с поддержкой команд позиционирования, яркости и +таблицы символов CP866. -Подходит для: - -- домашних IoT-проектов -- индикаторов состояния -- часов, дашбордов, уведомлений -- ретро-интерфейсов - -Поддержка: - -- печать текста по координатам -- обновление строк через diff (без мерцания) -- яркость дисплея -- программный marquee (бегущая строка) -- работа через serial (USB-UART) - ---- +Библиотека подходит для часов, локальных дашбордов, кассовых приложений, уведомлений, +индикаторов состояния и других проектов, где данные нужно обновлять без мерцания всей +строки. ## Возможности -- Двухстрочный дисплей (ширина настраивается) -- `print_line_diff` — обновление только изменённых символов -- Управление яркостью (1–4) -- Бегущая строка (marquee) в отдельном воркере -- Потокобезопасный API (worker + handle) +- вывод текста в первую или вторую строку; +- печать с координаты `(x, y)`; +- обновление только изменившихся диапазонов строки через `print_line_diff`; +- фоновая бегущая строка с регулируемой скоростью и паузой; +- четыре уровня яркости; +- кириллица через CP866; +- потокобезопасный интерфейс `VfdHandle`, который можно клонировать; +- автоматическая обрезка текста по правому краю и дополнение строк пробелами. ---- +## Поддерживаемый протокол -## Установка +При открытии устройства библиотека использует следующие параметры и команды: -Добавьте библиотеку в `Cargo.toml`: +| Параметр | Значение | +| --- | --- | +| Скорость serial-порта | `9600 baud` | +| Таблица символов | CP866, команда `ESC t 6` | +| Инициализация | `ESC @` | +| Очистка | `FF` (`0x0C`) | +| Координаты | `US $ x y`, нумерация от 1 | +| Яркость | `US X n`, где `n = 1..4` | +| Число строк | 2 | +| Допустимая ширина | `1..=255` символов | + +Перед запуском убедитесь, что дисплей переведён DIP-переключателями в совместимый +режим. Точные настройки конкретной модели смотрите в документации из каталога +[`docs`](docs/). + +## Подключение + +Для локального проекта добавьте библиотеку как path-зависимость: ```toml [dependencies] -m = { path = "./path/to/vfd-lib" } +m = { path = "../vfd" } +anyhow = "1" ``` ---- +Имя crate сейчас — `m`, поэтому импорт начинается с `m::`. ## Быстрый старт -```rust +```rust,no_run +use anyhow::Result; use m::vfd::VfdConfig; use m::worker::VfdWorker; - -let cfg = VfdConfig::new("/dev/cu.usbmodem101") - .with_width(20); - -let worker = VfdWorker::start(cfg)?; -let vfd = worker.handle(); - -vfd.clear(); -vfd.set_brightness(2); -vfd.print_line_diff(1, "Hello VFD!"); -vfd.print_line_diff(2, "It works!"); -``` - ---- - -## Бегущая строка (marquee) - -```rust use std::time::Duration; -vfd.set_marquee_text(" Это пример бегущей строки "); -vfd.start_marquee( - 2, // строка - 8, // символов в секунду - Duration::from_millis(1500), // пауза в конце +fn main() -> Result<()> { + let config = VfdConfig::new("/dev/cu.usbmodem101") + .with_width(20); + + // Serial-порт открывается до запуска фонового потока, + // поэтому ошибка подключения вернётся из start(). + let worker = VfdWorker::start(config)?; + let display = worker.handle(); + + display.clear(); + display.set_brightness(2); + display.print_line_diff(1, "Привет!")?; + display.print_line_diff(2, "VFD готов")?; + + // Команды выполняются асинхронно: не завершаем программу сразу после отправки. + std::thread::sleep(Duration::from_secs(2)); + + Ok(()) +} +``` + +`VfdWorker` последовательно выполняет команды в отдельном потоке. При удалении worker +отправляет команду завершения и дожидается остановки потока. Сохраняйте `worker` в +области видимости, пока дисплей используется. + +## Основной API + +### Конфигурация + +```rust,no_run +use m::vfd::VfdConfig; +use std::time::Duration; + +let config = VfdConfig::new("/dev/ttyUSB0") + .with_width(20) + .with_timeout(Duration::from_millis(250)); +``` + +Ширина автоматически ограничивается диапазоном `1..=255`, потому что координаты +протокола передаются одним байтом. + +### Полная и дифференциальная запись + +```rust,ignore +display.print_line(1, "Полная перезапись")?; +display.print_line_diff(2, "Изменились цифры: 42")?; +``` + +`print_line_diff` хранит последний кадр каждой строки и группирует соседние изменения. +Если текст не изменился, serial-команда не отправляется. Это полезно для часов и +дашбордов, которые обновляются часто. + +### Печать по координатам + +```rust,ignore +display.print_at(7, 1, "21.50")?; +display.print_at(16, 1, "1200")?; +``` + +Координаты начинаются с единицы. Допустимы строки `1` и `2`; текст, выходящий за правую +границу, обрезается по символам без повреждения UTF-8. + +### Бегущая строка + +```rust,ignore +use std::time::Duration; + +display.set_marquee_text("Длинное сообщение для посетителя"); +display.start_marquee( + 2, // строка + 8, // символов в секунду + Duration::from_millis(1500), // пауза после полного прохода ); + +// При необходимости: +display.stop_marquee(); ``` -Marquee работает в фоне и не мешает `print_line_diff`, -если не писать в ту же строку. - ---- - -## Управление яркостью - -```rust -vfd.set_brightness(1); // диапазон 1..4 -``` - -Яркость можно менять динамически (ночной режим, «дыхание» и т.п.). - ---- +Обычные команды записи в строку, занятую marquee, игнорируются, чтобы два источника +не перезаписывали друг друга. ## Примеры -Запуск через `cargo run --example` или `task`: +Все примеры принимают два общих аргумента: + +1. serial-порт, по умолчанию `/dev/cu.usbmodem101`; +2. ширина дисплея, по умолчанию `20`. + +Дополнительные аргументы зависят от примера: + +| Пример | Что демонстрирует | Дополнительные аргументы | +| --- | --- | --- | +| `clock` | Дату, время, день недели и часть суток | `[brightness=2]` | +| `marquee` | Фоновую бегущую строку | `[cps=8] [pause_ms=1500] [brightness=2]` | +| `brightness` | Перебор уровней яркости `1..=4` | `[delay_ms=800]` | +| `position` | Движение символа по второй строке | `[delay_ms=80]` | +| `update_at` | Частичное обновление полей дашборда | `[delay_ms=200]` | +| `blink` | Мигание текста в заданной позиции | `[delay_ms=400] [x=1] [y=1] [text=BLINK]` | + +Примеры запуска: ```bash -cargo run --example clock -- /dev/cu.usbmodem101 20 8 +cargo run --example clock -- /dev/cu.usbmodem101 20 2 cargo run --example marquee -- /dev/cu.usbmodem101 20 8 1500 2 cargo run --example brightness -- /dev/cu.usbmodem101 20 800 +cargo run --example position -- /dev/cu.usbmodem101 20 80 +cargo run --example update_at -- /dev/cu.usbmodem101 20 200 +cargo run --example blink -- /dev/cu.usbmodem101 20 400 1 2 BLINK ``` -Примеры: +`update_at` использует фиксированную 20-символьную раскладку и завершится с понятной +ошибкой, если передать меньшую ширину. -- `clock` — часы -- `marquee` — бегущая строка -- `brightness` — демонстрация яркости +Те же программы доступны через [Task](https://taskfile.dev/): ---- +```bash +task vfd:clock +task vfd:marquee +task vfd:brightness +task vfd:position +task vfd:update_at +task vfd:blink +``` -## Требования +Значения можно переопределять переменными, например: -- Дисплей в **Epson-режиме** (DIP-переключатели) -- Скорость: 9600 baud -- Кодировка: CP866 (кириллица) +```bash +task vfd:marquee VFD_PORT=/dev/ttyUSB0 VFD_WIDTH=20 VFD_CPS=10 +``` ---- +## Кодировка текста -## Идеи для использования +Перед записью библиотека заменяет несколько типографских символов на совместимые +аналоги: -- часы / дата / температура -- MQTT-дашборд -- уведомления умного дома -- статус сервиса / сборки -- ретро-индикатор +- `…` → `.`; +- `—` и `–` → `-`; +- `№` → `#`; +- типографские кавычки → обычные кавычки; +- табуляция → пробел. + +Остальной текст кодируется с помощью `encoding_rs::IBM866`. + +## Структура проекта + +```text +src/vfd.rs низкоуровневые команды и CP866 +src/worker.rs очередь команд, diff и marquee +examples/common/mod.rs общие CLI-утилиты примеров +examples/*.rs демонстрационные программы +docs/ руководства для дисплеев +taskfile.yml команды запуска примеров +``` + +## Разработка и проверка + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --all-features --locked -- -D warnings +cargo test --all-targets --locked +cargo check --examples --locked +``` + +Автоматические тесты проверяют Unicode, CP866-санацию, ширину строк, обновление кэша, +группировку diff-диапазонов, таймер marquee и функции примера часов. Для полной проверки +нужен отдельный smoke-test с физическим дисплеем и фактическим serial-портом. + +## Ограничения + +- поддерживаются только две строки; +- скорость подключения фиксирована на `9600 baud`; +- очередь команд в worker не ограничена по размеру; +- аппаратные ошибки после запуска worker выводятся в stderr из фонового потока; +- пример `clock` использует системную команду `date` и рассчитан на Unix-подобную среду; +- библиотека не определяет serial-порт автоматически. diff --git a/examples/blink.rs b/examples/blink.rs index 85c7fa7..b5a23e1 100644 --- a/examples/blink.rs +++ b/examples/blink.rs @@ -1,13 +1,11 @@ +//! Демонстрирует мигание произвольного текста в выбранной позиции через `print_at`. + +mod common; + use anyhow::Result; -use std::time::Duration; -use m::vfd::VfdConfig; use m::worker::VfdWorker; -fn spaces(n: usize) -> String { - " ".repeat(n) -} - fn main() -> Result<()> { // args: // 1) port (default: /dev/cu.usbmodem101) @@ -16,28 +14,14 @@ fn main() -> Result<()> { // 4) x (default: 1) 1-based // 5) y (default: 1) 1-based line // 6) text (default: "BLINK") - let mut args = std::env::args().skip(1); - - let port_name = args.next().unwrap_or("/dev/cu.usbmodem101".into()); - - let width: usize = args.next().as_deref().unwrap_or("20").parse().unwrap_or(20); - - let delay_ms: u64 = args - .next() - .as_deref() - .unwrap_or("400") - .parse() - .unwrap_or(400); - - let x: u8 = args.next().as_deref().unwrap_or("1").parse().unwrap_or(1); - - let y: u8 = args.next().as_deref().unwrap_or("1").parse().unwrap_or(1); - - let text = args.next().unwrap_or("BLINK".into()); + let mut args = common::ExampleArgs::from_env(); + let delay_ms: u64 = args.parse_or(400); + let x: u8 = args.parse_or(1); + let y: u8 = args.parse_or(1); + let text = args.string_or("BLINK"); let text_len = text.chars().count(); - let cfg = VfdConfig::new(port_name).with_width(width); - let worker = VfdWorker::start(cfg)?; + let worker = VfdWorker::start(args.config)?; let vfd = worker.handle(); vfd.clear(); @@ -47,7 +31,7 @@ fn main() -> Result<()> { let _ = vfd.print_at(1, y, ""); // просто чтобы “активировать” строку у некоторых дисплеев } - let blank = spaces(text_len); + let blank = " ".repeat(text_len); let mut on = false; loop { @@ -57,6 +41,6 @@ fn main() -> Result<()> { let _ = vfd.print_at(x, y, &text); } on = !on; - std::thread::sleep(Duration::from_millis(delay_ms)); + common::sleep_ms(delay_ms); } } diff --git a/examples/brightness.rs b/examples/brightness.rs index a4a5267..513185c 100644 --- a/examples/brightness.rs +++ b/examples/brightness.rs @@ -1,7 +1,9 @@ +//! Демонстрирует четыре уровня яркости и обновление строки без полного мерцания. + +mod common; + use anyhow::Result; -use std::time::Duration; -use m::vfd::VfdConfig; use m::worker::VfdWorker; fn main() -> Result<()> { @@ -9,26 +11,9 @@ fn main() -> Result<()> { // 1) port (default: /dev/cu.usbmodem101) // 2) width (default: 20) // 3) delay_ms (default: 800) - let port_name = std::env::args() - .nth(1) - .unwrap_or("/dev/cu.usbmodem101".into()); - - let width: usize = std::env::args() - .nth(2) - .as_deref() - .unwrap_or("20") - .parse() - .unwrap_or(20); - - let delay_ms: u64 = std::env::args() - .nth(3) - .as_deref() - .unwrap_or("800") - .parse() - .unwrap_or(800); - - let cfg = VfdConfig::new(port_name).with_width(width); - let worker = VfdWorker::start(cfg)?; + let mut args = common::ExampleArgs::from_env(); + let delay_ms: u64 = args.parse_or(800); + let worker = VfdWorker::start(args.config)?; let vfd = worker.handle(); vfd.clear(); @@ -47,7 +32,7 @@ fn main() -> Result<()> { let _ = vfd.print_line_diff(2, line2); - std::thread::sleep(Duration::from_millis(delay_ms)); + common::sleep_ms(delay_ms); } } } diff --git a/examples/clock.rs b/examples/clock.rs index 0ed6800..3cd28e7 100644 --- a/examples/clock.rs +++ b/examples/clock.rs @@ -1,8 +1,11 @@ -// examples/clock_ru.rs +//! Показывает локальные дату, время, день недели и часть суток с обновлением раз в секунду. + +mod common; + use anyhow::Result; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use m::vfd::VfdConfig; +use m::vfd::fit_to_width; use m::worker::VfdWorker; fn run_date(args: &[&str]) -> Option { @@ -16,16 +19,19 @@ fn run_date(args: &[&str]) -> Option { Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) } -fn local_datetime_ddmmyyyy_hhmmss() -> String { - // 28.01.2006 12:03:34 - run_date(&["+%d.%m.%Y %H:%M:%S"]).unwrap_or_else(|| "??.??.???? ??:??:??".to_string()) +fn parse_datetime_and_weekday(value: &str) -> Option<(String, u8)> { + let (datetime, weekday) = value.split_once('|')?; + let weekday = weekday.parse::().ok()?; + (1..=7) + .contains(&weekday) + .then(|| (datetime.to_string(), weekday)) } -fn local_weekday_num_1_7() -> u8 { - // 1..7 (Mon..Sun) - run_date(&["+%u"]) - .and_then(|s| s.parse::().ok()) - .unwrap_or(1) +fn local_datetime_and_weekday() -> (String, u8) { + run_date(&["+%d.%m.%Y %H:%M:%S|%u"]) + .as_deref() + .and_then(parse_datetime_and_weekday) + .unwrap_or_else(|| ("??.??.???? ??:??:??".to_string(), 1)) } fn weekday_ru_full(n: u8) -> &'static str { @@ -74,51 +80,22 @@ fn extract_hour(datetime: &str) -> u8 { .unwrap_or(12) } -fn fit_to_width(s: &str, width: usize) -> String { - // простое “обрезать по символам”, чтобы кириллицу не порвать - let mut out = String::new(); - for (i, ch) in s.chars().enumerate() { - if i >= width { - break; - } - out.push(ch); - } - out -} - fn main() -> Result<()> { // args: // 1) port (default: /dev/cu.usbmodem101) // 2) width (default: 20) // 3) brightness 1..4 (default: 2) - let port_name = std::env::args() - .nth(1) - .unwrap_or("/dev/cu.usbmodem101".into()); - - let width: usize = std::env::args() - .nth(2) - .as_deref() - .unwrap_or("20") - .parse() - .unwrap_or(20); - - let brightness: u8 = std::env::args() - .nth(3) - .as_deref() - .unwrap_or("2") - .parse() - .unwrap_or(2); - - let cfg = VfdConfig::new(port_name).with_width(width); - let worker = VfdWorker::start(cfg)?; + let mut args = common::ExampleArgs::from_env(); + let brightness: u8 = args.parse_or(2); + let width = args.width; + let worker = VfdWorker::start(args.config)?; let vfd = worker.handle(); vfd.clear(); vfd.set_brightness(brightness); loop { - let dt = local_datetime_ddmmyyyy_hhmmss(); - let wd = local_weekday_num_1_7(); + let (dt, wd) = local_datetime_and_weekday(); let hour = extract_hour(&dt); let tod = time_of_day_ru(hour); @@ -148,3 +125,26 @@ fn main() -> Result<()> { std::thread::sleep(Duration::from_millis(ms_to_next)); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_date_and_weekday_from_one_process_result() { + assert_eq!( + parse_datetime_and_weekday("15.08.2026 23:59:58|6"), + Some(("15.08.2026 23:59:58".to_string(), 6)) + ); + assert_eq!(parse_datetime_and_weekday("invalid"), None); + assert_eq!(parse_datetime_and_weekday("15.08.2026 23:59:58|8"), None); + } + + #[test] + fn classifies_hours_and_extracts_them_safely() { + assert_eq!(extract_hour("15.08.2026 05:00:00"), 5); + assert_eq!(extract_hour("invalid"), 12); + assert_eq!(time_of_day_ru(5), "утро"); + assert_eq!(time_of_day_ru(23), "ночь"); + } +} diff --git a/examples/common/mod.rs b/examples/common/mod.rs new file mode 100644 index 0000000..84b74c2 --- /dev/null +++ b/examples/common/mod.rs @@ -0,0 +1,59 @@ +//! Общие утилиты командной строки для всех демонстрационных программ. +#![allow( + dead_code, + reason = "модуль компилируется отдельно для каждого примера, поэтому часть API используется в соседних примерах" +)] + +use m::vfd::VfdConfig; +use std::str::FromStr; +use std::vec::IntoIter; + +const DEFAULT_PORT: &str = "/dev/cu.usbmodem101"; +const DEFAULT_WIDTH: usize = 20; + +/// Общие аргументы примера: serial-порт, ширина дисплея и оставшиеся параметры. +pub struct ExampleArgs { + pub config: VfdConfig, + pub width: usize, + remaining: IntoIter, +} + +impl ExampleArgs { + /// Читает общие аргументы из командной строки и применяет безопасные значения по умолчанию. + pub fn from_env() -> Self { + let mut values = std::env::args().skip(1); + let port_name = values.next().unwrap_or_else(|| DEFAULT_PORT.to_string()); + let width = values + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_WIDTH); + let config = VfdConfig::new(port_name).with_width(width); + + Self { + width: config.width, + config, + remaining: values.collect::>().into_iter(), + } + } + + /// Читает следующий аргумент нужного типа или возвращает переданное значение по умолчанию. + pub fn parse_or(&mut self, default: T) -> T + where + T: FromStr, + { + self.remaining + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or(default) + } + + /// Читает следующий строковый аргумент или возвращает строку по умолчанию. + pub fn string_or(&mut self, default: &str) -> String { + self.remaining.next().unwrap_or_else(|| default.to_string()) + } +} + +/// Приостанавливает пример на указанное число миллисекунд. +pub fn sleep_ms(delay_ms: u64) { + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); +} diff --git a/examples/marquee.rs b/examples/marquee.rs index f68b583..53ec7db 100644 --- a/examples/marquee.rs +++ b/examples/marquee.rs @@ -1,7 +1,10 @@ +//! Демонстрирует фоновую бегущую строку с регулируемой скоростью и паузой в конце. + +mod common; + use anyhow::Result; use std::time::Duration; -use m::vfd::VfdConfig; use m::worker::VfdWorker; fn main() -> Result<()> { @@ -11,40 +14,11 @@ fn main() -> Result<()> { // 3) cps (chars per second, default: 8) // 4) end_pause_ms (default: 1500) // 5) brightness 1..4 (default: 2) - let port_name = std::env::args() - .nth(1) - .unwrap_or("/dev/cu.usbmodem101".into()); - - let width: usize = std::env::args() - .nth(2) - .as_deref() - .unwrap_or("20") - .parse() - .unwrap_or(20); - - let cps: u32 = std::env::args() - .nth(3) - .as_deref() - .unwrap_or("8") - .parse() - .unwrap_or(8); - - let end_pause_ms: u64 = std::env::args() - .nth(4) - .as_deref() - .unwrap_or("1500") - .parse() - .unwrap_or(1500); - - let brightness: u8 = std::env::args() - .nth(5) - .as_deref() - .unwrap_or("2") - .parse() - .unwrap_or(2); - - let cfg = VfdConfig::new(port_name).with_width(width); - let worker = VfdWorker::start(cfg)?; + let mut args = common::ExampleArgs::from_env(); + let cps: u32 = args.parse_or(8); + let end_pause_ms: u64 = args.parse_or(1500); + let brightness: u8 = args.parse_or(2); + let worker = VfdWorker::start(args.config)?; let vfd = worker.handle(); vfd.clear(); @@ -61,6 +35,6 @@ fn main() -> Result<()> { let _ = vfd.print_line_diff(1, "Бегущая строка"); loop { - std::thread::sleep(Duration::from_secs(3600)); + common::sleep_ms(3_600_000); } } diff --git a/examples/position.rs b/examples/position.rs index f9f700f..204af53 100644 --- a/examples/position.rs +++ b/examples/position.rs @@ -1,7 +1,9 @@ +//! Демонстрирует позиционирование символа и его движение по второй строке дисплея. + +mod common; + use anyhow::Result; -use std::time::Duration; -use m::vfd::VfdConfig; use m::worker::VfdWorker; fn main() -> Result<()> { @@ -9,26 +11,10 @@ fn main() -> Result<()> { // 1) port (default: /dev/cu.usbmodem101) // 2) width (default: 20) // 3) delay_ms (default: 80) - let port_name = std::env::args() - .nth(1) - .unwrap_or("/dev/cu.usbmodem101".into()); - - let width: usize = std::env::args() - .nth(2) - .as_deref() - .unwrap_or("20") - .parse() - .unwrap_or(20); - - let delay_ms: u64 = std::env::args() - .nth(3) - .as_deref() - .unwrap_or("80") - .parse() - .unwrap_or(80); - - let cfg = VfdConfig::new(port_name).with_width(width); - let worker = VfdWorker::start(cfg)?; + let mut args = common::ExampleArgs::from_env(); + let delay_ms: u64 = args.parse_or(80); + let width = args.width; + let worker = VfdWorker::start(args.config)?; let vfd = worker.handle(); vfd.clear(); @@ -69,6 +55,6 @@ fn main() -> Result<()> { } } - std::thread::sleep(Duration::from_millis(delay_ms)); + common::sleep_ms(delay_ms); } } diff --git a/examples/update_at.rs b/examples/update_at.rs index 3ed9e30..0c1942c 100644 --- a/examples/update_at.rs +++ b/examples/update_at.rs @@ -1,7 +1,9 @@ +//! Имитирует компактный дашборд и обновляет только изменяемые поля через `print_at`. + +mod common; + use anyhow::Result; -use std::time::Duration; -use m::vfd::VfdConfig; use m::worker::VfdWorker; fn pad_left(s: &str, width: usize) -> String { @@ -18,26 +20,13 @@ fn main() -> Result<()> { // 1) port (default: /dev/cu.usbmodem101) // 2) width (default: 20) // 3) delay_ms (default: 200) - let port_name = std::env::args() - .nth(1) - .unwrap_or("/dev/cu.usbmodem101".into()); - - let width: usize = std::env::args() - .nth(2) - .as_deref() - .unwrap_or("20") - .parse() - .unwrap_or(20); - - let delay_ms: u64 = std::env::args() - .nth(3) - .as_deref() - .unwrap_or("200") - .parse() - .unwrap_or(200); - - let cfg = VfdConfig::new(port_name).with_width(width); - let worker = VfdWorker::start(cfg)?; + let mut args = common::ExampleArgs::from_env(); + let delay_ms: u64 = args.parse_or(200); + let width = args.width; + if width < 20 { + anyhow::bail!("update_at requires a display width of at least 20 columns"); + } + let worker = VfdWorker::start(args.config)?; let vfd = worker.handle(); vfd.clear(); @@ -103,6 +92,6 @@ fn main() -> Result<()> { si = (si + 1) % spinner.len(); } - std::thread::sleep(Duration::from_millis(delay_ms)); + common::sleep_ms(delay_ms); } } diff --git a/src/lib.rs b/src/lib.rs index e003570..9404907 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,6 @@ +//! Библиотека управления двухстрочными VFD-дисплеями в Epson-совместимом режиме. + +/// Низкоуровневая работа с serial-портом, кодировкой и командами дисплея. pub mod vfd; +/// Фоновый поток и потокобезопасный интерфейс для обновления дисплея. pub mod worker; diff --git a/src/vfd.rs b/src/vfd.rs index e87a376..c554c43 100644 --- a/src/vfd.rs +++ b/src/vfd.rs @@ -6,15 +6,21 @@ use std::time::Duration; const TABLE_CYR: u8 = 6; // PD-2600: your Cyrillic table (ESC t 6) const FIXED_BAUD: u32 = 9600; +const MAX_WIDTH: usize = u8::MAX as usize; #[derive(Debug, Clone)] +/// Настройки serial-подключения и геометрии VFD-дисплея. pub struct VfdConfig { + /// Имя serial-порта, например `/dev/cu.usbmodem101`. pub port_name: String, + /// Число символов в строке; ограничивается диапазоном `1..=255`. pub width: usize, + /// Тайм-аут операций чтения и записи serial-порта. pub timeout: Duration, } impl VfdConfig { + /// Создаёт конфигурацию с шириной 20 символов и тайм-аутом 100 мс. pub fn new(port_name: impl Into) -> Self { Self { port_name: port_name.into(), @@ -23,25 +29,27 @@ impl VfdConfig { } } + /// Устанавливает ширину строки с учётом диапазона координат протокола. pub fn with_width(mut self, width: usize) -> Self { - self.width = width.max(1); + self.width = width.clamp(1, MAX_WIDTH); self } - #[allow(dead_code)] + /// Устанавливает тайм-аут операций serial-порта. pub fn with_timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; self } } +/// Низкоуровневое соединение с дисплеем, владеющее serial-портом. pub struct Vfd { port: Box, pub width: usize, - blank_line: String, } impl Vfd { + /// Открывает serial-порт и инициализирует дисплей с таблицей CP866. pub fn open(cfg: VfdConfig) -> Result { let mut port = serialport::new(cfg.port_name, FIXED_BAUD) .timeout(cfg.timeout) @@ -55,61 +63,69 @@ impl Vfd { Ok(Self { port, width: cfg.width, - blank_line: " ".repeat(cfg.width), }) } + /// Очищает обе строки дисплея стандартной командой очистки. pub fn clear(&mut self) -> std::io::Result<()> { self.port.write_all(&[0x0C]) } + /// Полностью перезаписывает первую или вторую строку текстом фиксированной ширины. pub fn print_line(&mut self, line: u8, text: &str) -> std::io::Result<()> { if !(1..=2).contains(&line) { return Ok(()); } - // overwrite the whole line to avoid leftovers - self.goto_xy(1, line)?; - - // FIX: take owned copy so self isn't immutably borrowed during &mut self call - let blank = self.blank_line.clone(); - self.write_cp866(&blank)?; - + // Кадр фиксированной ширины сам перезаписывает остаток предыдущей строки. self.goto_xy(1, line)?; let fitted = fit_to_width(&sanitize_for_cp866(text), self.width); self.write_cp866(&fitted) } - /// Print already-prepared fixed-width frame at (1, line) without clearing. - /// Use this for marquee to avoid double-writes. + /// Выводит подготовленный кадр с начала строки без предварительной очистки. pub fn print_frame(&mut self, line: u8, frame: &str) -> std::io::Result<()> { if !(1..=2).contains(&line) { - // silently ignore invalid lines (or return an error if you want) + // Некорректный номер строки игнорируется так же, как в остальных методах печати. return Ok(()); } self.goto_xy(1, line)?; - // frame should already be width-sized; if not, fit it + // На случай короткого или длинного кадра приводим его к ширине дисплея. let fitted = fit_to_width(frame, self.width); self.write_cp866(&fitted) } - #[allow(dead_code)] + /// Печатает текст с координаты `(x, y)`, выполняя санацию и обрезку по правому краю. pub fn print_at(&mut self, x: u8, y: u8, text: &str) -> std::io::Result<()> { - self.goto_xy(x, y)?; - self.write_cp866(&sanitize_for_cp866(text)) + let text = sanitize_for_cp866(text); + self.print_at_prepared(x, y, &text) } + /// Печатает уже подготовленный текст без повторной санации. + pub(crate) fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> std::io::Result<()> { + if x == 0 || usize::from(x) > self.width || !(1..=2).contains(&y) { + return Ok(()); + } + + let remaining = self.width - usize::from(x) + 1; + let text = truncate_chars(text, remaining); + self.goto_xy(x, y)?; + self.write_cp866(text) + } + + /// Перемещает аппаратный курсор в координаты дисплея, заданные от единицы. fn goto_xy(&mut self, x: u8, y: u8) -> std::io::Result<()> { // US $ x y self.port.write_all(&[0x1F, 0x24, x, y]) } + /// Кодирует строку в CP866 и целиком записывает байты в serial-порт. fn write_cp866(&mut self, s: &str) -> std::io::Result<()> { let (bytes, _, _) = IBM866.encode(s); self.port.write_all(&bytes) } - /// Epson customer display: US X n (brightness), n=1..4 + /// Устанавливает яркость; значение ограничивается диапазоном `1..=4`. pub fn set_brightness(&mut self, n: u8) -> Result<()> { let n = n.clamp(1, 4); let cmd = [0x1F, 0x58, n]; // US 'X' n @@ -120,6 +136,14 @@ impl Vfd { } } +/// Возвращает срез не длиннее заданного числа символов, не разрывая UTF-8. +fn truncate_chars(s: &str, max_chars: usize) -> &str { + s.char_indices() + .nth(max_chars) + .map_or(s, |(byte_index, _)| &s[..byte_index]) +} + +/// Заменяет типографские символы на безопасные аналоги, представимые в CP866. pub fn sanitize_for_cp866(s: &str) -> String { s.chars() .map(|c| match c { @@ -134,11 +158,46 @@ pub fn sanitize_for_cp866(s: &str) -> String { .collect() } +/// Обрезает строку по числу символов и дополняет пробелами до заданной ширины. pub fn fit_to_width(s: &str, width: usize) -> String { - let mut out: String = s.chars().take(width).collect(); - let len = out.chars().count(); - if len < width { - out.push_str(&" ".repeat(width - len)); + let mut out = String::with_capacity(width); + let mut len = 0; + for ch in s.chars().take(width) { + out.push(ch); + len += 1; } + out.extend(std::iter::repeat_n(' ', width - len)); out } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_width_stays_within_protocol_coordinate_range() { + assert_eq!(VfdConfig::new("test").with_width(0).width, 1); + assert_eq!(VfdConfig::new("test").with_width(20).width, 20); + assert_eq!(VfdConfig::new("test").with_width(usize::MAX).width, 255); + } + + #[test] + fn sanitizes_typographic_characters_without_changing_regular_text() { + assert_eq!(sanitize_for_cp866("№1\t“тест”—‘да’…"), "#1 \"тест\"-'да'."); + assert_eq!(sanitize_for_cp866("обычный text"), "обычный text"); + } + + #[test] + fn fits_unicode_by_characters_and_pads_short_input() { + assert_eq!(fit_to_width("Привет", 4), "Прив"); + assert_eq!(fit_to_width("да", 4), "да "); + assert_eq!(fit_to_width("text", 0), ""); + } + + #[test] + fn truncates_without_splitting_utf8_characters() { + assert_eq!(truncate_chars("ёжик", 3), "ёжи"); + assert_eq!(truncate_chars("ёжик", 10), "ёжик"); + assert_eq!(truncate_chars("ёжик", 0), ""); + } +} diff --git a/src/worker.rs b/src/worker.rs index e707ddf..43c2b8d 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -9,6 +9,7 @@ use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; #[derive(Debug, Clone)] +/// Команда, передаваемая фоновому потоку дисплея. pub enum Cmd { Clear, PrintLine { @@ -49,21 +50,24 @@ pub enum Cmd { } #[derive(Clone)] +/// Клонируемый потокобезопасный интерфейс отправки команд дисплею. pub struct VfdHandle { tx: Sender, stop: Arc, } impl VfdHandle { + /// Ставит в очередь очистку дисплея. pub fn clear(&self) { let _ = self.tx.send(Cmd::Clear); } + /// Ставит в очередь изменение яркости. pub fn set_brightness(&self, level: u8) { let _ = self.tx.send(Cmd::SetBrightness { level }); } - #[allow(dead_code)] + /// Ставит в очередь полную перезапись строки. pub fn print_line(&self, line: u8, text: impl Into) -> anyhow::Result<()> { self.tx.send(Cmd::PrintLine { line, @@ -72,6 +76,7 @@ impl VfdHandle { Ok(()) } + /// Обновляет только изменившиеся диапазоны строки и уменьшает число serial-команд. pub fn print_line_diff(&self, line: u8, text: impl Into) -> anyhow::Result<()> { self.tx.send(Cmd::PrintLineDiff { line, @@ -80,6 +85,7 @@ impl VfdHandle { Ok(()) } + /// Ставит в очередь печать текста с координаты `(x, y)`. pub fn print_at(&self, x: u8, y: u8, text: impl Into) -> anyhow::Result<()> { self.tx.send(Cmd::PrintAt { x, @@ -89,10 +95,12 @@ impl VfdHandle { Ok(()) } + /// Заменяет текст, используемый бегущей строкой. pub fn set_marquee_text(&self, text: impl Into) { let _ = self.tx.send(Cmd::SetMarqueeText { text: text.into() }); } + /// Запускает бегущую строку на выбранной линии с заданной скоростью и паузой. pub fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) { let _ = self.tx.send(Cmd::StartMarquee { line, @@ -101,23 +109,28 @@ impl VfdHandle { }); } + /// Останавливает активную бегущую строку. pub fn stop_marquee(&self) { let _ = self.tx.send(Cmd::StopMarquee); } + /// Сигнализирует фоновому потоку о завершении работы. pub fn shutdown(&self) { self.stop.store(true, Ordering::Relaxed); let _ = self.tx.send(Cmd::Shutdown); } } +/// Владеет фоновым потоком записи и дожидается его завершения при удалении. pub struct VfdWorker { handle: VfdHandle, join: Option>, } impl VfdWorker { + /// Открывает дисплей и запускает поток последовательной обработки команд. pub fn start(cfg: VfdConfig) -> Result { + let vfd = Vfd::open(cfg)?; let (tx, rx) = mpsc::channel::(); let stop = Arc::new(AtomicBool::new(false)); @@ -127,7 +140,7 @@ impl VfdWorker { }; let join = thread::spawn(move || { - if let Err(e) = writer_loop(cfg, rx, stop) { + if let Err(e) = writer_loop(vfd, rx, stop) { eprintln!("[vfd] writer loop error: {e:#}"); } }); @@ -138,14 +151,16 @@ impl VfdWorker { }) } + /// Возвращает новый клон интерфейса отправки команд. pub fn handle(&self) -> VfdHandle { self.handle.clone() } } impl Drop for VfdWorker { + /// Корректно останавливает фоновый поток при выходе владельца из области видимости. fn drop(&mut self) { - // try to stop gracefully + // Сначала посылаем сигнал остановки, затем дожидаемся завершения потока. self.handle.shutdown(); if let Some(j) = self.join.take() { let _ = j.join(); @@ -169,6 +184,7 @@ struct MarqueeState { } impl MarqueeState { + /// Создаёт неактивное состояние бегущей строки со значениями по умолчанию. fn new() -> Self { Self { active: false, @@ -183,25 +199,67 @@ impl MarqueeState { } } + /// Перестраивает поток символов с пустыми полями до и после текста. fn rebuild_stream(&mut self, width: usize) { let text = sanitize_for_cp866(&self.text); self.stream.clear(); - self.stream.extend(std::iter::repeat(' ').take(width)); + self.stream.reserve(width * 2 + text.chars().count()); + self.stream.extend(std::iter::repeat_n(' ', width)); self.stream.extend(text.chars()); - self.stream.extend(std::iter::repeat(' ').take(width)); + self.stream.extend(std::iter::repeat_n(' ', width)); self.offset = 0; self.paused_until = None; self.last_step = Instant::now(); } + /// Рассчитывает ненулевой интервал между сдвигами из скорости в символах в секунду. fn step_interval(&self) -> Duration { - let cps = self.cps.max(1); - Duration::from_millis((1000 / cps) as u64) + let cps = u64::from(self.cps.max(1)); + Duration::from_nanos((1_000_000_000 / cps).max(1)) } } -fn writer_loop(cfg: VfdConfig, rx: Receiver, stop: Arc) -> Result<()> { - let mut vfd = Vfd::open(cfg)?; +/// Группирует соседние изменившиеся символы в минимальное число диапазонов записи. +fn changed_runs(current: &str, next: &str) -> Vec<(u8, String)> { + let mut runs = Vec::new(); + let mut run_start = None; + let mut run_text = String::new(); + + for (index, (old, new)) in current.chars().zip(next.chars()).enumerate() { + if old != new { + run_start.get_or_insert((index + 1) as u8); + run_text.push(new); + } else if let Some(start) = run_start.take() { + runs.push((start, std::mem::take(&mut run_text))); + } + } + + if let Some(start) = run_start { + runs.push((start, run_text)); + } + + runs +} + +/// Обновляет фрагмент кэшированной строки с учётом Unicode и правой границы. +fn replace_cached_range(line: &mut String, x: u8, text: &str, width: usize) { + if x == 0 || usize::from(x) > width || text.is_empty() { + return; + } + + let mut chars: Vec = line.chars().take(width).collect(); + chars.resize(width, ' '); + let start = usize::from(x) - 1; + for (slot, ch) in chars[start..].iter_mut().zip(text.chars()) { + *slot = ch; + } + + line.clear(); + line.extend(chars); +} + +/// Последовательно обрабатывает команды и по таймеру формирует кадры бегущей строки. +fn writer_loop(mut vfd: Vfd, rx: Receiver, stop: Arc) -> Result<()> { // optional initial clear: let _ = vfd.clear(); @@ -258,24 +316,15 @@ fn writer_loop(cfg: VfdConfig, rx: Receiver, stop: Arc) -> Resu continue; } - let mut buf = [0u8; 4]; - - for (i, (a, b)) in last_lines[idx].chars().zip(next.chars()).enumerate() { - if i >= width { - break; - } - if a != b { - let x = (i as u8) + 1; - let s = b.encode_utf8(&mut buf); - let _ = vfd.print_at(x, line, s); - } + for (x, text) in changed_runs(&last_lines[idx], &next) { + let _ = vfd.print_at_prepared(x, line, &text); } last_lines[idx] = next; } Cmd::PrintAt { x, y, text } => { // базовая валидация координат - if !(1..=width as u8).contains(&x) { + if x == 0 || usize::from(x) > width { continue; } @@ -288,39 +337,13 @@ fn writer_loop(cfg: VfdConfig, rx: Receiver, stop: Arc) -> Resu continue; } - let s = sanitize_for_cp866(&text); - let _ = vfd.print_at(x, y, &s); + let remaining = width - usize::from(x) + 1; + let text: String = + sanitize_for_cp866(&text).chars().take(remaining).collect(); + let _ = vfd.print_at_prepared(x, y, &text); - // Обновляем кеш после изменения - if (1..=2).contains(&y) { - let idx = (y - 1) as usize; - - // гарантируем fixed-width в кэше - if last_lines[idx].is_empty() { - last_lines[idx] = " ".repeat(width); - } - - // заменяем символ в позиции x (1-based) на первый символ s (после sanitize) - // (если строка пустая — просто игнор) - if let Some(ch) = s.chars().next() { - let pos = (x - 1) as usize; - let mut new_line = String::with_capacity(width); - - for (i, cur) in last_lines[idx].chars().enumerate() { - if i == pos { - new_line.push(ch); - } else { - new_line.push(cur); - } - if new_line.chars().count() >= width { - break; - } - } - - // если вдруг кэш был короче width — добиваем пробелами - last_lines[idx] = crate::vfd::fit_to_width(&new_line, width); - } - } + let idx = (y - 1) as usize; + replace_cached_range(&mut last_lines[idx], x, &text, width); } Cmd::SetMarqueeText { text } => { marquee.text = text; @@ -381,11 +404,10 @@ fn writer_loop(cfg: VfdConfig, rx: Receiver, stop: Arc) -> Resu let end = (start + width).min(marquee.stream.len()); let frame: String = marquee.stream[start..end].iter().collect(); - let _ = vfd.print_frame(marquee.line, &frame); + let _ = vfd.print_at_prepared(1, marquee.line, &frame); if (1..=2).contains(&marquee.line) { - last_lines[(marquee.line - 1) as usize] = - crate::vfd::fit_to_width(&frame, width); + last_lines[(marquee.line - 1) as usize] = frame; } if marquee.offset >= max_off { @@ -403,3 +425,43 @@ fn writer_loop(cfg: VfdConfig, rx: Receiver, stop: Arc) -> Resu Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cache_update_replaces_the_complete_fragment() { + let mut line = "Temp: 00.00C ".to_string(); + + replace_cached_range(&mut line, 7, "21.50", 20); + + assert_eq!(line, "Temp: 21.50C "); + } + + #[test] + fn cache_update_handles_unicode_and_clips_at_the_right_edge() { + let mut line = String::new(); + + replace_cached_range(&mut line, 3, "ёжик", 5); + + assert_eq!(line, " ёжи"); + } + + #[test] + fn diff_groups_adjacent_changes_into_minimal_runs() { + assert_eq!( + changed_runs("abcd efgh", "abXY eZZh"), + vec![(3, "XY".to_string()), (7, "ZZ".to_string())] + ); + assert!(changed_runs("без перемен", "без перемен").is_empty()); + } + + #[test] + fn marquee_interval_never_collapses_to_zero() { + let mut marquee = MarqueeState::new(); + marquee.cps = u32::MAX; + + assert!(!marquee.step_interval().is_zero()); + } +} diff --git a/taskfile.yml b/taskfile.yml index 7ebe28c..ac6ab01 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -8,9 +8,9 @@ tasks: vfd:clock: desc: Run VFD clock example cmds: - - cargo run --example clock -- {{.VFD_PORT}} {{.VFD_WIDTH}} {{.VFD_CPS | default "8"}} + - cargo run --example clock -- {{.VFD_PORT}} {{.VFD_WIDTH}} {{.VFD_BRIGHTNESS | default "2"}} vars: - VFD_CPS: "8" + VFD_BRIGHTNESS: "2" vfd:marquee: desc: Run VFD marquee example