init
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
pub mod vfd;
|
||||
pub mod worker;
|
||||
+45
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user