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
View File
@@ -0,0 +1,2 @@
pub mod vfd;
pub mod worker;
+45
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
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
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(())
}