use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc::Sender,
},
time::{Duration, Instant},
};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tokio::sync::{Notify, broadcast, oneshot};
use crate::{
log::{ConsoleLog, LogInfo},
ring::{Matcher, Ring},
};
const EVENT_QUEUE: usize = 1024;
const MAX_EXPECT_MS: u64 = 120_000;
const RELEASE_POLL: Duration = Duration::from_millis(50);
const RELEASE_TICKS: usize = 40;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Origin {
Typed,
Key,
Agent,
Bridge,
}
#[derive(Clone)]
pub enum ConsoleEvent {
Rx(Vec<u8>),
Echo {
origin: Origin,
text: String,
},
System(String),
Connected,
}
pub struct Inject {
pub bytes: Vec<u8>,
pub echo: String,
pub origin: Origin,
pub resp: Option<oneshot::Sender<Result<(), String>>>,
}
pub struct Expect {
pub matched: bool,
pub data: String,
pub cursor: u64,
pub timed_out: bool,
}
pub struct ConsoleSpec {
pub device: String,
pub label: Option<String>,
pub baud: u32,
pub eol: Vec<u8>,
pub ring_cap: usize,
pub bridge: Option<u16>,
}
pub struct Console {
device: String,
label: Option<String>,
baud: u32,
eol: Vec<u8>,
ring: Mutex<Ring>,
notify: Notify,
inject: Sender<Inject>,
events: broadcast::Sender<ConsoleEvent>,
log: Mutex<ConsoleLog>,
connected: AtomicBool,
released: AtomicBool,
bridge: Option<u16>,
}
impl Console {
pub fn new(spec: ConsoleSpec, log: ConsoleLog, inject: Sender<Inject>) -> Arc<Self> {
let ConsoleSpec {
device,
label,
baud,
eol,
ring_cap,
bridge,
} = spec;
Arc::new(Self {
device,
label,
baud,
eol,
ring: Mutex::new(Ring::new(ring_cap)),
notify: Notify::new(),
inject,
events: broadcast::Sender::new(EVENT_QUEUE),
log: Mutex::new(log),
connected: AtomicBool::new(false),
released: AtomicBool::new(false),
bridge,
})
}
pub fn device(&self) -> &str {
&self.device
}
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
pub fn name(&self) -> &str {
self.label.as_deref().unwrap_or(&self.device)
}
pub fn matches(&self, query: &str) -> bool {
if self.label.as_deref().is_some_and(|l| l.eq_ignore_ascii_case(query)) {
return true;
}
if self.device.eq_ignore_ascii_case(query) {
return true;
}
self.device
.rsplit(['/', '\\'])
.next()
.is_some_and(|file| file.eq_ignore_ascii_case(query))
}
pub fn subscribe(&self) -> broadcast::Receiver<ConsoleEvent> {
self.events.subscribe()
}
pub fn push_rx(&self, bytes: &[u8]) {
self.ring.lock().unwrap().append(bytes);
self.record(self.log.lock().unwrap().rx(bytes));
self.notify.notify_waiters();
self.emit(ConsoleEvent::Rx(bytes.to_vec()));
}
pub fn push_echo(&self, origin: Origin, bytes: &[u8], echo: &str) {
self.record(self.log.lock().unwrap().tx(origin, bytes, echo));
self.emit(ConsoleEvent::Echo {
origin,
text: echo.to_string(),
});
}
pub fn note(&self, text: &str) {
self.record(self.log.lock().unwrap().system(text));
}
pub fn push_system(&self, text: &str) {
self.record(self.log.lock().unwrap().system(text));
self.emit(ConsoleEvent::System(text.to_string()));
}
pub fn set_connected(&self, connected: bool) {
self.connected.store(connected, Ordering::Relaxed);
self.emit(ConsoleEvent::Connected);
}
pub fn connected(&self) -> bool {
self.connected.load(Ordering::Relaxed)
}
pub async fn release(&self) -> bool {
self.released.store(true, Ordering::Relaxed);
for _ in 0..RELEASE_TICKS {
if !self.connected() {
return true;
}
tokio::time::sleep(RELEASE_POLL).await;
}
false
}
pub fn hold(&self) {
self.released.store(false, Ordering::Relaxed);
}
pub fn released(&self) -> bool {
self.released.load(Ordering::Relaxed)
}
pub fn bridge_port(&self) -> Option<u16> {
self.bridge
}
pub fn queue_raw(&self, bytes: Vec<u8>) -> Result<(), String> {
let echo = String::from_utf8_lossy(&bytes).into_owned();
self.queue(bytes, echo, Origin::Bridge)
}
pub fn baud(&self) -> u32 {
self.baud
}
pub fn total(&self) -> u64 {
self.ring.lock().unwrap().total()
}
pub fn read(&self, cursor: Option<u64>) -> (String, u64) {
let ring = self.ring.lock().unwrap();
let start = cursor.unwrap_or(ring.base());
let (abs, hay) = ring.slice_from(start);
(String::from_utf8_lossy(hay).into_owned(), abs + hay.len() as u64)
}
pub fn snapshot(&self, lines: usize) -> String {
self.ring.lock().unwrap().tail_lines(lines)
}
pub fn log_info(&self) -> LogInfo {
self.log.lock().unwrap().info()
}
pub fn log_roll(&self, tag: Option<&str>) -> Result<LogInfo> {
self.log.lock().unwrap().roll(tag)
}
pub fn queue_line(&self, text: &str) -> Result<(), String> {
let mut bytes = text.as_bytes().to_vec();
bytes.extend_from_slice(&self.eol);
self.queue(bytes, text.to_string(), Origin::Typed)
}
pub fn queue_ctrl(&self, ctrl: char) -> Result<(), String> {
let byte = ctrl_byte(ctrl).ok_or_else(|| format!("no control byte for '{ctrl}'"))?;
let echo = format!("Ctrl+{}", ctrl.to_ascii_uppercase());
self.queue(vec![byte], echo, Origin::Key)
}
fn queue(&self, bytes: Vec<u8>, echo: String, origin: Origin) -> Result<(), String> {
self.inject
.send(Inject {
bytes,
echo,
origin,
resp: None,
})
.map_err(|_| "serial session closed".to_string())
}
pub async fn send(&self, text: String, newline: bool) -> Result<u64, String> {
let cursor = self.total();
let echo = text.clone();
let mut bytes = text.into_bytes();
if newline {
bytes.extend_from_slice(&self.eol);
}
self.inject_and_wait(bytes, echo).await?;
Ok(cursor)
}
pub async fn send_ctrl(&self, ctrl: char) -> Result<u64, String> {
let byte = ctrl_byte(ctrl).ok_or_else(|| format!("no control byte for '{ctrl}'"))?;
let cursor = self.total();
let echo = format!("Ctrl+{}", ctrl.to_ascii_uppercase());
self.inject_and_wait_as(vec![byte], echo, Origin::Key).await?;
Ok(cursor)
}
async fn inject_and_wait(&self, bytes: Vec<u8>, echo: String) -> Result<(), String> {
self.inject_and_wait_as(bytes, echo, Origin::Agent).await
}
async fn inject_and_wait_as(&self, bytes: Vec<u8>, echo: String, origin: Origin) -> Result<(), String> {
let (resp_tx, resp_rx) = oneshot::channel();
self.inject
.send(Inject {
bytes,
echo,
origin,
resp: Some(resp_tx),
})
.map_err(|_| "serial session closed".to_string())?;
match resp_rx.await {
Ok(result) => result,
Err(_) => Err("serial session closed".to_string()),
}
}
pub async fn expect(
&self,
pattern: &str,
timeout_ms: u64,
regex: bool,
cursor: Option<u64>,
) -> Result<Expect, String> {
let matcher = Matcher::build(pattern, regex)?;
let start = cursor.unwrap_or_else(|| self.total());
let deadline = Instant::now() + Duration::from_millis(timeout_ms.min(MAX_EXPECT_MS));
let mut scan_from = start;
loop {
let notified = self.notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
{
let ring = self.ring.lock().unwrap();
let (scan_abs, scan_hay) = ring.slice_from(scan_from);
if let Some(end) = matcher.find_end(scan_hay) {
let match_end = scan_abs + end as u64;
let (abs, hay) = ring.slice_from(start);
let data = &hay[..(match_end - abs) as usize];
return Ok(Expect {
matched: true,
data: String::from_utf8_lossy(data).into_owned(),
cursor: match_end,
timed_out: false,
});
}
scan_from = matcher.resume_from(start, ring.total());
}
let now = Instant::now();
if now >= deadline {
let ring = self.ring.lock().unwrap();
let (abs, hay) = ring.slice_from(start);
return Ok(Expect {
matched: false,
data: String::from_utf8_lossy(hay).into_owned(),
cursor: abs + hay.len() as u64,
timed_out: true,
});
}
tokio::select! {
() = &mut notified => {}
() = tokio::time::sleep(deadline - now) => {}
}
}
}
fn emit(&self, event: ConsoleEvent) -> usize {
self.events.send(event).unwrap_or(0)
}
fn record(&self, outcome: Result<()>) {
if let Err(e) = outcome {
eprintln!("smon: log write failed on {}: {e:#}", self.name());
}
}
}
pub fn ctrl_byte(c: char) -> Option<u8> {
let lower = c.to_ascii_lowercase();
Some(match lower {
'a'..='z' => (lower as u8) - b'a' + 1,
'@' => 0,
'[' => 0x1b,
'\\' => 0x1c,
']' => 0x1d,
'^' => 0x1e,
'_' => 0x1f,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use std::{env, fs, sync::mpsc::channel};
use super::*;
use crate::ring::DEFAULT_RING_CAP;
fn console(device: &str, label: Option<&str>) -> Arc<Console> {
let dir = env::temp_dir().join(format!("smon-console-test-{}", sanitize_for_test(device)));
if dir.exists() {
fs::remove_dir_all(&dir).unwrap();
}
let log = ConsoleLog::open_in(dir, device, 30, None).unwrap();
Console::new(
ConsoleSpec {
device: device.to_string(),
label: label.map(str::to_string),
baud: 115_200,
eol: b"\r\n".to_vec(),
ring_cap: DEFAULT_RING_CAP,
bridge: None,
},
log,
channel().0,
)
}
fn sanitize_for_test(s: &str) -> String {
s.chars().filter(char::is_ascii_alphanumeric).collect()
}
#[test]
fn snapshot_returns_last_lines() {
let c = console("COM1", None);
c.push_rx(b"one\r\ntwo\r\nthree\r\npartial");
assert_eq!(c.snapshot(2), "three\npartial");
assert_eq!(c.snapshot(10), "one\ntwo\nthree\npartial");
assert_eq!(c.snapshot(0), "");
}
#[test]
fn a_label_names_the_console_but_the_device_still_addresses_it() {
let c = console("/dev/ttyUSB2", Some("board-a"));
assert_eq!(c.name(), "board-a");
assert!(c.matches("board-a"));
assert!(c.matches("BOARD-A"));
assert!(c.matches("/dev/ttyUSB2"));
assert!(c.matches("ttyUSB2"));
assert!(!c.matches("ttyUSB1"));
}
#[test]
fn without_a_label_the_device_is_the_name() {
let c = console("COM11", None);
assert_eq!(c.name(), "COM11");
assert!(c.matches("com11"));
assert!(!c.matches("COM1"));
}
#[test]
fn viewers_see_received_bytes_and_state_changes() {
let c = console("COM7", None);
let mut viewer = c.subscribe();
c.push_rx(b"hello");
c.set_connected(false);
assert!(matches!(viewer.try_recv(), Ok(ConsoleEvent::Rx(b)) if b == b"hello"));
assert!(matches!(viewer.try_recv(), Ok(ConsoleEvent::Connected)));
assert!(!c.connected());
}
#[test]
fn ctrl_byte_maps_letters_and_symbols() {
assert_eq!(ctrl_byte('c'), Some(3));
assert_eq!(ctrl_byte('C'), Some(3));
assert_eq!(ctrl_byte('['), Some(0x1b));
assert_eq!(ctrl_byte('1'), None);
}
}