Add TUI serial collector and export commands
This commit is contained in:
280
src/app.rs
280
src/app.rs
@@ -1,5 +1,12 @@
|
|||||||
|
use anyhow::{Context, anyhow};
|
||||||
|
use chrono::Local;
|
||||||
use log::error;
|
use log::error;
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
env,
|
||||||
|
fs::{self, File},
|
||||||
|
io::BufWriter,
|
||||||
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
@@ -9,7 +16,7 @@ use tokio_util::sync::CancellationToken;
|
|||||||
|
|
||||||
use crate::serial_core::{
|
use crate::serial_core::{
|
||||||
TactileARecording,
|
TactileARecording,
|
||||||
codecs::tactile_a::{TactileACodec, TactileAHandler},
|
codecs::tactile_a::{TactileACodec, TactileAHandler, export_recording_csv},
|
||||||
error::SerialError,
|
error::SerialError,
|
||||||
frame::TactileAFrame,
|
frame::TactileAFrame,
|
||||||
serial::{self, PollMode, SerialFrame, TactileAPollRequester},
|
serial::{self, PollMode, SerialFrame, TactileAPollRequester},
|
||||||
@@ -38,28 +45,124 @@ impl SerialSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct SerialConnectionState {
|
pub struct SerialConnectionState {
|
||||||
session: Mutex<Option<Arc<SerialSession>>>,
|
sessions: Mutex<HashMap<String, Arc<SerialSession>>>,
|
||||||
last_record: Mutex<Option<Arc<Mutex<TactileARecording>>>>,
|
last_records: Mutex<HashMap<String, Arc<Mutex<TactileARecording>>>>,
|
||||||
|
export_dir: Mutex<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SerialConnectionState {
|
impl SerialConnectionState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
session: Mutex::new(None),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
last_record: Mutex::new(None),
|
last_records: Mutex::new(HashMap::new()),
|
||||||
|
export_dir: Mutex::new(default_export_dir()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_port(&self) -> Result<Option<String>, SerialError> {
|
pub fn active_ports(&self) -> Result<Vec<String>, SerialError> {
|
||||||
let session = self.session.lock().map_err(|_| SerialError::StateError)?;
|
let sessions = self.sessions.lock().map_err(|_| SerialError::StateError)?;
|
||||||
Ok(session.as_ref().map(|session| session.port.clone()))
|
let mut ports = sessions.keys().cloned().collect::<Vec<_>>();
|
||||||
|
ports.sort();
|
||||||
|
Ok(ports)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_record(&self) -> Result<Option<Arc<Mutex<TactileARecording>>>, SerialError> {
|
pub fn collector_lines(&self) -> Result<Vec<String>, SerialError> {
|
||||||
let session = self.session.lock().map_err(|_| SerialError::StateError)?;
|
let ports = self.active_ports()?;
|
||||||
Ok(session
|
if ports.is_empty() {
|
||||||
.as_ref()
|
return Ok(vec![
|
||||||
.map(|session| Arc::clone(&session.current_record)))
|
"No active serial collectors.".to_string(),
|
||||||
|
"Use /open <port> to start collecting.".to_string(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ports
|
||||||
|
.into_iter()
|
||||||
|
.map(|port| format!("Serial {port} is collecting..."))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exportable_ports(&self) -> Result<Vec<String>, SerialError> {
|
||||||
|
let active_ports = self.active_ports()?;
|
||||||
|
let last_records = self
|
||||||
|
.last_records
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| SerialError::StateError)?;
|
||||||
|
let mut ports = active_ports;
|
||||||
|
ports.extend(last_records.keys().cloned());
|
||||||
|
ports.sort();
|
||||||
|
ports.dedup();
|
||||||
|
Ok(ports)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_export_dir(&self) -> Result<PathBuf, SerialError> {
|
||||||
|
let export_dir = self
|
||||||
|
.export_dir
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| SerialError::StateError)?;
|
||||||
|
Ok(export_dir.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_export_dir(&self, path: &str) -> Result<PathBuf, SerialError> {
|
||||||
|
let export_dir = normalize_export_dir(path)?;
|
||||||
|
let mut current = self
|
||||||
|
.export_dir
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| SerialError::StateError)?;
|
||||||
|
*current = export_dir.clone();
|
||||||
|
Ok(export_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export_port_recording(&self, port: &str) -> anyhow::Result<PathBuf> {
|
||||||
|
let port_name = port.trim();
|
||||||
|
if port_name.is_empty() {
|
||||||
|
return Err(anyhow!(SerialError::InvalidConfig));
|
||||||
|
}
|
||||||
|
|
||||||
|
let recording_handle = {
|
||||||
|
let sessions = self
|
||||||
|
.sessions
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow!(SerialError::StateError))?;
|
||||||
|
if let Some(session) = sessions.get(port_name) {
|
||||||
|
Arc::clone(&session.current_record)
|
||||||
|
} else {
|
||||||
|
drop(sessions);
|
||||||
|
let last_records = self
|
||||||
|
.last_records
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow!(SerialError::StateError))?;
|
||||||
|
last_records
|
||||||
|
.get(port_name)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow!(SerialError::NoRecordedData))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let recording = recording_handle
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow!(SerialError::StateError))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
if recording.frames.is_empty() {
|
||||||
|
return Err(anyhow!(SerialError::NoRecordedData));
|
||||||
|
}
|
||||||
|
|
||||||
|
let export_dir = self
|
||||||
|
.current_export_dir()
|
||||||
|
.map_err(|err| anyhow!(err))
|
||||||
|
.context("failed to read export directory")?;
|
||||||
|
fs::create_dir_all(&export_dir).with_context(|| {
|
||||||
|
format!("failed to create export directory {}", export_dir.display())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let output_path = export_dir.join(export_file_name(port_name));
|
||||||
|
let file = File::create(&output_path)
|
||||||
|
.with_context(|| format!("failed to create {}", output_path.display()))?;
|
||||||
|
let writer = BufWriter::new(file);
|
||||||
|
export_recording_csv(&recording, writer)
|
||||||
|
.with_context(|| format!("failed to export {}", output_path.display()))?;
|
||||||
|
|
||||||
|
Ok(output_path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +191,8 @@ pub async fn serial_connect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let session = state.session.lock().map_err(|_| SerialError::StateError)?;
|
let sessions = state.sessions.lock().map_err(|_| SerialError::StateError)?;
|
||||||
if session.is_some() {
|
if sessions.contains_key(&port_name) {
|
||||||
return Err(SerialError::AlreadyConnected);
|
return Err(SerialError::AlreadyConnected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,28 +203,31 @@ pub async fn serial_connect(
|
|||||||
|
|
||||||
let cancel = CancellationToken::new();
|
let cancel = CancellationToken::new();
|
||||||
let current_record = Arc::new(Mutex::new(TactileARecording::new()));
|
let current_record = Arc::new(Mutex::new(TactileARecording::new()));
|
||||||
let session_started_at = Instant::now();
|
|
||||||
let session = Arc::new(SerialSession::new(
|
let session = Arc::new(SerialSession::new(
|
||||||
port_name,
|
port_name.clone(),
|
||||||
Arc::clone(¤t_record),
|
Arc::clone(¤t_record),
|
||||||
cancel.clone(),
|
cancel.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut active_session = state.session.lock().map_err(|_| SerialError::StateError)?;
|
let mut sessions = state.sessions.lock().map_err(|_| SerialError::StateError)?;
|
||||||
*active_session = Some(Arc::clone(&session));
|
if sessions.contains_key(&port_name) {
|
||||||
|
return Err(SerialError::AlreadyConnected);
|
||||||
}
|
}
|
||||||
{
|
sessions.insert(port_name.clone(), Arc::clone(&session));
|
||||||
let mut last_record = state
|
}
|
||||||
.last_record
|
|
||||||
|
state
|
||||||
|
.last_records
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| SerialError::StateError)?;
|
.map_err(|_| SerialError::StateError)?
|
||||||
*last_record = None;
|
.remove(&port_name);
|
||||||
}
|
|
||||||
|
|
||||||
let task_state = Arc::clone(&state);
|
let task_state = Arc::clone(&state);
|
||||||
let task_session = Arc::clone(&session);
|
let task_session = Arc::clone(&session);
|
||||||
let task_record = Arc::clone(¤t_record);
|
let task_record = Arc::clone(¤t_record);
|
||||||
|
let task_port = port_name.clone();
|
||||||
|
let session_started_at = Instant::now();
|
||||||
|
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
let codec = TactileACodec::new(7, 12);
|
let codec = TactileACodec::new(7, 12);
|
||||||
@@ -144,19 +250,19 @@ pub async fn serial_connect(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
error!("serial task exited with error: {err}");
|
error!("serial task exited with error on {task_port}: {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(mut last_record) = task_state.last_record.lock() {
|
if let Ok(mut last_records) = task_state.last_records.lock() {
|
||||||
*last_record = Some(Arc::clone(&task_record));
|
last_records.insert(task_port.clone(), Arc::clone(&task_record));
|
||||||
}
|
}
|
||||||
if let Ok(mut active_session) = task_state.session.lock() {
|
if let Ok(mut sessions) = task_state.sessions.lock() {
|
||||||
let should_clear = active_session
|
let should_clear = sessions
|
||||||
.as_ref()
|
.get(&task_port)
|
||||||
.map(|session| Arc::ptr_eq(session, &task_session))
|
.map(|session| Arc::ptr_eq(session, &task_session))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if should_clear {
|
if should_clear {
|
||||||
*active_session = None;
|
sessions.remove(&task_port);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Ok(mut task_slot) = task_session.task.lock() {
|
if let Ok(mut task_slot) = task_session.task.lock() {
|
||||||
@@ -170,20 +276,53 @@ pub async fn serial_connect(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn serial_disconnect(state: Arc<SerialConnectionState>) -> Result<(), SerialError> {
|
pub async fn serial_disconnect_port(
|
||||||
shutdown_active_session(&state).await
|
port: &str,
|
||||||
|
state: &SerialConnectionState,
|
||||||
|
) -> Result<(), SerialError> {
|
||||||
|
let port_name = port.trim();
|
||||||
|
if port_name.is_empty() {
|
||||||
|
return Err(SerialError::InvalidConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn shutdown_active_session(state: &SerialConnectionState) -> Result<(), SerialError> {
|
|
||||||
let session = {
|
let session = {
|
||||||
let mut active_session = state.session.lock().map_err(|_| SerialError::StateError)?;
|
let mut sessions = state.sessions.lock().map_err(|_| SerialError::StateError)?;
|
||||||
active_session.take()
|
sessions.remove(port_name)
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(session) = session else {
|
let Some(session) = session else {
|
||||||
return Ok(());
|
return Err(SerialError::NotConnected);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
shutdown_session(state, session).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn shutdown_all_sessions(
|
||||||
|
state: &SerialConnectionState,
|
||||||
|
) -> Result<Vec<String>, SerialError> {
|
||||||
|
let sessions = {
|
||||||
|
let mut sessions = state.sessions.lock().map_err(|_| SerialError::StateError)?;
|
||||||
|
let drained = sessions
|
||||||
|
.drain()
|
||||||
|
.map(|(_, session)| session)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
drained
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut closed_ports = Vec::with_capacity(sessions.len());
|
||||||
|
for session in sessions {
|
||||||
|
closed_ports.push(session.port.clone());
|
||||||
|
shutdown_session(state, session).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
closed_ports.sort();
|
||||||
|
Ok(closed_ports)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown_session(
|
||||||
|
state: &SerialConnectionState,
|
||||||
|
session: Arc<SerialSession>,
|
||||||
|
) -> Result<(), SerialError> {
|
||||||
session.cancel.cancel();
|
session.cancel.cancel();
|
||||||
|
|
||||||
let task = {
|
let task = {
|
||||||
@@ -195,11 +334,70 @@ pub async fn shutdown_active_session(state: &SerialConnectionState) -> Result<()
|
|||||||
task.await.map_err(|_| SerialError::CloseError)?;
|
task.await.map_err(|_| SerialError::CloseError)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut last_record = state
|
let mut last_records = state
|
||||||
.last_record
|
.last_records
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| SerialError::StateError)?;
|
.map_err(|_| SerialError::StateError)?;
|
||||||
*last_record = Some(Arc::clone(&session.current_record));
|
last_records.insert(session.port.clone(), Arc::clone(&session.current_record));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_export_dir() -> PathBuf {
|
||||||
|
let base = if cfg!(windows) {
|
||||||
|
env::var_os("USERPROFILE")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| env::current_dir().ok())
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
} else {
|
||||||
|
env::var_os("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| env::current_dir().ok())
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
};
|
||||||
|
|
||||||
|
base.join("Desktop")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_export_dir(path: &str) -> Result<PathBuf, SerialError> {
|
||||||
|
let trimmed = path.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(SerialError::InvalidConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = Path::new(trimmed);
|
||||||
|
let resolved = if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
env::current_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join(path)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_file_name(port: &str) -> String {
|
||||||
|
let sanitized_port = sanitize_file_component(port);
|
||||||
|
let timestamp = Local::now().format("%Y%m%d_%H%M%S");
|
||||||
|
format!("tactile_a_{sanitized_port}_{timestamp}.csv")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_file_component(value: &str) -> String {
|
||||||
|
let sanitized = value
|
||||||
|
.chars()
|
||||||
|
.map(|ch| {
|
||||||
|
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
|
||||||
|
ch
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
let trimmed = sanitized.trim_matches('_');
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"serial".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
345
src/cmd.rs
345
src/cmd.rs
@@ -1,115 +1,268 @@
|
|||||||
use std::io::{self, Stdout, stdout};
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Ok;
|
use anyhow::{Result, bail};
|
||||||
use crossterm::{
|
|
||||||
event::KeyCode,
|
use crate::{
|
||||||
execute,
|
app::{SerialConnectionState, serial_connect, serial_disconnect_port, shutdown_all_sessions},
|
||||||
terminal::{
|
serial_core::{error::SerialError, utils::serial_enum},
|
||||||
self, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use ratatui::{
|
|
||||||
Terminal,
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
backend::{self, CrosstermBackend},
|
pub enum Command {
|
||||||
layout::{Constraint, Direction, Layout, Margin},
|
Help,
|
||||||
style::{Modifier, Style},
|
Exit,
|
||||||
widgets::{Block, Borders, Paragraph, Wrap},
|
Scan,
|
||||||
|
Status,
|
||||||
|
Echo(String),
|
||||||
|
Open(String),
|
||||||
|
Close(Option<String>),
|
||||||
|
Export(String),
|
||||||
|
SetExport(String),
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CommandResponse {
|
||||||
|
pub lines: Vec<String>,
|
||||||
|
pub should_exit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandResponse {
|
||||||
|
fn new(lines: Vec<String>, should_exit: bool) -> Self {
|
||||||
|
Self { lines, should_exit }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_line(line: impl Into<String>) -> Self {
|
||||||
|
Self::new(vec![line.into()], false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_command(input: &str) -> Result<Command> {
|
||||||
|
let input = input.trim();
|
||||||
|
if input.is_empty() {
|
||||||
|
bail!("empty command");
|
||||||
|
}
|
||||||
|
if !input.starts_with('/') {
|
||||||
|
bail!("commands must start with '/'");
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = &input[1..];
|
||||||
|
let mut parts = body.splitn(2, ' ');
|
||||||
|
let name = parts.next().unwrap_or("").trim().to_ascii_lowercase();
|
||||||
|
let rest = parts.next().unwrap_or("").trim();
|
||||||
|
|
||||||
|
let command = match name.as_str() {
|
||||||
|
"help" => Command::Help,
|
||||||
|
"exit" | "quit" => Command::Exit,
|
||||||
|
"scan" => Command::Scan,
|
||||||
|
"status" => Command::Status,
|
||||||
|
"echo" => Command::Echo(rest.to_string()),
|
||||||
|
"open" => {
|
||||||
|
if rest.is_empty() {
|
||||||
|
bail!("/open requires a serial port path");
|
||||||
|
}
|
||||||
|
Command::Open(rest.to_string())
|
||||||
|
}
|
||||||
|
"close" => Command::Close(if rest.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(rest.to_string())
|
||||||
|
}),
|
||||||
|
"export" => {
|
||||||
|
if rest.is_empty() {
|
||||||
|
bail!("/export requires a serial port path");
|
||||||
|
}
|
||||||
|
Command::Export(rest.to_string())
|
||||||
|
}
|
||||||
|
"set" => {
|
||||||
|
let mut setting_parts = rest.splitn(2, ' ');
|
||||||
|
let setting_name = setting_parts
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
let setting_value = setting_parts.next().unwrap_or("").trim();
|
||||||
|
|
||||||
|
match setting_name.as_str() {
|
||||||
|
"export" => {
|
||||||
|
if setting_value.is_empty() {
|
||||||
|
bail!("/set export requires a directory path");
|
||||||
|
}
|
||||||
|
Command::SetExport(setting_value.to_string())
|
||||||
|
}
|
||||||
|
_ => bail!("unknown setting: {setting_name}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => Command::Unknown(other.to_string()),
|
||||||
};
|
};
|
||||||
use ratatui_textarea::TextArea;
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
Ok(command)
|
||||||
struct App {
|
|
||||||
messages: Vec<String>,
|
|
||||||
should_quit: bool,
|
|
||||||
input: TextArea<'static>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
pub async fn execute_input(input: &str, state: Arc<SerialConnectionState>) -> CommandResponse {
|
||||||
fn new() -> Self {
|
let command = match parse_command(input) {
|
||||||
let mut input = TextArea::default();
|
Ok(command) => command,
|
||||||
input.set_block(Block::default().borders(Borders::ALL).title("Input"));
|
Err(err) => return CommandResponse::from_line(format!("Error: {err}")),
|
||||||
input.set_cursor_line_style(Style::default().add_modifier(Modifier::REVERSED));
|
|
||||||
Self {
|
|
||||||
messages: vec!["Welcome to JE-Skin-Cli".to_string()],
|
|
||||||
should_quit: false,
|
|
||||||
input,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_key(&mut self, code: KeyCode) {
|
|
||||||
match code {
|
|
||||||
KeyCode::Esc => self.should_quit = true,
|
|
||||||
KeyCode::Enter => {
|
|
||||||
let text = self.input.lines().join("\n");
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
self.messages.push(format!("You send: {}", text.trim()));
|
|
||||||
}
|
|
||||||
self.input = {
|
|
||||||
let mut ta = TextArea::default();
|
|
||||||
ta.set_block(Block::default().borders(Borders::ALL).title("Input"));
|
|
||||||
ta.set_cursor_line_style(Style::default().add_modifier(Modifier::REVERSED));
|
|
||||||
ta
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
execute_command(command, state).await
|
||||||
}
|
}
|
||||||
_ => {
|
|
||||||
self.input.input(match code {
|
pub async fn execute_command(
|
||||||
KeyCode::Char(c) => c.into(),
|
command: Command,
|
||||||
KeyCode::Backspace => ratatui_textarea::Input {
|
state: Arc<SerialConnectionState>,
|
||||||
key: ratatui_textarea::Key::Backspace,
|
) -> CommandResponse {
|
||||||
ctrl: false,
|
match command {
|
||||||
alt: false,
|
Command::Help => CommandResponse::new(help_lines(), false),
|
||||||
shift: false,
|
Command::Exit => CommandResponse::new(vec!["bye".to_string()], true),
|
||||||
},
|
Command::Scan => match serial_enum() {
|
||||||
KeyCode::Left => ratatui_textarea::Input {
|
Ok(ports) if ports.is_empty() => {
|
||||||
key: ratatui_textarea::Key::Left,
|
CommandResponse::from_line("No serial ports found".to_string())
|
||||||
ctrl: false,
|
|
||||||
alt: false,
|
|
||||||
shift: false,
|
|
||||||
},
|
|
||||||
KeyCode::Right => ratatui_textarea::Input {
|
|
||||||
key: ratatui_textarea::Key::Right,
|
|
||||||
ctrl: false,
|
|
||||||
alt: false,
|
|
||||||
shift: false,
|
|
||||||
},
|
|
||||||
KeyCode::Up => ratatui_textarea::Input {
|
|
||||||
key: ratatui_textarea::Key::Up,
|
|
||||||
ctrl: false,
|
|
||||||
alt: false,
|
|
||||||
shift: false,
|
|
||||||
},
|
|
||||||
KeyCode::Down => ratatui_textarea::Input {
|
|
||||||
key: ratatui_textarea::Key::Down,
|
|
||||||
ctrl: false,
|
|
||||||
alt: false,
|
|
||||||
shift: false,
|
|
||||||
},
|
|
||||||
_ => return,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
Ok(ports) => CommandResponse::new(
|
||||||
|
ports
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, port)| format!("{index}. {port}"))
|
||||||
|
.collect(),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
Err(err) => CommandResponse::from_line(format!("Scan ports failed: {err}")),
|
||||||
|
},
|
||||||
|
Command::Status => match state.collector_lines() {
|
||||||
|
Ok(mut lines) => {
|
||||||
|
match state.current_export_dir() {
|
||||||
|
Ok(export_dir) => {
|
||||||
|
lines.push(format!("Export directory: {}", export_dir.display()))
|
||||||
}
|
}
|
||||||
|
Err(err) => lines.push(format!("Read export directory failed: {err}")),
|
||||||
|
}
|
||||||
|
CommandResponse::new(lines, false)
|
||||||
|
}
|
||||||
|
Err(err) => CommandResponse::from_line(format!("Read collector status failed: {err}")),
|
||||||
|
},
|
||||||
|
Command::Echo(text) => CommandResponse::from_line(text),
|
||||||
|
Command::Open(port) => match serial_connect(port.clone(), Arc::clone(&state)).await {
|
||||||
|
Ok(()) => CommandResponse::from_line(format!("Serial {port} is collecting...")),
|
||||||
|
Err(err) => CommandResponse::from_line(open_error_message(&port, err)),
|
||||||
|
},
|
||||||
|
Command::Close(Some(port)) => match serial_disconnect_port(&port, state.as_ref()).await {
|
||||||
|
Ok(()) => CommandResponse::from_line(format!("Serial {port} stopped collecting.")),
|
||||||
|
Err(SerialError::NotConnected) => {
|
||||||
|
CommandResponse::from_line(format!("Serial {port} is not collecting."))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
CommandResponse::from_line(format!("Close serial port failed for {port}: {err}"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Command::Close(None) => match shutdown_all_sessions(state.as_ref()).await {
|
||||||
|
Ok(ports) if ports.is_empty() => {
|
||||||
|
CommandResponse::from_line("No active serial collectors.".to_string())
|
||||||
|
}
|
||||||
|
Ok(ports) => CommandResponse::new(
|
||||||
|
ports
|
||||||
|
.into_iter()
|
||||||
|
.map(|port| format!("Serial {port} stopped collecting."))
|
||||||
|
.collect(),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
Err(err) => {
|
||||||
|
CommandResponse::from_line(format!("Close all serial collectors failed: {err}"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Command::Export(port) => match state.export_port_recording(&port) {
|
||||||
|
Ok(output_path) => CommandResponse::new(
|
||||||
|
vec![
|
||||||
|
format!("Exported serial {port}."),
|
||||||
|
format!("Output: {}", output_path.display()),
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
Err(err) => CommandResponse::from_line(format!("Export failed for {port}: {err}")),
|
||||||
|
},
|
||||||
|
Command::SetExport(path) => match state.set_export_dir(&path) {
|
||||||
|
Ok(export_dir) => CommandResponse::new(
|
||||||
|
vec![
|
||||||
|
"Export directory updated.".to_string(),
|
||||||
|
format!("Current export directory: {}", export_dir.display()),
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
Err(err) => CommandResponse::from_line(format!("Set export directory failed: {err}")),
|
||||||
|
},
|
||||||
|
Command::Unknown(name) => CommandResponse::new(
|
||||||
|
vec![
|
||||||
|
format!("Unknown command: {name}"),
|
||||||
|
"Use /help to list available commands".to_string(),
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
|
fn open_error_message(port: &str, err: SerialError) -> String {
|
||||||
enable_raw_mode()?;
|
match err {
|
||||||
let mut stdout = io::stdout();
|
SerialError::AlreadyConnected => format!("Serial {port} is already collecting."),
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
other => format!("Open serial port failed for {port}: {other}"),
|
||||||
let backend = CrosstermBackend::new(stdout);
|
}
|
||||||
let terminal = Terminal::new(backend)?;
|
|
||||||
Ok(terminal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
|
fn help_lines() -> Vec<String> {
|
||||||
disable_raw_mode()?;
|
vec![
|
||||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
"Available commands:".to_string(),
|
||||||
terminal.show_cursor()?;
|
" /help Show help".to_string(),
|
||||||
Ok(())
|
" /scan List serial ports".to_string(),
|
||||||
|
" /status Show active collectors and export directory".to_string(),
|
||||||
|
" /open <path> Start collecting on a serial port".to_string(),
|
||||||
|
" /close <path> Stop collecting on one serial port".to_string(),
|
||||||
|
" /close Stop collecting on all serial ports".to_string(),
|
||||||
|
" /export <port> Export one serial recording to CSV".to_string(),
|
||||||
|
" /set export <dir> Set the export directory".to_string(),
|
||||||
|
" /echo <text> Print text to the left pane".to_string(),
|
||||||
|
" /exit Exit the TUI".to_string(),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_app(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
|
#[cfg(test)]
|
||||||
let mut app = App::new();
|
mod tests {
|
||||||
while !app.should_quit {
|
use super::*;
|
||||||
terminal.draw(|f| {})
|
|
||||||
|
#[test]
|
||||||
|
fn parse_open_command() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("/open /dev/ttyUSB0").unwrap(),
|
||||||
|
Command::Open("/dev/ttyUSB0".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_close_without_argument() {
|
||||||
|
assert_eq!(parse_command("/close").unwrap(), Command::Close(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_status_command() {
|
||||||
|
assert_eq!(parse_command("/status").unwrap(), Command::Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_export_command() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("/export /dev/ttyUSB0").unwrap(),
|
||||||
|
Command::Export("/dev/ttyUSB0".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_set_export_command() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("/set export ./exports").unwrap(),
|
||||||
|
Command::SetExport("./exports".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_non_command_input() {
|
||||||
|
assert!(parse_command("scan").is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/flog.rs
19
src/flog.rs
@@ -1,5 +1,8 @@
|
|||||||
use fern::{Dispatch, colors::{ColoredLevelConfig, Color}, DateBased};
|
use fern::{
|
||||||
use log::{debug};
|
DateBased, Dispatch,
|
||||||
|
colors::{Color, ColoredLevelConfig},
|
||||||
|
};
|
||||||
|
use log::debug;
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
pub fn setup_logger() {
|
pub fn setup_logger() {
|
||||||
let colors_line = ColoredLevelConfig::new()
|
let colors_line = ColoredLevelConfig::new()
|
||||||
@@ -18,8 +21,7 @@ pub fn setup_logger() {
|
|||||||
|
|
||||||
let console_config = fern::Dispatch::new()
|
let console_config = fern::Dispatch::new()
|
||||||
.format(move |out, message, record| {
|
.format(move |out, message, record| {
|
||||||
out.finish(
|
out.finish(format_args!(
|
||||||
format_args!(
|
|
||||||
"{colors_line}[{data} {level} {target} {colors_line}] {message}\x1B[0m",
|
"{colors_line}[{data} {level} {target} {colors_line}] {message}\x1B[0m",
|
||||||
colors_line = format_args!(
|
colors_line = format_args!(
|
||||||
"\x1B[{}m",
|
"\x1B[{}m",
|
||||||
@@ -29,23 +31,20 @@ pub fn setup_logger() {
|
|||||||
target = record.target(),
|
target = record.target(),
|
||||||
level = colors_level.color(record.level()),
|
level = colors_level.color(record.level()),
|
||||||
message = message,
|
message = message,
|
||||||
)
|
));
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.level(level)
|
.level(level)
|
||||||
.chain(std::io::stdout());
|
.chain(std::io::stdout());
|
||||||
|
|
||||||
let data_based_config = fern::Dispatch::new()
|
let data_based_config = fern::Dispatch::new()
|
||||||
.format(move |out, message, record| {
|
.format(move |out, message, record| {
|
||||||
out.finish(
|
out.finish(format_args!(
|
||||||
format_args!(
|
|
||||||
"[{data} {level} {target}] {message}",
|
"[{data} {level} {target}] {message}",
|
||||||
data = humantime::format_rfc3339_seconds(SystemTime::now()),
|
data = humantime::format_rfc3339_seconds(SystemTime::now()),
|
||||||
target = record.target(),
|
target = record.target(),
|
||||||
level = colors_level.color(record.level()),
|
level = colors_level.color(record.level()),
|
||||||
message = message,
|
message = message,
|
||||||
)
|
));
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.level(level)
|
.level(level)
|
||||||
.chain(fern::DateBased::new("program.log", "%Y-%m-%d"));
|
.chain(fern::DateBased::new("program.log", "%Y-%m-%d"));
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ pub mod app;
|
|||||||
pub mod cmd;
|
pub mod cmd;
|
||||||
pub mod flog;
|
pub mod flog;
|
||||||
pub mod serial_core;
|
pub mod serial_core;
|
||||||
fn main() {
|
pub mod tui;
|
||||||
println!("Hello, world!");
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
tui::run().await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::serial_core::{frame::TestFrame, record::Recording};
|
use crate::serial_core::{frame::TestFrame, record::Recording};
|
||||||
|
|
||||||
pub mod test;
|
|
||||||
pub mod tactile_a;
|
pub mod tactile_a;
|
||||||
|
pub mod test;
|
||||||
pub type TestRecording = Recording<TestFrame>;
|
pub type TestRecording = Recording<TestFrame>;
|
||||||
@@ -2,17 +2,17 @@ use crate::serial_core::error::CodecError;
|
|||||||
use crate::serial_core::frame::{
|
use crate::serial_core::frame::{
|
||||||
FrameHandler, TactileAFrameMetaData, TactileARepFrame, TactileAReqFrame,
|
FrameHandler, TactileAFrameMetaData, TactileARepFrame, TactileAReqFrame,
|
||||||
};
|
};
|
||||||
use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording};
|
use crate::serial_core::record::{CsvExporter, CsvImporter, RecordedFrame, Recording, write_csv};
|
||||||
use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis};
|
use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis};
|
||||||
use crate::serial_core::{
|
use crate::serial_core::{
|
||||||
codec::Codec,
|
codec::Codec,
|
||||||
frame::{TactileAFrame, TactileAFrameStatusCode},
|
frame::{TactileAFrame, TactileAFrameStatusCode},
|
||||||
};
|
};
|
||||||
|
use anyhow::anyhow;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use csv::StringRecord;
|
use csv::StringRecord;
|
||||||
use anyhow::anyhow;
|
|
||||||
use std::io::Read;
|
|
||||||
use log::debug;
|
use log::debug;
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
const FRAME_BUFFER_MIN_LENGTH: usize = 15;
|
const FRAME_BUFFER_MIN_LENGTH: usize = 15;
|
||||||
|
|
||||||
@@ -77,11 +77,7 @@ impl TactileACodec {
|
|||||||
.chunks_exact(2)
|
.chunks_exact(2)
|
||||||
.map(|chunk| {
|
.map(|chunk| {
|
||||||
let raw = u16::from_le_bytes([chunk[0], chunk[1]]) as i32;
|
let raw = u16::from_le_bytes([chunk[0], chunk[1]]) as i32;
|
||||||
if raw < 15 {
|
if raw < 15 { 0 } else { raw }
|
||||||
0
|
|
||||||
} else {
|
|
||||||
raw
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<i32>>();
|
.collect::<Vec<i32>>();
|
||||||
|
|
||||||
@@ -223,14 +219,13 @@ impl Codec<TactileAFrame> for TactileACodec {
|
|||||||
req_bytes.push(f.meta.func_code);
|
req_bytes.push(f.meta.func_code);
|
||||||
|
|
||||||
req_bytes.extend_from_slice(f.meta.start_addr.to_le_bytes().as_slice());
|
req_bytes.extend_from_slice(f.meta.start_addr.to_le_bytes().as_slice());
|
||||||
req_bytes.extend_from_slice((f.meta.except_data_len as u16).to_le_bytes().as_slice());
|
req_bytes
|
||||||
|
.extend_from_slice((f.meta.except_data_len as u16).to_le_bytes().as_slice());
|
||||||
let checksum = calc_crc8_itu(req_bytes.as_slice());
|
let checksum = calc_crc8_itu(req_bytes.as_slice());
|
||||||
req_bytes.push(checksum);
|
req_bytes.push(checksum);
|
||||||
Ok(req_bytes)
|
Ok(req_bytes)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => Err(CodecError::InvalidFrameType),
|
||||||
Err(CodecError::InvalidFrameType)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,10 +262,7 @@ impl CsvExporter<TactileARepFrame> for TactileACsvExporter {
|
|||||||
header
|
header
|
||||||
}
|
}
|
||||||
|
|
||||||
fn csv_row(
|
fn csv_row(&self, item: &RecordedFrame<TactileARepFrame>) -> anyhow::Result<Vec<String>> {
|
||||||
&self,
|
|
||||||
item: &RecordedFrame<TactileARepFrame>,
|
|
||||||
) -> anyhow::Result<Vec<String>> {
|
|
||||||
let packet = TactileADataPacket::try_from(&item.frame)?;
|
let packet = TactileADataPacket::try_from(&item.frame)?;
|
||||||
let summary: i32 = packet.data.iter().sum();
|
let summary: i32 = packet.data.iter().sum();
|
||||||
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect();
|
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect();
|
||||||
@@ -293,13 +285,12 @@ impl CsvExporter<TactileAFrame> for TactileACsvExporter {
|
|||||||
header
|
header
|
||||||
}
|
}
|
||||||
|
|
||||||
fn csv_row(
|
fn csv_row(&self, item: &RecordedFrame<TactileAFrame>) -> anyhow::Result<Vec<String>> {
|
||||||
&self,
|
|
||||||
item: &RecordedFrame<TactileAFrame>,
|
|
||||||
) -> anyhow::Result<Vec<String>> {
|
|
||||||
let rep = match &item.frame {
|
let rep = match &item.frame {
|
||||||
TactileAFrame::Rep(rep) => rep,
|
TactileAFrame::Rep(rep) => rep,
|
||||||
TactileAFrame::Req(_) => return Err(anyhow!("request frame cannot be exported to csv row")),
|
TactileAFrame::Req(_) => {
|
||||||
|
return Err(anyhow!("request frame cannot be exported to csv row"));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let packet = TactileADataPacket::try_from(rep)?;
|
let packet = TactileADataPacket::try_from(rep)?;
|
||||||
@@ -329,7 +320,9 @@ impl TactileACsvImporter {
|
|||||||
|
|
||||||
let mut data = Vec::with_capacity(self.channels);
|
let mut data = Vec::with_capacity(self.channels);
|
||||||
for index in 0..self.channels {
|
for index in 0..self.channels {
|
||||||
let cell = record.get(index).ok_or_else(|| anyhow!("missing channel cell"))?;
|
let cell = record
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| anyhow!("missing channel cell"))?;
|
||||||
data.push(cell.parse::<i32>()?);
|
data.push(cell.parse::<i32>()?);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,7 +357,10 @@ impl CsvImporter<TactileADataPacket> for TactileACsvImporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn export_recording_csv<W>(recording: &Recording<TactileAFrame>, writer: W) -> anyhow::Result<()>
|
pub fn export_recording_csv<W>(
|
||||||
|
recording: &Recording<TactileAFrame>,
|
||||||
|
writer: W,
|
||||||
|
) -> anyhow::Result<()>
|
||||||
where
|
where
|
||||||
W: std::io::Write,
|
W: std::io::Write,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
use std::io::Read;
|
use crate::serial_core::frame::FrameHandler;
|
||||||
use std::time::Instant;
|
use crate::serial_core::record::{CsvExporter, CsvImporter, RecordedFrame, Recording, write_csv};
|
||||||
use crate::serial_core::frame::{FrameHandler};
|
use crate::serial_core::utils::{elapsed_millis, usize_to_u16_be_bytes};
|
||||||
use crate::serial_core::{codec::Codec, error::CodecError, frame::TestFrame};
|
use crate::serial_core::{codec::Codec, error::CodecError, frame::TestFrame};
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use csv::StringRecord;
|
use csv::StringRecord;
|
||||||
use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording};
|
use std::io::Read;
|
||||||
use crate::serial_core::utils::{
|
use std::time::Instant;
|
||||||
elapsed_millis,
|
|
||||||
usize_to_u16_be_bytes
|
|
||||||
};
|
|
||||||
pub struct TestCodec {
|
pub struct TestCodec {
|
||||||
buffer: Vec<u8>,
|
buffer: Vec<u8>,
|
||||||
}
|
}
|
||||||
@@ -23,7 +20,11 @@ impl TestCodec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Codec<TestFrame> for TestCodec {
|
impl Codec<TestFrame> for TestCodec {
|
||||||
fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result<Vec<TestFrame>, CodecError> {
|
fn decode(
|
||||||
|
&mut self,
|
||||||
|
input: &[u8],
|
||||||
|
session_started_at: Instant,
|
||||||
|
) -> Result<Vec<TestFrame>, CodecError> {
|
||||||
self.buffer.extend_from_slice(input);
|
self.buffer.extend_from_slice(input);
|
||||||
let mut frames = Vec::new();
|
let mut frames = Vec::new();
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ pub struct TestCsvImporter {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct TestDataPacket {
|
pub struct TestDataPacket {
|
||||||
pub data: Vec<i32>,
|
pub data: Vec<i32>,
|
||||||
pub dts_ms: u64
|
pub dts_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<&TestFrame> for TestDataPacket {
|
impl TryFrom<&TestFrame> for TestDataPacket {
|
||||||
@@ -134,7 +135,10 @@ impl TryFrom<&TestFrame> for TestDataPacket {
|
|||||||
fn try_from(frame: &TestFrame) -> Result<TestDataPacket, Self::Error> {
|
fn try_from(frame: &TestFrame) -> Result<TestDataPacket, Self::Error> {
|
||||||
let data = parse_data_frame(&frame.payload)?;
|
let data = parse_data_frame(&frame.payload)?;
|
||||||
let dts = frame.dts_ms;
|
let dts = frame.dts_ms;
|
||||||
Ok(TestDataPacket { data: data, dts_ms: dts })
|
Ok(TestDataPacket {
|
||||||
|
data: data,
|
||||||
|
dts_ms: dts,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// impl From<TestFrame> for TestDataPacket {
|
// impl From<TestFrame> for TestDataPacket {
|
||||||
@@ -145,14 +149,17 @@ impl TryFrom<&TestFrame> for TestDataPacket {
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
|
||||||
impl CsvExporter<TestFrame> for TestCsvExporter {
|
impl CsvExporter<TestFrame> for TestCsvExporter {
|
||||||
type Error = CodecError;
|
type Error = CodecError;
|
||||||
fn csv_header(&self, recording: &Recording<TestFrame>) -> Vec<String> {
|
fn csv_header(&self, recording: &Recording<TestFrame>) -> Vec<String> {
|
||||||
let channel_nb = recording
|
let channel_nb = recording
|
||||||
.frames
|
.frames
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|frame| parse_data_frame(&frame.frame.payload).ok().map(|vals| vals.len()))
|
.find_map(|frame| {
|
||||||
|
parse_data_frame(&frame.frame.payload)
|
||||||
|
.ok()
|
||||||
|
.map(|vals| vals.len())
|
||||||
|
})
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let mut header: Vec<String> = Vec::new();
|
let mut header: Vec<String> = Vec::new();
|
||||||
for i in 0..channel_nb {
|
for i in 0..channel_nb {
|
||||||
@@ -191,7 +198,9 @@ impl TestCsvImporter {
|
|||||||
|
|
||||||
let mut data = Vec::with_capacity(self.channels);
|
let mut data = Vec::with_capacity(self.channels);
|
||||||
for index in 0..self.channels {
|
for index in 0..self.channels {
|
||||||
let cell = record.get(index).ok_or_else(|| anyhow!("missing channel cell"))?;
|
let cell = record
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| anyhow!("missing channel cell"))?;
|
||||||
data.push(cell.parse::<i32>()?);
|
data.push(cell.parse::<i32>()?);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +235,6 @@ impl CsvImporter<TestDataPacket> for TestCsvImporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn export_recording_csv<W>(recording: &Recording<TestFrame>, writer: W) -> anyhow::Result<()>
|
pub fn export_recording_csv<W>(recording: &Recording<TestFrame>, writer: W) -> anyhow::Result<()>
|
||||||
where
|
where
|
||||||
W: std::io::Write,
|
W: std::io::Write,
|
||||||
@@ -237,19 +245,19 @@ where
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use csv::Reader;
|
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_read_csv_basic() -> anyhow::Result<()> {
|
fn test_read_csv_basic() -> anyhow::Result<()> {
|
||||||
let mut rdr = Reader::from_path("recording_20260329_125238.csv")?;
|
let csv = "channel1,channel2,dts\n10,20,5\n30,40,15\n";
|
||||||
let headers = rdr.headers()?;
|
let mut importer = TestCsvImporter::new("");
|
||||||
println!("headers: {:?}", headers);
|
let packets = importer.load(Cursor::new(csv))?;
|
||||||
|
|
||||||
for result in rdr.records() {
|
assert_eq!(packets.len(), 2);
|
||||||
let record = result?;
|
assert_eq!(packets[0].data, vec![10, 20]);
|
||||||
println!("record: {:?}", record);
|
assert_eq!(packets[0].dts_ms, 5);
|
||||||
}
|
assert_eq!(packets[1].data, vec![30, 40]);
|
||||||
|
assert_eq!(packets[1].dts_ms, 15);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub enum SerialError {
|
|||||||
ScanError,
|
ScanError,
|
||||||
InvalidConfig,
|
InvalidConfig,
|
||||||
AlreadyConnected,
|
AlreadyConnected,
|
||||||
|
NotConnected,
|
||||||
StateError,
|
StateError,
|
||||||
NoRecordedData,
|
NoRecordedData,
|
||||||
ExportError,
|
ExportError,
|
||||||
@@ -22,6 +23,7 @@ impl fmt::Display for SerialError {
|
|||||||
SerialError::ScanError => write!(f, "Scan Error"),
|
SerialError::ScanError => write!(f, "Scan Error"),
|
||||||
SerialError::InvalidConfig => write!(f, "Invalid Config"),
|
SerialError::InvalidConfig => write!(f, "Invalid Config"),
|
||||||
SerialError::AlreadyConnected => write!(f, "Already Connected"),
|
SerialError::AlreadyConnected => write!(f, "Already Connected"),
|
||||||
|
SerialError::NotConnected => write!(f, "Not Connected"),
|
||||||
SerialError::StateError => write!(f, "State Error"),
|
SerialError::StateError => write!(f, "State Error"),
|
||||||
SerialError::NoRecordedData => write!(f, "No Recorded Data"),
|
SerialError::NoRecordedData => write!(f, "No Recorded Data"),
|
||||||
SerialError::ExportError => write!(f, "Export Error"),
|
SerialError::ExportError => write!(f, "Export Error"),
|
||||||
@@ -30,6 +32,8 @@ impl fmt::Display for SerialError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SerialError {}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum CodecError {
|
pub enum CodecError {
|
||||||
InvalidHeader,
|
InvalidHeader,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub struct TestFrame {
|
|||||||
pub length: usize,
|
pub length: usize,
|
||||||
pub payload: Vec<u8>,
|
pub payload: Vec<u8>,
|
||||||
pub checksum: u8,
|
pub checksum: u8,
|
||||||
pub dts_ms: u64
|
pub dts_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -35,23 +35,22 @@ pub struct TactileARepFrame {
|
|||||||
pub meta: TactileAFrameMetaData,
|
pub meta: TactileAFrameMetaData,
|
||||||
pub status: TactileAFrameStatusCode,
|
pub status: TactileAFrameStatusCode,
|
||||||
pub payload: Vec<u8>,
|
pub payload: Vec<u8>,
|
||||||
pub dts_ms: u64
|
pub dts_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum TactileAFrameStatusCode {
|
pub enum TactileAFrameStatusCode {
|
||||||
Success,
|
Success,
|
||||||
Failure
|
Failure,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum TactileAFrame {
|
pub enum TactileAFrame {
|
||||||
Req(TactileAReqFrame),
|
Req(TactileAReqFrame),
|
||||||
Rep(TactileARepFrame)
|
Rep(TactileARepFrame),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait FrameHandler<F, T>: Send {
|
pub trait FrameHandler<F, T>: Send {
|
||||||
async fn on_frame(&mut self, frame: &F) -> Result<Option<Vec<T>>>;
|
async fn on_frame(&mut self, frame: &F) -> Result<Option<Vec<T>>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,4 +42,3 @@ pub fn serial_enum() -> Result<Vec<String>, SerialError> {
|
|||||||
|
|
||||||
Ok(ports)
|
Ok(ports)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,16 +7,18 @@ pub struct FrameTiming {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RecordedFrame<F> {
|
pub struct RecordedFrame<F> {
|
||||||
pub timing: FrameTiming,
|
pub timing: FrameTiming,
|
||||||
pub frame: F
|
pub frame: F,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct Recording<F> {
|
pub struct Recording<F> {
|
||||||
pub frames: Vec<RecordedFrame<F>>
|
pub frames: Vec<RecordedFrame<F>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<F> Recording<F> {
|
impl<F> Recording<F> {
|
||||||
pub fn new() -> Recording<F> { Self { frames: Vec::new() } }
|
pub fn new() -> Recording<F> {
|
||||||
|
Self { frames: Vec::new() }
|
||||||
|
}
|
||||||
pub fn push(&mut self, ite: RecordedFrame<F>) {
|
pub fn push(&mut self, ite: RecordedFrame<F>) {
|
||||||
self.frames.push(ite);
|
self.frames.push(ite);
|
||||||
}
|
}
|
||||||
@@ -33,11 +35,7 @@ pub trait CsvImporter<P> {
|
|||||||
fn load<R: std::io::Read>(&mut self, reader: R) -> anyhow::Result<Vec<P>>;
|
fn load<R: std::io::Read>(&mut self, reader: R) -> anyhow::Result<Vec<P>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_csv<F, E, W>(
|
pub fn write_csv<F, E, W>(recording: &Recording<F>, exporter: &E, writer: W) -> anyhow::Result<()>
|
||||||
recording: &Recording<F>,
|
|
||||||
exporter: &E,
|
|
||||||
writer: W,
|
|
||||||
) -> anyhow::Result<()>
|
|
||||||
where
|
where
|
||||||
E: CsvExporter<F>,
|
E: CsvExporter<F>,
|
||||||
W: std::io::Write,
|
W: std::io::Write,
|
||||||
|
|||||||
@@ -155,8 +155,6 @@ where
|
|||||||
});
|
});
|
||||||
|
|
||||||
let mut buffer = [0u8; 1024];
|
let mut buffer = [0u8; 1024];
|
||||||
let mut prune_interval = time::interval(Duration::from_millis(450));
|
|
||||||
prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -193,17 +191,21 @@ where
|
|||||||
r.on_rx_frame(&frame);
|
r.on_rx_frame(&frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
let decode_res = handler
|
let _decoded_values = handler
|
||||||
.on_frame(&frame)
|
.on_frame(&frame)
|
||||||
.await?
|
.await?
|
||||||
.map(|vals| vals.into_iter().map(Into::into).collect::<Vec<i32>>());
|
.map(|vals| vals.into_iter().map(Into::into).collect::<Vec<i32>>());
|
||||||
|
|
||||||
let mut record = recording.lock().map_err(|_| anyhow::anyhow!("recording state poisoned"))?;
|
let mut record = recording
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("recording state poisoned"))?;
|
||||||
record.push(RecordedFrame {
|
record.push(RecordedFrame {
|
||||||
timing: FrameTiming { pts_ms: None, dts_ms: frame.dts_ms() },
|
timing: FrameTiming {
|
||||||
|
pts_ms: None,
|
||||||
|
dts_ms: frame.dts_ms(),
|
||||||
|
},
|
||||||
frame: frame.clone(),
|
frame: frame.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use tokio_serial::available_ports;
|
||||||
|
|
||||||
|
use crate::serial_core::error::SerialError;
|
||||||
|
|
||||||
pub fn usize_to_u16_be_bytes(n: usize) -> [u8; 2] {
|
pub fn usize_to_u16_be_bytes(n: usize) -> [u8; 2] {
|
||||||
(n as u16).to_be_bytes()
|
(n as u16).to_be_bytes()
|
||||||
}
|
}
|
||||||
@@ -33,6 +36,16 @@ pub fn elapsed_millis(start_at: Instant) -> u64 {
|
|||||||
start_at.elapsed().as_millis() as u64
|
start_at.elapsed().as_millis() as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn serial_enum() -> Result<Vec<String>, SerialError> {
|
||||||
|
let ports = available_ports()
|
||||||
|
.map_err(|_| SerialError::ScanError)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|info| info.port_name)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(ports)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use anyhow::Ok;
|
use anyhow::Ok;
|
||||||
@@ -41,7 +54,9 @@ mod test {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_crc8_itu() -> anyhow::Result<()> {
|
fn test_crc8_itu() -> anyhow::Result<()> {
|
||||||
let req_vec = vec![0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00];
|
let req_vec = vec![
|
||||||
|
0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00,
|
||||||
|
];
|
||||||
let checksum = calc_crc8_itu(req_vec.as_slice());
|
let checksum = calc_crc8_itu(req_vec.as_slice());
|
||||||
assert_eq!(checksum, 0x7A);
|
assert_eq!(checksum, 0x7A);
|
||||||
|
|
||||||
@@ -50,7 +65,9 @@ mod test {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_crc8_smbus() -> anyhow::Result<()> {
|
fn test_crc8_smbus() -> anyhow::Result<()> {
|
||||||
let req_vec = vec![0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00];
|
let req_vec = vec![
|
||||||
|
0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00,
|
||||||
|
];
|
||||||
let checksum = calc_crc8_smbus(req_vec.as_slice());
|
let checksum = calc_crc8_smbus(req_vec.as_slice());
|
||||||
assert_eq!(checksum, 0x2F);
|
assert_eq!(checksum, 0x2F);
|
||||||
|
|
||||||
|
|||||||
706
src/tui.rs
Normal file
706
src/tui.rs
Normal file
@@ -0,0 +1,706 @@
|
|||||||
|
use std::{fs, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use crossterm::event::{self, Event};
|
||||||
|
use ratatui::{
|
||||||
|
DefaultTerminal, Frame,
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::Line,
|
||||||
|
widgets::{Block, Borders, Paragraph, Wrap},
|
||||||
|
};
|
||||||
|
use ratatui_textarea::{CursorMove, DataCursor, Input, Key, TextArea};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::{SerialConnectionState, shutdown_all_sessions},
|
||||||
|
cmd,
|
||||||
|
serial_core::utils::serial_enum,
|
||||||
|
};
|
||||||
|
|
||||||
|
const APP_POLL_INTERVAL_MS: u64 = 250;
|
||||||
|
const MAX_COMMAND_LINES: usize = 512;
|
||||||
|
const COMMAND_INPUT_TITLE: &str = "Command Input [Enter=run Tab=complete]";
|
||||||
|
const COMPLETION_PREVIEW_LIMIT: usize = 4;
|
||||||
|
const COMMAND_COMPLETIONS: &[&str] = &[
|
||||||
|
"/help", "/scan", "/status", "/open", "/close", "/export", "/set", "/echo", "/exit", "/quit",
|
||||||
|
];
|
||||||
|
const SETTING_COMPLETIONS: &[&str] = &["export"];
|
||||||
|
|
||||||
|
pub async fn run() -> Result<()> {
|
||||||
|
let serial_state = Arc::new(SerialConnectionState::new());
|
||||||
|
let mut terminal = ratatui::init();
|
||||||
|
let run_result = {
|
||||||
|
let mut app = TuiApp::new(Arc::clone(&serial_state));
|
||||||
|
app.run(&mut terminal).await
|
||||||
|
};
|
||||||
|
let shutdown_result = shutdown_all_sessions(&serial_state).await;
|
||||||
|
ratatui::restore();
|
||||||
|
|
||||||
|
run_result?;
|
||||||
|
shutdown_result.map_err(|err| anyhow!("failed to close active serial sessions: {err}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TuiApp {
|
||||||
|
should_quit: bool,
|
||||||
|
command_output: Vec<String>,
|
||||||
|
command_input: TextArea<'static>,
|
||||||
|
serial_state: Arc<SerialConnectionState>,
|
||||||
|
completion_hint: Option<String>,
|
||||||
|
completion_cycle: Option<CompletionCycle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TuiApp {
|
||||||
|
fn new(serial_state: Arc<SerialConnectionState>) -> Self {
|
||||||
|
let mut app = Self {
|
||||||
|
should_quit: false,
|
||||||
|
command_output: Vec::new(),
|
||||||
|
command_input: new_command_input(),
|
||||||
|
serial_state,
|
||||||
|
completion_hint: None,
|
||||||
|
completion_cycle: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
app.push_command_lines([
|
||||||
|
"JE-Skin CLI TUI".to_string(),
|
||||||
|
"Streaming serial text has been disabled to keep the terminal responsive.".to_string(),
|
||||||
|
"Use /scan, /open <port>, /status, /export <port>, /set export <dir>, /close <port>, /close, /exit.".to_string(),
|
||||||
|
"The right pane now shows active collectors only.".to_string(),
|
||||||
|
"Press Tab to autocomplete commands and paths.".to_string(),
|
||||||
|
]);
|
||||||
|
app.refresh_input_block();
|
||||||
|
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
|
||||||
|
while !self.should_quit {
|
||||||
|
terminal.draw(|frame| self.draw(frame))?;
|
||||||
|
|
||||||
|
if event::poll(Duration::from_millis(APP_POLL_INTERVAL_MS))? {
|
||||||
|
match event::read()? {
|
||||||
|
Event::Key(key_event) => self.handle_key(Input::from(key_event)).await?,
|
||||||
|
Event::Resize(_, _) => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_key(&mut self, input: Input) -> Result<()> {
|
||||||
|
match input {
|
||||||
|
Input { key: Key::Esc, .. }
|
||||||
|
| Input {
|
||||||
|
key: Key::Char('c'),
|
||||||
|
ctrl: true,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
self.should_quit = true;
|
||||||
|
}
|
||||||
|
Input {
|
||||||
|
key: Key::Enter, ..
|
||||||
|
} => {
|
||||||
|
self.invalidate_completion();
|
||||||
|
self.submit_command().await?;
|
||||||
|
}
|
||||||
|
Input { key: Key::Tab, .. } => {
|
||||||
|
self.complete_input();
|
||||||
|
}
|
||||||
|
Input { key: Key::Null, .. } => {}
|
||||||
|
other => {
|
||||||
|
self.invalidate_completion();
|
||||||
|
self.command_input.input(other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn submit_command(&mut self) -> Result<()> {
|
||||||
|
let raw_command = self.command_input.lines().join("\n");
|
||||||
|
let command = raw_command.trim().to_string();
|
||||||
|
self.command_input = new_command_input();
|
||||||
|
|
||||||
|
if command.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.push_command_line(format!("> {command}"));
|
||||||
|
let response = cmd::execute_input(&command, Arc::clone(&self.serial_state)).await;
|
||||||
|
self.push_command_lines(response.lines);
|
||||||
|
self.should_quit |= response.should_exit;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complete_input(&mut self) {
|
||||||
|
if self.apply_cycle_completion() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = self.current_input_line();
|
||||||
|
let DataCursor(_, cursor_col) = self.command_input.cursor();
|
||||||
|
let Some(request) = build_completion_request(&line, cursor_col) else {
|
||||||
|
self.set_completion_hint(Some("No completion available".to_string()));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let candidates = self.completion_candidates(&request);
|
||||||
|
if candidates.is_empty() {
|
||||||
|
self.set_completion_hint(Some("No completion matches".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidates.len() == 1 {
|
||||||
|
let replacement = finalize_unique_completion(&request, &candidates[0], &line);
|
||||||
|
self.replace_input_range(request.start, request.end, &replacement);
|
||||||
|
self.invalidate_completion();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let common_prefix = longest_common_prefix(&candidates);
|
||||||
|
let common_prefix_len = common_prefix.chars().count();
|
||||||
|
let token_len = request.token.chars().count();
|
||||||
|
let preview = completion_preview(&candidates);
|
||||||
|
|
||||||
|
if common_prefix_len > token_len {
|
||||||
|
self.replace_input_range(request.start, request.end, &common_prefix);
|
||||||
|
self.completion_cycle = Some(CompletionCycle::new(
|
||||||
|
request.start,
|
||||||
|
request.start + common_prefix_len,
|
||||||
|
candidates,
|
||||||
|
));
|
||||||
|
self.set_completion_hint(Some(format!("Matches: {preview}")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.completion_cycle = Some(CompletionCycle::new(request.start, request.end, candidates));
|
||||||
|
self.set_completion_hint(Some(format!("Tab again to cycle: {preview}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_cycle_completion(&mut self) -> bool {
|
||||||
|
let Some(mut cycle) = self.completion_cycle.take() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if cycle.candidates.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate_index = cycle.next_index % cycle.candidates.len();
|
||||||
|
let candidate = cycle.candidates[candidate_index].clone();
|
||||||
|
let new_end = cycle.start + candidate.chars().count();
|
||||||
|
self.replace_input_range(cycle.start, cycle.end, &candidate);
|
||||||
|
cycle.end = new_end;
|
||||||
|
cycle.next_index = candidate_index + 1;
|
||||||
|
let preview = completion_preview(&cycle.candidates);
|
||||||
|
self.completion_cycle = Some(cycle);
|
||||||
|
self.set_completion_hint(Some(format!("Cycling: {preview}")));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn completion_candidates(&self, request: &CompletionRequest) -> Vec<String> {
|
||||||
|
let mut candidates = match &request.kind {
|
||||||
|
CompletionKind::Command => command_completion_candidates(&request.token),
|
||||||
|
CompletionKind::Setting => setting_completion_candidates(&request.token),
|
||||||
|
CompletionKind::SerialPort { command_name } => {
|
||||||
|
serial_port_completion_candidates(command_name, &request.token, &self.serial_state)
|
||||||
|
}
|
||||||
|
CompletionKind::FileSystemPath => filesystem_path_candidates(&request.token),
|
||||||
|
};
|
||||||
|
|
||||||
|
candidates.sort();
|
||||||
|
candidates.dedup();
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_command_line(&mut self, line: impl Into<String>) {
|
||||||
|
self.command_output.push(line.into());
|
||||||
|
trim_lines(&mut self.command_output, MAX_COMMAND_LINES);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_command_lines<I>(&mut self, lines: I)
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = String>,
|
||||||
|
{
|
||||||
|
for line in lines {
|
||||||
|
self.push_command_line(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&self, frame: &mut Frame) {
|
||||||
|
let columns = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
|
||||||
|
.split(frame.area());
|
||||||
|
|
||||||
|
let left = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(6), Constraint::Length(3)])
|
||||||
|
.split(columns[0]);
|
||||||
|
|
||||||
|
let left_title = Line::styled(
|
||||||
|
"Command Console",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
let left_output = Paragraph::new(render_lines(
|
||||||
|
&self.command_output,
|
||||||
|
"No command output yet. Type /help.",
|
||||||
|
))
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(left_title))
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((scroll_offset(left[0], self.command_output.len()), 0));
|
||||||
|
frame.render_widget(left_output, left[0]);
|
||||||
|
|
||||||
|
frame.render_widget(&self.command_input, left[1]);
|
||||||
|
|
||||||
|
let collector_lines = self
|
||||||
|
.serial_state
|
||||||
|
.collector_lines()
|
||||||
|
.unwrap_or_else(|err| vec![format!("Read collector status failed: {err}")]);
|
||||||
|
let collector_count = self
|
||||||
|
.serial_state
|
||||||
|
.active_ports()
|
||||||
|
.map(|ports| ports.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let right_title = Line::styled(
|
||||||
|
format!("Collectors [{collector_count}]"),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
let right_output = Paragraph::new(render_lines(
|
||||||
|
&collector_lines,
|
||||||
|
"No active serial collectors.",
|
||||||
|
))
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(right_title))
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((scroll_offset(columns[1], collector_lines.len()), 0));
|
||||||
|
frame.render_widget(right_output, columns[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate_completion(&mut self) {
|
||||||
|
self.completion_hint = None;
|
||||||
|
self.completion_cycle = None;
|
||||||
|
self.refresh_input_block();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_completion_hint(&mut self, hint: Option<String>) {
|
||||||
|
self.completion_hint = hint;
|
||||||
|
self.refresh_input_block();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_input_block(&mut self) {
|
||||||
|
let title = match self.completion_hint.as_deref() {
|
||||||
|
Some(hint) => format!("{COMMAND_INPUT_TITLE} | {hint}"),
|
||||||
|
None => COMMAND_INPUT_TITLE.to_string(),
|
||||||
|
};
|
||||||
|
self.command_input
|
||||||
|
.set_block(Block::default().borders(Borders::ALL).title(title));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_input_line(&self) -> String {
|
||||||
|
self.command_input
|
||||||
|
.lines()
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_input_range(&mut self, start: usize, end: usize, replacement: &str) {
|
||||||
|
let line = self.current_input_line();
|
||||||
|
let start_byte = char_to_byte_index(&line, start);
|
||||||
|
let end_byte = char_to_byte_index(&line, end);
|
||||||
|
let new_line = format!(
|
||||||
|
"{}{}{}",
|
||||||
|
&line[..start_byte],
|
||||||
|
replacement,
|
||||||
|
&line[end_byte..]
|
||||||
|
);
|
||||||
|
let cursor_col = start + replacement.chars().count();
|
||||||
|
|
||||||
|
self.command_input.clear();
|
||||||
|
self.command_input.insert_str(&new_line);
|
||||||
|
self.command_input.move_cursor(CursorMove::Jump(
|
||||||
|
0,
|
||||||
|
cursor_col.min(u16::MAX as usize) as u16,
|
||||||
|
));
|
||||||
|
self.refresh_input_block();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_command_input() -> TextArea<'static> {
|
||||||
|
let mut input = TextArea::default();
|
||||||
|
input.set_cursor_line_style(Style::default());
|
||||||
|
input.set_style(Style::default().fg(Color::White));
|
||||||
|
input.set_placeholder_text(
|
||||||
|
"/scan | /open /dev/ttyUSB0 | /export /dev/ttyUSB0 | /set export ./exports",
|
||||||
|
);
|
||||||
|
input
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct CompletionCycle {
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
candidates: Vec<String>,
|
||||||
|
next_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompletionCycle {
|
||||||
|
fn new(start: usize, end: usize, candidates: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
candidates,
|
||||||
|
next_index: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
enum CompletionKind {
|
||||||
|
Command,
|
||||||
|
Setting,
|
||||||
|
SerialPort { command_name: String },
|
||||||
|
FileSystemPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct CompletionRequest {
|
||||||
|
kind: CompletionKind,
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_completion_request(line: &str, cursor_col: usize) -> Option<CompletionRequest> {
|
||||||
|
if !line.starts_with('/') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let char_len = line.chars().count();
|
||||||
|
let cursor_col = cursor_col.min(char_len);
|
||||||
|
let (start, end) = token_bounds(line, cursor_col);
|
||||||
|
let token = slice_by_chars(line, start, end).to_string();
|
||||||
|
|
||||||
|
if start == 0 {
|
||||||
|
return Some(CompletionRequest {
|
||||||
|
kind: CompletionKind::Command,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let command_name = line.split_whitespace().next()?.to_string();
|
||||||
|
let token_prefix = &line[..char_to_byte_index(line, start)];
|
||||||
|
let token_position = token_prefix.split_whitespace().count();
|
||||||
|
|
||||||
|
if command_name == "/set" {
|
||||||
|
if token_position == 1 {
|
||||||
|
return Some(CompletionRequest {
|
||||||
|
kind: CompletionKind::Setting,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
let _ = parts.next();
|
||||||
|
let setting_name = parts.next().unwrap_or_default();
|
||||||
|
if setting_name == "export" {
|
||||||
|
return Some(CompletionRequest {
|
||||||
|
kind: CompletionKind::FileSystemPath,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(command_name.as_str(), "/open" | "/close" | "/export") {
|
||||||
|
return Some(CompletionRequest {
|
||||||
|
kind: CompletionKind::SerialPort { command_name },
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_completion_candidates(prefix: &str) -> Vec<String> {
|
||||||
|
COMMAND_COMPLETIONS
|
||||||
|
.iter()
|
||||||
|
.filter(|command| command.starts_with(prefix))
|
||||||
|
.map(|command| (*command).to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setting_completion_candidates(prefix: &str) -> Vec<String> {
|
||||||
|
SETTING_COMPLETIONS
|
||||||
|
.iter()
|
||||||
|
.filter(|setting| setting.starts_with(prefix))
|
||||||
|
.map(|setting| (*setting).to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serial_port_completion_candidates(
|
||||||
|
command_name: &str,
|
||||||
|
prefix: &str,
|
||||||
|
state: &SerialConnectionState,
|
||||||
|
) -> Vec<String> {
|
||||||
|
match command_name {
|
||||||
|
"/open" => {
|
||||||
|
let mut candidates = serial_enum()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|port| port.starts_with(prefix))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
candidates.extend(filesystem_path_candidates(prefix));
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
"/close" => state
|
||||||
|
.active_ports()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|port| port.starts_with(prefix))
|
||||||
|
.collect(),
|
||||||
|
"/export" => state
|
||||||
|
.exportable_ports()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|port| port.starts_with(prefix))
|
||||||
|
.collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filesystem_path_candidates(prefix: &str) -> Vec<String> {
|
||||||
|
if prefix.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (search_dir, dir_prefix, name_prefix, separator) = filesystem_search_parts(prefix);
|
||||||
|
let entries = match fs::read_dir(&search_dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let file_name = file_name.to_string_lossy();
|
||||||
|
if !file_name.starts_with(name_prefix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut candidate = format!("{dir_prefix}{file_name}");
|
||||||
|
if entry.path().is_dir() {
|
||||||
|
candidate.push(separator);
|
||||||
|
}
|
||||||
|
candidates.push(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort();
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filesystem_search_parts(prefix: &str) -> (String, String, &str, char) {
|
||||||
|
let separator = if prefix.contains('\\') && !prefix.contains('/') {
|
||||||
|
'\\'
|
||||||
|
} else {
|
||||||
|
'/'
|
||||||
|
};
|
||||||
|
|
||||||
|
match prefix.rfind(['/', '\\']) {
|
||||||
|
Some(index) => {
|
||||||
|
let dir_prefix = &prefix[..=index];
|
||||||
|
let search_dir = if dir_prefix.is_empty() {
|
||||||
|
".".to_string()
|
||||||
|
} else {
|
||||||
|
dir_prefix.to_string()
|
||||||
|
};
|
||||||
|
let name_prefix = &prefix[index + 1..];
|
||||||
|
(search_dir, dir_prefix.to_string(), name_prefix, separator)
|
||||||
|
}
|
||||||
|
None => (".".to_string(), String::new(), prefix, separator),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize_unique_completion(request: &CompletionRequest, candidate: &str, line: &str) -> String {
|
||||||
|
match &request.kind {
|
||||||
|
CompletionKind::Command => {
|
||||||
|
if command_takes_argument(candidate) && request.end == line.chars().count() {
|
||||||
|
format!("{candidate} ")
|
||||||
|
} else {
|
||||||
|
candidate.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CompletionKind::Setting => {
|
||||||
|
if request.end == line.chars().count() {
|
||||||
|
format!("{candidate} ")
|
||||||
|
} else {
|
||||||
|
candidate.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CompletionKind::SerialPort { .. } => candidate.to_string(),
|
||||||
|
CompletionKind::FileSystemPath => {
|
||||||
|
if candidate.ends_with('/') || request.end < line.chars().count() {
|
||||||
|
candidate.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{candidate} ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_takes_argument(command: &str) -> bool {
|
||||||
|
matches!(command, "/open" | "/close" | "/export" | "/set" | "/echo")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn completion_preview(candidates: &[String]) -> String {
|
||||||
|
let preview = candidates
|
||||||
|
.iter()
|
||||||
|
.take(COMPLETION_PREVIEW_LIMIT)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
if candidates.len() > COMPLETION_PREVIEW_LIMIT {
|
||||||
|
format!("{preview}, ...")
|
||||||
|
} else {
|
||||||
|
preview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn longest_common_prefix(items: &[String]) -> String {
|
||||||
|
let Some(first) = items.first() else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut prefix = first.clone();
|
||||||
|
while !prefix.is_empty() {
|
||||||
|
if items.iter().all(|item| item.starts_with(&prefix)) {
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
prefix.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_bounds(line: &str, cursor_col: usize) -> (usize, usize) {
|
||||||
|
let chars = line.chars().collect::<Vec<_>>();
|
||||||
|
let char_len = chars.len();
|
||||||
|
let cursor = cursor_col.min(char_len);
|
||||||
|
|
||||||
|
let mut start = cursor;
|
||||||
|
while start > 0 && !chars[start - 1].is_whitespace() {
|
||||||
|
start -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut end = cursor;
|
||||||
|
while end < char_len && !chars[end].is_whitespace() {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slice_by_chars(text: &str, start: usize, end: usize) -> &str {
|
||||||
|
let start_byte = char_to_byte_index(text, start);
|
||||||
|
let end_byte = char_to_byte_index(text, end);
|
||||||
|
&text[start_byte..end_byte]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn char_to_byte_index(text: &str, char_index: usize) -> usize {
|
||||||
|
text.char_indices()
|
||||||
|
.nth(char_index)
|
||||||
|
.map(|(byte_index, _)| byte_index)
|
||||||
|
.unwrap_or(text.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_lines(lines: &mut Vec<String>, max_lines: usize) {
|
||||||
|
if lines.len() > max_lines {
|
||||||
|
let overflow = lines.len() - max_lines;
|
||||||
|
lines.drain(0..overflow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_lines(lines: &[String], empty_hint: &str) -> String {
|
||||||
|
if lines.is_empty() {
|
||||||
|
empty_hint.to_string()
|
||||||
|
} else {
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_offset(area: Rect, line_count: usize) -> u16 {
|
||||||
|
let visible_lines = area.height.saturating_sub(2) as usize;
|
||||||
|
line_count.saturating_sub(visible_lines) as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_completion_request() {
|
||||||
|
let request = build_completion_request("/op", 3).unwrap();
|
||||||
|
assert_eq!(request.kind, CompletionKind::Command);
|
||||||
|
assert_eq!(request.token, "/op");
|
||||||
|
assert_eq!((request.start, request.end), (0, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_open_path_completion_request() {
|
||||||
|
let line = "/open /dev/ttyU";
|
||||||
|
let request = build_completion_request(line, line.chars().count()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
request.kind,
|
||||||
|
CompletionKind::SerialPort {
|
||||||
|
command_name: "/open".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(request.token, "/dev/ttyU");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_setting_completion_request() {
|
||||||
|
let line = "/set exp";
|
||||||
|
let request = build_completion_request(line, line.chars().count()).unwrap();
|
||||||
|
assert_eq!(request.kind, CompletionKind::Setting);
|
||||||
|
assert_eq!(request.token, "exp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_set_export_path_request() {
|
||||||
|
let line = "/set export ./out";
|
||||||
|
let request = build_completion_request(line, line.chars().count()).unwrap();
|
||||||
|
assert_eq!(request.kind, CompletionKind::FileSystemPath);
|
||||||
|
assert_eq!(request.token, "./out");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn longest_common_prefix_for_paths() {
|
||||||
|
let prefix = longest_common_prefix(&[
|
||||||
|
"/dev/ttyUSB0".to_string(),
|
||||||
|
"/dev/ttyUSB1".to_string(),
|
||||||
|
"/dev/ttyUSB2".to_string(),
|
||||||
|
]);
|
||||||
|
assert_eq!(prefix, "/dev/ttyUSB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_completion_candidates_match_prefix() {
|
||||||
|
let candidates = command_completion_candidates("/st");
|
||||||
|
assert_eq!(candidates, vec!["/status".to_string()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user