This commit is contained in:
Кобелев Андрей Андреевич
2026-01-28 01:36:35 +05:00
commit 446a710e01
11 changed files with 1191 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
.DS_Store

286
Cargo.lock generated Normal file
View File

@@ -0,0 +1,286 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "core-foundation"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
"mach2",
]
[[package]]
name = "libc"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libudev"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
dependencies = [
"libc",
"libudev-sys",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "m"
version = "0.1.0"
dependencies = [
"anyhow",
"encoding_rs",
"serialport",
]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "nix"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serialport"
version = "4.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21f60a586160667241d7702c420fc223939fb3c0bb8d3fac84f78768e8970dee"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"core-foundation",
"core-foundation-sys",
"io-kit-sys",
"libudev",
"mach2",
"nix",
"quote",
"scopeguard",
"unescaper",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unescaper"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e"
dependencies = [
"thiserror",
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"

9
Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[package]
name = "m"
version = "0.1.0"
edition = "2024"
[dependencies]
serialport = "4"
anyhow = "1"
encoding_rs = "0.8"

53
examples/brightness.rs Normal file
View File

@@ -0,0 +1,53 @@
use anyhow::Result;
use std::time::Duration;
use m::vfd::VfdConfig;
use m::worker::VfdWorker;
fn main() -> Result<()> {
// args:
// 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 vfd = worker.handle();
vfd.clear();
// шапка один раз (не обязательно, но удобно)
let _ = vfd.print_line_diff(1, "Яркость");
loop {
for level in 1u8..=4u8 {
vfd.set_brightness(level);
// обновляем строку медленно, через print_line_diff
// можно сделать “индикатор” уровня (*****)
let bar = "*".repeat(level as usize);
let line2 = format!("Уровень: {} {}", level, bar);
let _ = vfd.print_line_diff(2, line2);
std::thread::sleep(Duration::from_millis(delay_ms));
}
}
}

150
examples/clock.rs Normal file
View File

@@ -0,0 +1,150 @@
// examples/clock_ru.rs
use anyhow::Result;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use m::vfd::VfdConfig;
use m::worker::VfdWorker;
fn run_date(args: &[&str]) -> Option<String> {
let out = std::process::Command::new("date")
.args(args)
.output()
.ok()?;
if !out.status.success() {
return None;
}
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 local_weekday_num_1_7() -> u8 {
// 1..7 (Mon..Sun)
run_date(&["+%u"])
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(1)
}
fn weekday_ru_full(n: u8) -> &'static str {
match n {
1 => "Понедельник",
2 => "Вторник",
3 => "Среда",
4 => "Четверг",
5 => "Пятница",
6 => "Суббота",
7 => "Воскресенье",
_ => "Понедельник",
}
}
fn weekday_ru_short(n: u8) -> &'static str {
match n {
1 => "Пн",
2 => "Вт",
3 => "Ср",
4 => "Чт",
5 => "Пт",
6 => "Сб",
7 => "Вс",
_ => "Пн",
}
}
fn time_of_day_ru(hour: u8) -> &'static str {
match hour {
5..=10 => "утро",
11..=16 => "день",
17..=22 => "вечер",
_ => "ночь",
}
}
fn extract_hour(datetime: &str) -> u8 {
// ожидаем "DD.MM.YYYY HH:MM:SS"
// берём HH как 2 символа после пробела
datetime
.split_whitespace()
.nth(1)
.and_then(|t| t.get(0..2))
.and_then(|hh| hh.parse::<u8>().ok())
.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 vfd = worker.handle();
vfd.clear();
vfd.set_brightness(brightness);
loop {
let dt = local_datetime_ddmmyyyy_hhmmss();
let wd = local_weekday_num_1_7();
let hour = extract_hour(&dt);
let tod = time_of_day_ru(hour);
// 1 строка: "28.01.2006 12:03:34"
let line1 = fit_to_width(&dt, width);
// 2 строка: "Понедельник сейчас день"
// но если не влазит — "Пн сейчас день"
let full = format!("{} сейчас {}", weekday_ru_full(wd), tod);
let mut line2 = full;
if line2.chars().count() > width {
line2 = format!("{} сейчас {}", weekday_ru_short(wd), tod);
}
line2 = fit_to_width(&line2, width);
vfd.print_line_diff(1, line1)?;
vfd.print_line_diff(2, line2)?;
// чтобы обновлялось ровно раз в секунду (плюс/минус), можно “привязать” к UNIX time
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let ms_to_next = 1000 - (now % 1000) as u64;
std::thread::sleep(Duration::from_millis(ms_to_next));
}
}

