commit 446a710e01329a5cc7d9486a00b260c37a2401e8 Author: Кобелев Андрей Андреевич Date: Wed Jan 28 01:36:35 2026 +0500 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0592392 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8007474 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..40dde89 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "m" +version = "0.1.0" +edition = "2024" + +[dependencies] +serialport = "4" +anyhow = "1" +encoding_rs = "0.8" diff --git a/examples/brightness.rs b/examples/brightness.rs new file mode 100644 index 0000000..a4a5267 --- /dev/null +++ b/examples/brightness.rs @@ -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)); + } + } +} diff --git a/examples/clock.rs b/examples/clock.rs new file mode 100644 index 0000000..0ed6800 --- /dev/null +++ b/examples/clock.rs @@ -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 { + 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::().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::().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)); + } +} diff --git a/examples/marquee.rs b/examples/marquee.rs new file mode 100644 index 0000000..f68b583 --- /dev/null +++ b/examples/marquee.rs @@ -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)); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..e003570 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,2 @@ +pub mod vfd; +pub mod worker; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..129cecf --- /dev/null +++ b/src/main.rs @@ -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(()) +} diff --git a/src/vfd.rs b/src/vfd.rs new file mode 100644 index 0000000..e87a376 --- /dev/null +++ b/src/vfd.rs @@ -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) -> 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, + pub width: usize, + blank_line: String, +} + +impl Vfd { + pub fn open(cfg: VfdConfig) -> Result { + 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 +} diff --git a/src/worker.rs b/src/worker.rs new file mode 100644 index 0000000..e707ddf --- /dev/null +++ b/src/worker.rs @@ -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, + 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, + text: text.into(), + })?; + Ok(()) + } + + pub fn print_line_diff(&self, line: u8, text: impl Into) -> anyhow::Result<()> { + self.tx.send(Cmd::PrintLineDiff { + line, + text: text.into(), + })?; + Ok(()) + } + + pub fn print_at(&self, x: u8, y: u8, text: impl Into) -> anyhow::Result<()> { + self.tx.send(Cmd::PrintAt { + x, + y, + text: text.into(), + })?; + 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, + 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>, +} + +impl VfdWorker { + pub fn start(cfg: VfdConfig) -> Result { + let (tx, rx) = mpsc::channel::(); + 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, + offset: usize, + last_step: Instant, + paused_until: Option, +} + +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, stop: Arc) -> 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(()) +} diff --git a/taskfile.yml b/taskfile.yml new file mode 100644 index 0000000..3d89da1 --- /dev/null +++ b/taskfile.yml @@ -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"