рефакторинг библиотеки.

This commit is contained in:
Кобелев Андрей Андреевич
2026-08-15 23:53:45 +05:00
parent 896670b62f
commit efc53e6ef5
13 changed files with 584 additions and 371 deletions
Generated
+27 -28
View File
@@ -4,9 +4,9 @@ version = 4
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.100" version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
@@ -16,9 +16,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.10.0" version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
@@ -28,9 +28,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.0" version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [ dependencies = [
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
@@ -63,9 +63,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.180" version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]] [[package]]
name = "libudev" name = "libudev"
@@ -118,24 +118,24 @@ dependencies = [
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.32" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.40" version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@@ -148,11 +148,11 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "serialport" name = "serialport"
version = "4.8.1" version = "4.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21f60a586160667241d7702c420fc223939fb3c0bb8d3fac84f78768e8970dee" checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.13.1",
"cfg-if", "cfg-if",
"core-foundation", "core-foundation",
"core-foundation-sys", "core-foundation-sys",
@@ -160,7 +160,6 @@ dependencies = [
"libudev", "libudev",
"mach2", "mach2",
"nix", "nix",
"quote",
"scopeguard", "scopeguard",
"unescaper", "unescaper",
"windows-sys", "windows-sys",
@@ -168,9 +167,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.114" version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -179,18 +178,18 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.18" version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -199,18 +198,18 @@ dependencies = [
[[package]] [[package]]
name = "unescaper" name = "unescaper"
version = "0.1.8" version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4"
dependencies = [ dependencies = [
"thiserror", "thiserror",
] ]
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.22" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
+195 -83
View File
@@ -1,124 +1,236 @@
# VFD — библиотека управления VFD-дисплеями (Epson mode) # VFD — управление дисплеями в Epson-совместимом режиме
Небольшая Rust-библиотека для управления **VFD-дисплеями Epson/ESC-совместимого режима** Небольшая Rust-библиотека для двухстрочных VFD-дисплеев покупателя, работающих через
(например PD-2600 / PD-2800 и похожие). serial-порт в Epson/ESC-совместимом режиме. Проект ориентирован на дисплеи семейства
PD-2600/PD-2800 и похожие модели с поддержкой команд позиционирования, яркости и
таблицы символов CP866.
Подходит для: Библиотека подходит для часов, локальных дашбордов, кассовых приложений, уведомлений,
индикаторов состояния и других проектов, где данные нужно обновлять без мерцания всей
- домашних IoT-проектов строки.
- индикаторов состояния
- часов, дашбордов, уведомлений
- ретро-интерфейсов
Поддержка:
- печать текста по координатам
- обновление строк через diff (без мерцания)
- яркость дисплея
- программный marquee (бегущая строка)
- работа через serial (USB-UART)
---
## Возможности ## Возможности
- Двухстрочный дисплей (ширина настраивается) - вывод текста в первую или вторую строку;
- `print_line_diff` — обновление только изменённых символов - печать с координаты `(x, y)`;
- Управление яркостью (1–4) - обновление только изменившихся диапазонов строки через `print_line_diff`;
- Бегущая строка (marquee) в отдельном воркере - фоновая бегущая строка с регулируемой скоростью и паузой;
- Потокобезопасный API (worker + handle) - четыре уровня яркости;
- кириллица через 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 ```toml
[dependencies] [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::vfd::VfdConfig;
use m::worker::VfdWorker; 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; use std::time::Duration;
vfd.set_marquee_text(" Это пример бегущей строки "); fn main() -> Result<()> {
vfd.start_marquee( 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, // строка 2, // строка
8, // символов в секунду 8, // символов в секунду
Duration::from_millis(1500), // пауза в конце Duration::from_millis(1500), // пауза после полного прохода
); );
// При необходимости:
display.stop_marquee();
``` ```
Marquee работает в фоне и не мешает `print_line_diff`, Обычные команды записи в строку, занятую marquee, игнорируются, чтобы два источника
если не писать в ту же строку. не перезаписывали друг друга.
---
## Управление яркостью
```rust
vfd.set_brightness(1); // диапазон 1..4
```
Яркость можно менять динамически (ночной режим, «дыхание» и т.п.).
---
## Примеры ## Примеры
Запуск через `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 ```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 marquee -- /dev/cu.usbmodem101 20 8 1500 2
cargo run --example brightness -- /dev/cu.usbmodem101 20 800 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` — часы Те же программы доступны через [Task](https://taskfile.dev/):
- `marquee` — бегущая строка
- `brightness` — демонстрация яркости
--- ```bash
task vfd:clock
task vfd:marquee
task vfd:brightness
task vfd:position
task vfd:update_at
task vfd:blink
```
## Требования Значения можно переопределять переменными, например:
- Дисплей в **Epson-режиме** (DIP-переключатели) ```bash
- Скорость: 9600 baud task vfd:marquee VFD_PORT=/dev/ttyUSB0 VFD_WIDTH=20 VFD_CPS=10
- Кодировка: CP866 (кириллица) ```
--- ## Кодировка текста
## Идеи для использования Перед записью библиотека заменяет несколько типографских символов на совместимые
аналоги:
- часы / дата / температура - `…` → `.`;
- 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-порт автоматически.
+12 -28
View File
@@ -1,13 +1,11 @@
//! Демонстрирует мигание произвольного текста в выбранной позиции через `print_at`.
mod common;
use anyhow::Result; use anyhow::Result;
use std::time::Duration;
use m::vfd::VfdConfig;
use m::worker::VfdWorker; use m::worker::VfdWorker;
fn spaces(n: usize) -> String {
" ".repeat(n)
}
fn main() -> Result<()> { fn main() -> Result<()> {
// args: // args:
// 1) port (default: /dev/cu.usbmodem101) // 1) port (default: /dev/cu.usbmodem101)
@@ -16,28 +14,14 @@ fn main() -> Result<()> {
// 4) x (default: 1) 1-based // 4) x (default: 1) 1-based
// 5) y (default: 1) 1-based line // 5) y (default: 1) 1-based line
// 6) text (default: "BLINK") // 6) text (default: "BLINK")
let mut args = std::env::args().skip(1); let mut args = common::ExampleArgs::from_env();
let delay_ms: u64 = args.parse_or(400);
let port_name = args.next().unwrap_or("/dev/cu.usbmodem101".into()); let x: u8 = args.parse_or(1);
let y: u8 = args.parse_or(1);
let width: usize = args.next().as_deref().unwrap_or("20").parse().unwrap_or(20); let text = args.string_or("BLINK");
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 text_len = text.chars().count(); let text_len = text.chars().count();
let cfg = VfdConfig::new(port_name).with_width(width); let worker = VfdWorker::start(args.config)?;
let worker = VfdWorker::start(cfg)?;
let vfd = worker.handle(); let vfd = worker.handle();
vfd.clear(); vfd.clear();
@@ -47,7 +31,7 @@ fn main() -> Result<()> {
let _ = vfd.print_at(1, y, ""); // просто чтобы “активировать” строку у некоторых дисплеев let _ = vfd.print_at(1, y, ""); // просто чтобы “активировать” строку у некоторых дисплеев
} }
let blank = spaces(text_len); let blank = " ".repeat(text_len);
let mut on = false; let mut on = false;
loop { loop {
@@ -57,6 +41,6 @@ fn main() -> Result<()> {
let _ = vfd.print_at(x, y, &text); let _ = vfd.print_at(x, y, &text);
} }
on = !on; on = !on;
std::thread::sleep(Duration::from_millis(delay_ms)); common::sleep_ms(delay_ms);
} }
} }
+8 -23
View File
@@ -1,7 +1,9 @@
//! Демонстрирует четыре уровня яркости и обновление строки без полного мерцания.
mod common;
use anyhow::Result; use anyhow::Result;
use std::time::Duration;
use m::vfd::VfdConfig;
use m::worker::VfdWorker; use m::worker::VfdWorker;
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -9,26 +11,9 @@ fn main() -> Result<()> {
// 1) port (default: /dev/cu.usbmodem101) // 1) port (default: /dev/cu.usbmodem101)
// 2) width (default: 20) // 2) width (default: 20)
// 3) delay_ms (default: 800) // 3) delay_ms (default: 800)
let port_name = std::env::args() let mut args = common::ExampleArgs::from_env();
.nth(1) let delay_ms: u64 = args.parse_or(800);
.unwrap_or("/dev/cu.usbmodem101".into()); let worker = VfdWorker::start(args.config)?;
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 vfd = worker.handle(); let vfd = worker.handle();
vfd.clear(); vfd.clear();
@@ -47,7 +32,7 @@ fn main() -> Result<()> {
let _ = vfd.print_line_diff(2, line2); let _ = vfd.print_line_diff(2, line2);
std::thread::sleep(Duration::from_millis(delay_ms)); common::sleep_ms(delay_ms);
} }
} }
} }
+44 -44
View File
@@ -1,8 +1,11 @@
// examples/clock_ru.rs //! Показывает локальные дату, время, день недели и часть суток с обновлением раз в секунду.
mod common;
use anyhow::Result; use anyhow::Result;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use m::vfd::VfdConfig; use m::vfd::fit_to_width;
use m::worker::VfdWorker; use m::worker::VfdWorker;
fn run_date(args: &[&str]) -> Option<String> { fn run_date(args: &[&str]) -> Option<String> {
@@ -16,16 +19,19 @@ fn run_date(args: &[&str]) -> Option<String> {
Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
} }
fn local_datetime_ddmmyyyy_hhmmss() -> String { fn parse_datetime_and_weekday(value: &str) -> Option<(String, u8)> {
// 28.01.2006 12:03:34 let (datetime, weekday) = value.split_once('|')?;
run_date(&["+%d.%m.%Y %H:%M:%S"]).unwrap_or_else(|| "??.??.???? ??:??:??".to_string()) let weekday = weekday.parse::<u8>().ok()?;
(1..=7)
.contains(&weekday)
.then(|| (datetime.to_string(), weekday))
} }
fn local_weekday_num_1_7() -> u8 { fn local_datetime_and_weekday() -> (String, u8) {
// 1..7 (Mon..Sun) run_date(&["+%d.%m.%Y %H:%M:%S|%u"])
run_date(&["+%u"]) .as_deref()
.and_then(|s| s.parse::<u8>().ok()) .and_then(parse_datetime_and_weekday)
.unwrap_or(1) .unwrap_or_else(|| ("??.??.???? ??:??:??".to_string(), 1))
} }
fn weekday_ru_full(n: u8) -> &'static str { fn weekday_ru_full(n: u8) -> &'static str {
@@ -74,51 +80,22 @@ fn extract_hour(datetime: &str) -> u8 {
.unwrap_or(12) .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<()> { fn main() -> Result<()> {
// args: // args:
// 1) port (default: /dev/cu.usbmodem101) // 1) port (default: /dev/cu.usbmodem101)
// 2) width (default: 20) // 2) width (default: 20)
// 3) brightness 1..4 (default: 2) // 3) brightness 1..4 (default: 2)
let port_name = std::env::args() let mut args = common::ExampleArgs::from_env();
.nth(1) let brightness: u8 = args.parse_or(2);
.unwrap_or("/dev/cu.usbmodem101".into()); let width = args.width;
let worker = VfdWorker::start(args.config)?;
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 vfd = worker.handle(); let vfd = worker.handle();
vfd.clear(); vfd.clear();
vfd.set_brightness(brightness); vfd.set_brightness(brightness);
loop { loop {
let dt = local_datetime_ddmmyyyy_hhmmss(); let (dt, wd) = local_datetime_and_weekday();
let wd = local_weekday_num_1_7();
let hour = extract_hour(&dt); let hour = extract_hour(&dt);
let tod = time_of_day_ru(hour); let tod = time_of_day_ru(hour);
@@ -148,3 +125,26 @@ fn main() -> Result<()> {
std::thread::sleep(Duration::from_millis(ms_to_next)); 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), "ночь");
}
}
+59
View File
@@ -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<String>,
}
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::<Vec<_>>().into_iter(),
}
}
/// Читает следующий аргумент нужного типа или возвращает переданное значение по умолчанию.
pub fn parse_or<T>(&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));
}
+10 -36
View File
@@ -1,7 +1,10 @@
//! Демонстрирует фоновую бегущую строку с регулируемой скоростью и паузой в конце.
mod common;
use anyhow::Result; use anyhow::Result;
use std::time::Duration; use std::time::Duration;
use m::vfd::VfdConfig;
use m::worker::VfdWorker; use m::worker::VfdWorker;
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -11,40 +14,11 @@ fn main() -> Result<()> {
// 3) cps (chars per second, default: 8) // 3) cps (chars per second, default: 8)
// 4) end_pause_ms (default: 1500) // 4) end_pause_ms (default: 1500)
// 5) brightness 1..4 (default: 2) // 5) brightness 1..4 (default: 2)
let port_name = std::env::args() let mut args = common::ExampleArgs::from_env();
.nth(1) let cps: u32 = args.parse_or(8);
.unwrap_or("/dev/cu.usbmodem101".into()); let end_pause_ms: u64 = args.parse_or(1500);
let brightness: u8 = args.parse_or(2);
let width: usize = std::env::args() let worker = VfdWorker::start(args.config)?;
.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 vfd = worker.handle(); let vfd = worker.handle();
vfd.clear(); vfd.clear();
@@ -61,6 +35,6 @@ fn main() -> Result<()> {
let _ = vfd.print_line_diff(1, "Бегущая строка"); let _ = vfd.print_line_diff(1, "Бегущая строка");
loop { loop {
std::thread::sleep(Duration::from_secs(3600)); common::sleep_ms(3_600_000);
} }
} }
+9 -23
View File
@@ -1,7 +1,9 @@
//! Демонстрирует позиционирование символа и его движение по второй строке дисплея.
mod common;
use anyhow::Result; use anyhow::Result;
use std::time::Duration;
use m::vfd::VfdConfig;
use m::worker::VfdWorker; use m::worker::VfdWorker;
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -9,26 +11,10 @@ fn main() -> Result<()> {
// 1) port (default: /dev/cu.usbmodem101) // 1) port (default: /dev/cu.usbmodem101)
// 2) width (default: 20) // 2) width (default: 20)
// 3) delay_ms (default: 80) // 3) delay_ms (default: 80)
let port_name = std::env::args() let mut args = common::ExampleArgs::from_env();
.nth(1) let delay_ms: u64 = args.parse_or(80);
.unwrap_or("/dev/cu.usbmodem101".into()); let width = args.width;
let worker = VfdWorker::start(args.config)?;
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 vfd = worker.handle(); let vfd = worker.handle();
vfd.clear(); vfd.clear();
@@ -69,6 +55,6 @@ fn main() -> Result<()> {
} }
} }
std::thread::sleep(Duration::from_millis(delay_ms)); common::sleep_ms(delay_ms);
} }
} }
+12 -23
View File
@@ -1,7 +1,9 @@
//! Имитирует компактный дашборд и обновляет только изменяемые поля через `print_at`.
mod common;
use anyhow::Result; use anyhow::Result;
use std::time::Duration;
use m::vfd::VfdConfig;
use m::worker::VfdWorker; use m::worker::VfdWorker;
fn pad_left(s: &str, width: usize) -> String { fn pad_left(s: &str, width: usize) -> String {
@@ -18,26 +20,13 @@ fn main() -> Result<()> {
// 1) port (default: /dev/cu.usbmodem101) // 1) port (default: /dev/cu.usbmodem101)
// 2) width (default: 20) // 2) width (default: 20)
// 3) delay_ms (default: 200) // 3) delay_ms (default: 200)
let port_name = std::env::args() let mut args = common::ExampleArgs::from_env();
.nth(1) let delay_ms: u64 = args.parse_or(200);
.unwrap_or("/dev/cu.usbmodem101".into()); let width = args.width;
if width < 20 {
let width: usize = std::env::args() anyhow::bail!("update_at requires a display width of at least 20 columns");
.nth(2) }
.as_deref() let worker = VfdWorker::start(args.config)?;
.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 vfd = worker.handle(); let vfd = worker.handle();
vfd.clear(); vfd.clear();
@@ -103,6 +92,6 @@ fn main() -> Result<()> {
si = (si + 1) % spinner.len(); si = (si + 1) % spinner.len();
} }
std::thread::sleep(Duration::from_millis(delay_ms)); common::sleep_ms(delay_ms);
} }
} }
+4
View File
@@ -1,2 +1,6 @@
//! Библиотека управления двухстрочными VFD-дисплеями в Epson-совместимом режиме.
/// Низкоуровневая работа с serial-портом, кодировкой и командами дисплея.
pub mod vfd; pub mod vfd;
/// Фоновый поток и потокобезопасный интерфейс для обновления дисплея.
pub mod worker; pub mod worker;
+82 -23
View File
@@ -6,15 +6,21 @@ use std::time::Duration;
const TABLE_CYR: u8 = 6; // PD-2600: your Cyrillic table (ESC t 6) const TABLE_CYR: u8 = 6; // PD-2600: your Cyrillic table (ESC t 6)
const FIXED_BAUD: u32 = 9600; const FIXED_BAUD: u32 = 9600;
const MAX_WIDTH: usize = u8::MAX as usize;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
/// Настройки serial-подключения и геометрии VFD-дисплея.
pub struct VfdConfig { pub struct VfdConfig {
/// Имя serial-порта, например `/dev/cu.usbmodem101`.
pub port_name: String, pub port_name: String,
/// Число символов в строке; ограничивается диапазоном `1..=255`.
pub width: usize, pub width: usize,
/// Тайм-аут операций чтения и записи serial-порта.
pub timeout: Duration, pub timeout: Duration,
} }
impl VfdConfig { impl VfdConfig {
/// Создаёт конфигурацию с шириной 20 символов и тайм-аутом 100 мс.
pub fn new(port_name: impl Into<String>) -> Self { pub fn new(port_name: impl Into<String>) -> Self {
Self { Self {
port_name: port_name.into(), port_name: port_name.into(),
@@ -23,25 +29,27 @@ impl VfdConfig {
} }
} }
/// Устанавливает ширину строки с учётом диапазона координат протокола.
pub fn with_width(mut self, width: usize) -> Self { pub fn with_width(mut self, width: usize) -> Self {
self.width = width.max(1); self.width = width.clamp(1, MAX_WIDTH);
self self
} }
#[allow(dead_code)] /// Устанавливает тайм-аут операций serial-порта.
pub fn with_timeout(mut self, timeout: Duration) -> Self { pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout; self.timeout = timeout;
self self
} }
} }
/// Низкоуровневое соединение с дисплеем, владеющее serial-портом.
pub struct Vfd { pub struct Vfd {
port: Box<dyn SerialPort>, port: Box<dyn SerialPort>,
pub width: usize, pub width: usize,
blank_line: String,
} }
impl Vfd { impl Vfd {
/// Открывает serial-порт и инициализирует дисплей с таблицей CP866.
pub fn open(cfg: VfdConfig) -> Result<Self> { pub fn open(cfg: VfdConfig) -> Result<Self> {
let mut port = serialport::new(cfg.port_name, FIXED_BAUD) let mut port = serialport::new(cfg.port_name, FIXED_BAUD)
.timeout(cfg.timeout) .timeout(cfg.timeout)
@@ -55,61 +63,69 @@ impl Vfd {
Ok(Self { Ok(Self {
port, port,
width: cfg.width, width: cfg.width,
blank_line: " ".repeat(cfg.width),
}) })
} }
/// Очищает обе строки дисплея стандартной командой очистки.
pub fn clear(&mut self) -> std::io::Result<()> { pub fn clear(&mut self) -> std::io::Result<()> {
self.port.write_all(&[0x0C]) self.port.write_all(&[0x0C])
} }
/// Полностью перезаписывает первую или вторую строку текстом фиксированной ширины.
pub fn print_line(&mut self, line: u8, text: &str) -> std::io::Result<()> { pub fn print_line(&mut self, line: u8, text: &str) -> std::io::Result<()> {
if !(1..=2).contains(&line) { if !(1..=2).contains(&line) {
return Ok(()); 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)?; self.goto_xy(1, line)?;
let fitted = fit_to_width(&sanitize_for_cp866(text), self.width); let fitted = fit_to_width(&sanitize_for_cp866(text), self.width);
self.write_cp866(&fitted) 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<()> { pub fn print_frame(&mut self, line: u8, frame: &str) -> std::io::Result<()> {
if !(1..=2).contains(&line) { if !(1..=2).contains(&line) {
// silently ignore invalid lines (or return an error if you want) // Некорректный номер строки игнорируется так же, как в остальных методах печати.
return Ok(()); return Ok(());
} }
self.goto_xy(1, line)?; self.goto_xy(1, line)?;
// frame should already be width-sized; if not, fit it // На случай короткого или длинного кадра приводим его к ширине дисплея.
let fitted = fit_to_width(frame, self.width); let fitted = fit_to_width(frame, self.width);
self.write_cp866(&fitted) self.write_cp866(&fitted)
} }
#[allow(dead_code)] /// Печатает текст с координаты `(x, y)`, выполняя санацию и обрезку по правому краю.
pub fn print_at(&mut self, x: u8, y: u8, text: &str) -> std::io::Result<()> { pub fn print_at(&mut self, x: u8, y: u8, text: &str) -> std::io::Result<()> {
self.goto_xy(x, y)?; let text = sanitize_for_cp866(text);
self.write_cp866(&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<()> { fn goto_xy(&mut self, x: u8, y: u8) -> std::io::Result<()> {
// US $ x y // US $ x y
self.port.write_all(&[0x1F, 0x24, x, y]) self.port.write_all(&[0x1F, 0x24, x, y])
} }
/// Кодирует строку в CP866 и целиком записывает байты в serial-порт.
fn write_cp866(&mut self, s: &str) -> std::io::Result<()> { fn write_cp866(&mut self, s: &str) -> std::io::Result<()> {
let (bytes, _, _) = IBM866.encode(s); let (bytes, _, _) = IBM866.encode(s);
self.port.write_all(&bytes) 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<()> { pub fn set_brightness(&mut self, n: u8) -> Result<()> {
let n = n.clamp(1, 4); let n = n.clamp(1, 4);
let cmd = [0x1F, 0x58, n]; // US 'X' n 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 { pub fn sanitize_for_cp866(s: &str) -> String {
s.chars() s.chars()
.map(|c| match c { .map(|c| match c {
@@ -134,11 +158,46 @@ pub fn sanitize_for_cp866(s: &str) -> String {
.collect() .collect()
} }
/// Обрезает строку по числу символов и дополняет пробелами до заданной ширины.
pub fn fit_to_width(s: &str, width: usize) -> String { pub fn fit_to_width(s: &str, width: usize) -> String {
let mut out: String = s.chars().take(width).collect(); let mut out = String::with_capacity(width);
let len = out.chars().count(); let mut len = 0;
if len < width { for ch in s.chars().take(width) {
out.push_str(&" ".repeat(width - len)); out.push(ch);
len += 1;
} }
out.extend(std::iter::repeat_n(' ', width - len));
out 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), "");
}
}
+117 -55
View File
@@ -9,6 +9,7 @@ use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
/// Команда, передаваемая фоновому потоку дисплея.
pub enum Cmd { pub enum Cmd {
Clear, Clear,
PrintLine { PrintLine {
@@ -49,21 +50,24 @@ pub enum Cmd {
} }
#[derive(Clone)] #[derive(Clone)]
/// Клонируемый потокобезопасный интерфейс отправки команд дисплею.
pub struct VfdHandle { pub struct VfdHandle {
tx: Sender<Cmd>, tx: Sender<Cmd>,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
} }
impl VfdHandle { impl VfdHandle {
/// Ставит в очередь очистку дисплея.
pub fn clear(&self) { pub fn clear(&self) {
let _ = self.tx.send(Cmd::Clear); let _ = self.tx.send(Cmd::Clear);
} }
/// Ставит в очередь изменение яркости.
pub fn set_brightness(&self, level: u8) { pub fn set_brightness(&self, level: u8) {
let _ = self.tx.send(Cmd::SetBrightness { level }); let _ = self.tx.send(Cmd::SetBrightness { level });
} }
#[allow(dead_code)] /// Ставит в очередь полную перезапись строки.
pub fn print_line(&self, line: u8, text: impl Into<String>) -> anyhow::Result<()> { pub fn print_line(&self, line: u8, text: impl Into<String>) -> anyhow::Result<()> {
self.tx.send(Cmd::PrintLine { self.tx.send(Cmd::PrintLine {
line, line,
@@ -72,6 +76,7 @@ impl VfdHandle {
Ok(()) Ok(())
} }
/// Обновляет только изменившиеся диапазоны строки и уменьшает число serial-команд.
pub fn print_line_diff(&self, line: u8, text: impl Into<String>) -> anyhow::Result<()> { pub fn print_line_diff(&self, line: u8, text: impl Into<String>) -> anyhow::Result<()> {
self.tx.send(Cmd::PrintLineDiff { self.tx.send(Cmd::PrintLineDiff {
line, line,
@@ -80,6 +85,7 @@ impl VfdHandle {
Ok(()) Ok(())
} }
/// Ставит в очередь печать текста с координаты `(x, y)`.
pub fn print_at(&self, x: u8, y: u8, text: impl Into<String>) -> anyhow::Result<()> { pub fn print_at(&self, x: u8, y: u8, text: impl Into<String>) -> anyhow::Result<()> {
self.tx.send(Cmd::PrintAt { self.tx.send(Cmd::PrintAt {
x, x,
@@ -89,10 +95,12 @@ impl VfdHandle {
Ok(()) Ok(())
} }
/// Заменяет текст, используемый бегущей строкой.
pub fn set_marquee_text(&self, text: impl Into<String>) { pub fn set_marquee_text(&self, text: impl Into<String>) {
let _ = self.tx.send(Cmd::SetMarqueeText { text: text.into() }); let _ = self.tx.send(Cmd::SetMarqueeText { text: text.into() });
} }
/// Запускает бегущую строку на выбранной линии с заданной скоростью и паузой.
pub fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) { pub fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) {
let _ = self.tx.send(Cmd::StartMarquee { let _ = self.tx.send(Cmd::StartMarquee {
line, line,
@@ -101,23 +109,28 @@ impl VfdHandle {
}); });
} }
/// Останавливает активную бегущую строку.
pub fn stop_marquee(&self) { pub fn stop_marquee(&self) {
let _ = self.tx.send(Cmd::StopMarquee); let _ = self.tx.send(Cmd::StopMarquee);
} }
/// Сигнализирует фоновому потоку о завершении работы.
pub fn shutdown(&self) { pub fn shutdown(&self) {
self.stop.store(true, Ordering::Relaxed); self.stop.store(true, Ordering::Relaxed);
let _ = self.tx.send(Cmd::Shutdown); let _ = self.tx.send(Cmd::Shutdown);
} }
} }
/// Владеет фоновым потоком записи и дожидается его завершения при удалении.
pub struct VfdWorker { pub struct VfdWorker {
handle: VfdHandle, handle: VfdHandle,
join: Option<JoinHandle<()>>, join: Option<JoinHandle<()>>,
} }
impl VfdWorker { impl VfdWorker {
/// Открывает дисплей и запускает поток последовательной обработки команд.
pub fn start(cfg: VfdConfig) -> Result<Self> { pub fn start(cfg: VfdConfig) -> Result<Self> {
let vfd = Vfd::open(cfg)?;
let (tx, rx) = mpsc::channel::<Cmd>(); let (tx, rx) = mpsc::channel::<Cmd>();
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
@@ -127,7 +140,7 @@ impl VfdWorker {
}; };
let join = thread::spawn(move || { 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:#}"); eprintln!("[vfd] writer loop error: {e:#}");
} }
}); });
@@ -138,14 +151,16 @@ impl VfdWorker {
}) })
} }
/// Возвращает новый клон интерфейса отправки команд.
pub fn handle(&self) -> VfdHandle { pub fn handle(&self) -> VfdHandle {
self.handle.clone() self.handle.clone()
} }
} }
impl Drop for VfdWorker { impl Drop for VfdWorker {
/// Корректно останавливает фоновый поток при выходе владельца из области видимости.
fn drop(&mut self) { fn drop(&mut self) {
// try to stop gracefully // Сначала посылаем сигнал остановки, затем дожидаемся завершения потока.
self.handle.shutdown(); self.handle.shutdown();
if let Some(j) = self.join.take() { if let Some(j) = self.join.take() {
let _ = j.join(); let _ = j.join();
@@ -169,6 +184,7 @@ struct MarqueeState {
} }
impl MarqueeState { impl MarqueeState {
/// Создаёт неактивное состояние бегущей строки со значениями по умолчанию.
fn new() -> Self { fn new() -> Self {
Self { Self {
active: false, active: false,
@@ -183,25 +199,67 @@ impl MarqueeState {
} }
} }
/// Перестраивает поток символов с пустыми полями до и после текста.
fn rebuild_stream(&mut self, width: usize) { fn rebuild_stream(&mut self, width: usize) {
let text = sanitize_for_cp866(&self.text); let text = sanitize_for_cp866(&self.text);
self.stream.clear(); 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(text.chars());
self.stream.extend(std::iter::repeat(' ').take(width)); self.stream.extend(std::iter::repeat_n(' ', width));
self.offset = 0; self.offset = 0;
self.paused_until = None; self.paused_until = None;
self.last_step = Instant::now(); self.last_step = Instant::now();
} }
/// Рассчитывает ненулевой интервал между сдвигами из скорости в символах в секунду.
fn step_interval(&self) -> Duration { fn step_interval(&self) -> Duration {
let cps = self.cps.max(1); let cps = u64::from(self.cps.max(1));
Duration::from_millis((1000 / cps) as u64) Duration::from_nanos((1_000_000_000 / cps).max(1))
} }
} }
fn writer_loop(cfg: VfdConfig, rx: Receiver<Cmd>, stop: Arc<AtomicBool>) -> 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<char> = 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<Cmd>, stop: Arc<AtomicBool>) -> Result<()> {
// optional initial clear: // optional initial clear:
let _ = vfd.clear(); let _ = vfd.clear();
@@ -258,24 +316,15 @@ fn writer_loop(cfg: VfdConfig, rx: Receiver<Cmd>, stop: Arc<AtomicBool>) -> Resu
continue; continue;
} }
let mut buf = [0u8; 4]; for (x, text) in changed_runs(&last_lines[idx], &next) {
let _ = vfd.print_at_prepared(x, line, &text);
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);
}
} }
last_lines[idx] = next; last_lines[idx] = next;
} }
Cmd::PrintAt { x, y, text } => { Cmd::PrintAt { x, y, text } => {
// базовая валидация координат // базовая валидация координат
if !(1..=width as u8).contains(&x) { if x == 0 || usize::from(x) > width {
continue; continue;
} }
@@ -288,39 +337,13 @@ fn writer_loop(cfg: VfdConfig, rx: Receiver<Cmd>, stop: Arc<AtomicBool>) -> Resu
continue; continue;
} }
let s = sanitize_for_cp866(&text); let remaining = width - usize::from(x) + 1;
let _ = vfd.print_at(x, y, &s); 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; let idx = (y - 1) as usize;
replace_cached_range(&mut last_lines[idx], x, &text, width);
// гарантируем 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);
}
}
} }
Cmd::SetMarqueeText { text } => { Cmd::SetMarqueeText { text } => {
marquee.text = text; marquee.text = text;
@@ -381,11 +404,10 @@ fn writer_loop(cfg: VfdConfig, rx: Receiver<Cmd>, stop: Arc<AtomicBool>) -> Resu
let end = (start + width).min(marquee.stream.len()); let end = (start + width).min(marquee.stream.len());
let frame: String = marquee.stream[start..end].iter().collect(); 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) { if (1..=2).contains(&marquee.line) {
last_lines[(marquee.line - 1) as usize] = last_lines[(marquee.line - 1) as usize] = frame;
crate::vfd::fit_to_width(&frame, width);
} }
if marquee.offset >= max_off { if marquee.offset >= max_off {
@@ -403,3 +425,43 @@ fn writer_loop(cfg: VfdConfig, rx: Receiver<Cmd>, stop: Arc<AtomicBool>) -> Resu
Ok(()) 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());
}
}
+2 -2
View File
@@ -8,9 +8,9 @@ tasks:
vfd:clock: vfd:clock:
desc: Run VFD clock example desc: Run VFD clock example
cmds: 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: vars:
VFD_CPS: "8" VFD_BRIGHTNESS: "2"
vfd:marquee: vfd:marquee:
desc: Run VFD marquee example desc: Run VFD marquee example