66
examples/marquee.rs Normal file
View File

@@ -0,0 +1,66 @@
use anyhow::Result;
use std::time::Duration;
use m::vfd::VfdConfig;
use m::worker::VfdWorker;
fn main() -> Result<()> {
// args:
// 1) port (default: /dev/cu.usbmodem101)
// 2) width (default: 20)
// 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 vfd = worker.handle();
vfd.clear();
vfd.set_brightness(brightness);
let text = String::from(
"Однозначно, базовые сценарии поведения пользователей призывают нас к новым свершениям, которые, в свою очередь, должны быть объявлены нарушающими общечеловеческие нормы этики и морали. С другой стороны, понимание сути ресурсосберегающих технологий не даёт нам иного выбора, кроме определения экономической целесообразности принимаемых решений. Задача организации, в особенности же укрепление и развитие внутренней структуры обеспечивает актуальность соответствующих условий активизации. Предварительные выводы неутешительны: укрепление и развитие внутренней структуры однозначно фиксирует необходимость укрепления моральных ценностей. Высокий уровень вовлечения представителей целевой аудитории является четким доказательством простого факта: повышение уровня гражданского сознания представляет собой интересный эксперимент проверки поэтапного и последовательного развития общества.",
);
// “рыба” + бесконечный цикл
vfd.set_marquee_text(text);
vfd.start_marquee(2, cps, Duration::from_millis(end_pause_ms));
// можно подсветить вторую строку статикой
let _ = vfd.print_line_diff(1, "Бегущая строка");
loop {
std::thread::sleep(Duration::from_secs(3600));
}
}

2
src/lib.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod vfd;
pub mod worker;

45
src/main.rs Normal file
View File

@@ -0,0 +1,45 @@
use anyhow::Result;
use m::vfd::VfdConfig;
use m::worker::VfdWorker;
use std::time::Duration;
fn main() -> Result<()> {
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 cfg = VfdConfig::new(port_name).with_width(width);
let worker = VfdWorker::start(cfg)?;
let vfd = worker.handle();
// === бизнес-логика (демо) ===
vfd.clear();
vfd.set_brightness(0);
vfd.set_marquee_text(" Вставай самурай, у нас город в огне! Пора на работу!");
vfd.start_marquee(1, 8, Duration::from_millis(1500));
{
std::thread::sleep(Duration::from_millis(800));
vfd.set_brightness(1);
std::thread::sleep(Duration::from_millis(800));
vfd.set_brightness(2);
std::thread::sleep(Duration::from_millis(800));
vfd.set_brightness(3);
std::thread::sleep(Duration::from_millis(800));
vfd.set_brightness(4);
}
// останавливаем
std::thread::sleep(Duration::from_secs(30));
vfd.stop_marquee();
// graceful shutdown (или просто выйти — Drop у VfdWorker сделает shutdown+join)
vfd.shutdown();
Ok(())
}

144
src/vfd.rs Normal file
View File

@@ -0,0 +1,144 @@
use anyhow::Result;
use encoding_rs::IBM866;
use serialport::SerialPort;
use std::io::Write;
use std::time::Duration;
const TABLE_CYR: u8 = 6; // PD-2600: your Cyrillic table (ESC t 6)
const FIXED_BAUD: u32 = 9600;
#[derive(Debug, Clone)]
pub struct VfdConfig {
pub port_name: String,
pub width: usize,
pub timeout: Duration,
}
impl VfdConfig {
pub fn new(port_name: impl Into<String>) -> Self {
Self {
port_name: port_name.into(),
width: 20,
timeout: Duration::from_millis(100),
}
}
pub fn with_width(mut self, width: usize) -> Self {
self.width = width.max(1);
self
}
#[allow(dead_code)]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
pub struct Vfd {
port: Box<dyn SerialPort>,
pub width: usize,
blank_line: String,
}
impl Vfd {
pub fn open(cfg: VfdConfig) -> Result<Self> {
let mut port = serialport::new(cfg.port_name, FIXED_BAUD)
.timeout(cfg.timeout)
.open()?;
// ESC @
port.write_all(&[0x1B, 0x40])?;
// ESC t 6
port.write_all(&[0x1B, 0x74, TABLE_CYR])?;
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)]
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))
}
fn goto_xy(&mut self, x: u8, y: u8) -> std::io::Result<()> {
// US $ x y
self.port.write_all(&[0x1F, 0x24, x, y])
}
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
pub fn set_brightness(&mut self, n: u8) -> Result<()> {
let n = n.clamp(1, 4);
let cmd = [0x1F, 0x58, n]; // US 'X' n
self.port.write_all(&cmd)?;
self.port.flush()?;
std::thread::sleep(std::time::Duration::from_millis(2));
Ok(())
}
}
pub fn sanitize_for_cp866(s: &str) -> String {
s.chars()
.map(|c| match c {
'…' => '.', //
'—' | '' => '-', //
'№' => '#', //
'\t' => ' ',
'“' | '”' => '"',
'' | '' => '\'',
_ => c,
})
.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));
}
out
}

405
src/worker.rs Normal file
View File

@@ -0,0 +1,405 @@
use crate::vfd::{Vfd, VfdConfig, sanitize_for_cp866};
use anyhow::Result;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{self, Receiver, Sender},
};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub enum Cmd {
Clear,
PrintLine {
line: u8,
text: String,
},
PrintLineDiff {
line: u8,
text: String,
},
PrintAt {
x: u8,
y: u8,
text: String,
},
// marquee control
SetMarqueeText {
text: String,
},
StartMarquee {
line: u8,
cps: u32,
end_pause: Duration,
},
StopMarquee,
SetBrightness {
level: u8, // 1..4
},
// stop worker
Shutdown,
}
#[derive(Clone)]
pub struct VfdHandle {
tx: Sender<Cmd>,
stop: Arc<AtomicBool>,
}
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<String>) -> anyhow::Result<()> {
self.tx.send(Cmd::PrintLine {
line,
text: text.into(),
})?;
Ok(())
}
pub fn print_line_diff(&self, line: u8, text: impl Into<String>) -> anyhow::Result<()> {
self.tx.send(Cmd::PrintLineDiff {
line,
text: text.into(),
})?;
Ok(())
}
pub fn print_at(&self, x: u8, y: u8, text: impl Into<String>) -> anyhow::Result<()> {
self.tx.send(Cmd::PrintAt {
x,
y,
text: text.into(),
})?;
Ok(())
}
pub fn set_marquee_text(&self, text: impl Into<String>) {
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,
cps,
end_pause,
});
}
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<JoinHandle<()>>,
}
impl VfdWorker {
pub fn start(cfg: VfdConfig) -> Result<Self> {
let (tx, rx) = mpsc::channel::<Cmd>();
let stop = Arc::new(AtomicBool::new(false));
let handle = VfdHandle {
tx: tx.clone(),
stop: stop.clone(),
};
let join = thread::spawn(move || {
if let Err(e) = writer_loop(cfg, rx, stop) {
eprintln!("[vfd] writer loop error: {e:#}");
}
});
Ok(Self {
handle,
join: Some(join),
})
}
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();
}
}
}
#[derive(Debug, Clone)]
struct MarqueeState {
active: bool,
line: u8,
cps: u32,
end_pause: Duration,
text: String,
// runtime
stream: Vec<char>,
offset: usize,
last_step: Instant,
paused_until: Option<Instant>,
}
impl MarqueeState {
fn new() -> Self {
Self {
active: false,
line: 1,
cps: 5,
end_pause: Duration::from_millis(1500),
text: String::new(),
stream: Vec::new(),
offset: 0,
last_step: Instant::now(),
paused_until: None,
}
}
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.extend(text.chars());
self.stream.extend(std::iter::repeat(' ').take(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)
}
}
fn writer_loop(cfg: VfdConfig, rx: Receiver<Cmd>, stop: Arc<AtomicBool>) -> Result<()> {
let mut vfd = Vfd::open(cfg)?;
// optional initial clear:
let _ = vfd.clear();
let mut marquee = MarqueeState::new();
let mut last_lines = [String::new(), String::new()]; // line 1..2, fixed-width
let width = vfd.width;
let normalize_line =
|text: &str| -> String { crate::vfd::fit_to_width(&sanitize_for_cp866(text), width) };
let tick = Duration::from_millis(20); // internal scheduler tick
while !stop.load(Ordering::Relaxed) {
// 1) Drain commands (non-blocking)
loop {
match rx.try_recv() {
Ok(cmd) => match cmd {
Cmd::Clear => {
let _ = vfd.clear();
last_lines = [String::new(), String::new()];
}
Cmd::SetBrightness { level } => {
let _ = vfd.set_brightness(level);
}
Cmd::PrintLine { line, text } => {
let _ = vfd.print_line(line, &text);
if (1..=2).contains(&line) {
let idx = (line - 1) as usize;
last_lines[idx] = normalize_line(&text);
}
}
Cmd::PrintLineDiff { line, text } => {
// конфликт с marquee
if !(1..=2).contains(&line) {
continue;
}
if marquee.active && marquee.line == line {
continue;
}
let next = normalize_line(&text);
let idx = (line - 1) as usize;
// если строка не поменялась — ничего не делаем
if last_lines[idx] == next {
continue;
}
// первый кадр (или после clear) — лучше один раз вывести целиком
if last_lines[idx].is_empty() {
let _ = vfd.print_line(line, &next);
last_lines[idx] = next;
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);
}
}
last_lines[idx] = next;
}
Cmd::PrintAt { x, y, text } => {
// базовая валидация координат
if !(1..=width as u8).contains(&x) {
continue;
}
if !(1..=2).contains(&y) {
continue;
}
// если marquee активен и пишет в эту строку — игнорируем, иначе будет “драка”
if marquee.active && marquee.line == y {
continue;
}
let s = sanitize_for_cp866(&text);
let _ = vfd.print_at(x, y, &s);
// Обновляем кеш после изменения
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);
}
}
}
Cmd::SetMarqueeText { text } => {
marquee.text = text;
if marquee.active {
marquee.rebuild_stream(width);
}
}
Cmd::StartMarquee {
line,
cps,
end_pause,
} => {
let line = if (1..=2).contains(&line) { line } else { 1 };
last_lines[(line - 1) as usize].clear();
marquee.active = true;
marquee.line = line;
marquee.cps = cps.max(1);
marquee.end_pause = end_pause;
marquee.rebuild_stream(width);
}
Cmd::StopMarquee => {
if (1..=2).contains(&marquee.line) {
last_lines[(marquee.line - 1) as usize].clear();
}
marquee.active = false;
marquee.paused_until = None;
}
Cmd::Shutdown => {
stop.store(true, Ordering::Relaxed);
}
},
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// All senders dropped => exit thread cleanly
stop.store(true, Ordering::Relaxed);
break;
}
}
}
// 2) Render marquee if active
if marquee.active {
let now = Instant::now();
if let Some(until) = marquee.paused_until {
if now >= until {
marquee.paused_until = None;
marquee.last_step = now;
}
} else if now.duration_since(marquee.last_step) >= marquee.step_interval() {
marquee.last_step = now;
if marquee.stream.len() >= width {
let max_off = marquee.stream.len() - width;
let start = marquee.offset.min(max_off);
let end = (start + width).min(marquee.stream.len());
let frame: String = marquee.stream[start..end].iter().collect();
let _ = vfd.print_frame(marquee.line, &frame);
if (1..=2).contains(&marquee.line) {
last_lines[(marquee.line - 1) as usize] =
crate::vfd::fit_to_width(&frame, width);
}
if marquee.offset >= max_off {
marquee.offset = 0;
marquee.paused_until = Some(now + marquee.end_pause);
} else {
marquee.offset += 1;
}
}
}
}
thread::sleep(tick);
}
Ok(())
}

29
taskfile.yml Normal file
View File

@@ -0,0 +1,29 @@
version: "3"
vars:
VFD_PORT: /dev/cu.usbmodem101
VFD_WIDTH: "20"
tasks:
vfd:clock:
desc: Run VFD clock example
cmds:
- cargo run --example clock -- {{.VFD_PORT}} {{.VFD_WIDTH}} {{.VFD_CPS | default "8"}}
vars:
VFD_CPS: "8"
vfd:marquee:
desc: Run VFD marquee example
cmds:
- cargo run --example marquee -- {{.VFD_PORT}} {{.VFD_WIDTH}} {{.VFD_CPS | default "8"}} {{.VFD_END_PAUSE_MS | default "1500"}} {{.VFD_BRIGHTNESS | default "2"}}
vars:
VFD_CPS: "8"
VFD_END_PAUSE_MS: "1500"
VFD_BRIGHTNESS: "2"
vfd:brightness:
desc: Run VFD brightness example
cmds:
- cargo run --example brightness -- {{.VFD_PORT}} {{.VFD_WIDTH}} {{.VFD_DELAY_MS | default "800"}}
vars:
VFD_DELAY_MS: "800"