1use std::{
6 cmp::min,
7 collections::{BTreeMap, HashMap, HashSet, VecDeque},
8 fs::File,
9 io::{self, Read},
10 str::FromStr,
11 time::{Duration, Instant},
12};
13
14use clap::Parser;
15use crossbeam::channel::{self, Sender};
16use ratatui::{
17 crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
18 layout::{Constraint, Direction, Layout, Rect},
19 prelude::Stylize,
20 style::{Color, Modifier, Style},
21 symbols,
22 text::{Line, Span},
23 widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, List, Paragraph},
24 DefaultTerminal, Frame,
25};
26use toml_edit::{DocumentMut, InlineTable, Value};
27use tui_prompts::{State, TextState};
28use twinleaf::{
29 data::{AlignedWindow, Buffer, ColumnBatch, ColumnData, DeviceFullMetadata, Sample},
30 device::{util, DeviceEvent, DeviceTree, RpcClient, RpcList, RpcRegistry, TreeEvent, TreeItem},
31 tio::{
32 self,
33 proto::{
34 identifiers::{ColumnKey, StreamKey},
35 DeviceRoute, ProxyStatus,
36 },
37 },
38};
39use crate::TioOpts;
40use welch_sde::{Build, SpectralDensity};
41
42#[derive(Parser, Debug)]
43#[command(name = "tio-monitor", version, about = "Display live sensor data")]
44struct Cli {
45 #[command(flatten)]
46 tio: TioOpts,
47 #[arg(short = 'a', long = "all")]
48 all: bool,
49 #[arg(long = "fps", default_value_t = 20)]
50 fps: u32,
51 #[arg(short = 'c', long = "colors")]
52 colors: Option<String>,
53}
54
55#[derive(Debug, Clone)]
56pub enum NavPos {
57 EmptyDevice {
58 device_idx: usize,
59 route: DeviceRoute,
60 },
61 Column {
62 device_idx: usize,
63 stream_idx: usize,
64 spec: ColumnKey,
65 },
66}
67
68impl NavPos {
69 pub fn device_idx(&self) -> usize {
70 match self {
71 NavPos::EmptyDevice { device_idx, .. } => *device_idx,
72 NavPos::Column { device_idx, .. } => *device_idx,
73 }
74 }
75
76 pub fn route(&self) -> &DeviceRoute {
77 match self {
78 NavPos::EmptyDevice { route, .. } => route,
79 NavPos::Column { spec, .. } => &spec.route,
80 }
81 }
82
83 pub fn stream_idx(&self) -> Option<usize> {
84 match self {
85 NavPos::EmptyDevice { .. } => None,
86 NavPos::Column { stream_idx, .. } => Some(*stream_idx),
87 }
88 }
89
90 pub fn column_idx(&self) -> Option<usize> {
91 match self {
92 NavPos::EmptyDevice { .. } => None,
93 NavPos::Column { spec, .. } => Some(spec.column_id),
94 }
95 }
96
97 pub fn spec(&self) -> Option<&ColumnKey> {
98 match self {
99 NavPos::EmptyDevice { .. } => None,
100 NavPos::Column { spec, .. } => Some(spec),
101 }
102 }
103}
104
105#[derive(Debug, Clone, Default)]
106pub struct Nav {
107 pub idx: usize,
108}
109
110impl Nav {
111 pub fn step_linear(&mut self, items: &[NavPos], backward: bool) {
113 if items.is_empty() {
114 return;
115 }
116 let len = items.len();
117 self.idx = if backward {
118 (self.idx + len - 1) % len
119 } else {
120 (self.idx + 1) % len
121 };
122 }
123
124 pub fn step_within_stream(&mut self, items: &[NavPos], backward: bool) {
126 if items.is_empty() {
127 return;
128 }
129 let cur = &items[self.idx];
130 let (dev, stream) = match cur {
131 NavPos::EmptyDevice { .. } => return,
132 NavPos::Column {
133 device_idx,
134 stream_idx,
135 ..
136 } => (*device_idx, *stream_idx),
137 };
138
139 let siblings: Vec<usize> = items
140 .iter()
141 .enumerate()
142 .filter_map(|(i, pos)| match pos {
143 NavPos::Column {
144 device_idx,
145 stream_idx,
146 ..
147 } if *device_idx == dev && *stream_idx == stream => Some(i),
148 _ => None,
149 })
150 .collect();
151
152 if let Some(pos) = siblings.iter().position(|&i| i == self.idx) {
153 let len = siblings.len();
154 let new_pos = if backward {
155 (pos + len - 1) % len
156 } else {
157 (pos + 1) % len
158 };
159 self.idx = siblings[new_pos];
160 }
161 }
162
163 pub fn step_device(&mut self, items: &[NavPos], backward: bool) {
165 if items.is_empty() {
166 return;
167 }
168
169 let cur = &items[self.idx];
170 let cur_device = cur.device_idx();
171 let cur_stream = cur.stream_idx().unwrap_or(0);
172 let cur_column = cur.column_idx().unwrap_or(0);
173
174 let mut device_indices: Vec<usize> = items.iter().map(|p| p.device_idx()).collect();
176 device_indices.sort();
177 device_indices.dedup();
178
179 if device_indices.len() <= 1 {
180 return;
181 }
182
183 let dev_pos = device_indices
185 .iter()
186 .position(|&d| d == cur_device)
187 .unwrap_or(0);
188 let len = device_indices.len();
189 let new_dev_pos = if backward {
190 (dev_pos + len - 1) % len
191 } else {
192 (dev_pos + 1) % len
193 };
194 let target_device = device_indices[new_dev_pos];
195
196 self.idx = items
198 .iter()
199 .enumerate()
200 .filter(|(_, pos)| pos.device_idx() == target_device)
201 .map(|(i, pos)| {
202 let dist = match pos {
203 NavPos::EmptyDevice { .. } => (0, 0),
204 NavPos::Column {
205 stream_idx, spec, ..
206 } => {
207 let s = (*stream_idx as isize - cur_stream as isize).abs();
208 let c = (spec.column_id as isize - cur_column as isize).abs();
209 (s, c)
210 }
211 };
212 (i, dist)
213 })
214 .min_by_key(|&(_, dist)| dist)
215 .map(|(i, _)| i)
216 .unwrap_or(self.idx);
217 }
218
219 pub fn home(&mut self, items: &[NavPos]) {
220 if !items.is_empty() {
221 self.idx = 0;
222 }
223 }
224
225 pub fn end(&mut self, items: &[NavPos]) {
226 if !items.is_empty() {
227 self.idx = items.len() - 1;
228 }
229 }
230}
231
232#[derive(Debug, Clone, Default)]
233pub struct Theme {
234 pub value_bounds: HashMap<String, (std::ops::RangeInclusive<f64>, bool)>,
235}
236
237impl Theme {
238 pub fn get_value_color(&self, stream: &str, col: &str, val: f64) -> Option<Color> {
239 if val.is_nan() {
240 return Some(Color::Yellow);
241 }
242 let key = format!("{}.{}", stream, col);
243 if let Some((range, is_temp)) = self.value_bounds.get(&key) {
244 if val < *range.start() {
245 Some(if *is_temp { Color::Blue } else { Color::Red })
246 } else if val > *range.end() {
247 Some(Color::Red)
248 } else {
249 Some(Color::Green)
250 }
251 } else {
252 None
253 }
254 }
255}
256
257pub struct StyleContext {
258 pub is_selected: bool,
259 pub is_stale: bool,
260 pub in_plot_mode: bool,
261 pub base_color: Color,
262}
263
264impl Default for StyleContext {
265 fn default() -> Self {
266 Self {
267 is_selected: false,
268 is_stale: false,
269 in_plot_mode: false,
270 base_color: Color::Reset,
271 }
272 }
273}
274
275impl StyleContext {
276 pub fn new() -> Self {
277 Self::default()
278 }
279 pub fn selected(mut self, yes: bool) -> Self {
280 self.is_selected = yes;
281 self
282 }
283 pub fn stale(mut self, yes: bool) -> Self {
284 self.is_stale = yes;
285 self
286 }
287 pub fn plot_mode(mut self, yes: bool) -> Self {
288 self.in_plot_mode = yes;
289 self
290 }
291 pub fn color(mut self, c: Color) -> Self {
292 self.base_color = c;
293 self
294 }
295
296 pub fn resolve(&self) -> Style {
297 let mut s = Style::default().fg(self.base_color);
298 if self.is_stale {
299 s = s.add_modifier(Modifier::DIM);
300 }
301 if self.is_selected {
302 s = s.add_modifier(Modifier::BOLD);
303 if !self.in_plot_mode {
304 s = s.add_modifier(Modifier::RAPID_BLINK);
305 }
306 }
307 s
308 }
309}
310
311#[derive(Debug, Clone, Default)]
312pub struct DeviceStatus {
313 pub last_heartbeat: Option<Instant>,
314 pub connected: bool,
315}
316
317impl DeviceStatus {
318 pub fn on_heartbeat(&mut self) {
319 self.last_heartbeat = Some(Instant::now());
320 self.connected = true;
321 }
322
323 pub fn is_alive(&self, timeout: Duration) -> bool {
324 self.last_heartbeat
325 .map(|t| t.elapsed() < timeout)
326 .unwrap_or(false)
327 }
328}
329
330#[derive(Debug, Clone, PartialEq)]
331pub enum Mode {
332 Normal,
333 Command,
334}
335
336#[derive(Debug, Clone)]
337pub enum Action {
338 Quit,
339 SetMode(Mode),
340 AutoCompleteTab,
341 AutoCompleteBack,
342 NewCommandString,
343 SubmitCommand,
344 AcceptCompletion,
345 NavUp,
346 NavDown,
347 NavLeft,
348 NavRight,
349 NavTabNext,
350 NavTabPrev,
351 NavScroll(i16),
352 NavHome,
353 NavEnd,
354 TogglePlot,
355 ClosePlot,
356 ToggleFft,
357 ToggleFooter,
358 ToggleRoutes,
359 AdjustWindow(f64),
360 AdjustPlotWidth(i16),
361 AdjustPrecision(i8),
362 HistoryNavigate(HistDir),
363}
364
365#[derive(Debug, Clone)]
366pub enum HistDir {
367 Up,
368 Down,
369}
370
371#[derive(Debug, Clone)]
372pub struct ViewConfig {
373 pub show_plot: bool,
374 pub show_footer: bool,
375 pub show_routes: bool,
376 pub show_fft: bool,
377 pub plot_window_seconds: f64,
378 pub plot_width_percent: u16,
379 pub axis_precision: usize,
380 pub follow_selection: bool,
381 pub scroll: u16,
382 pub desc_width: usize,
383 pub units_width: usize,
384 pub theme: Theme,
385}
386
387impl Default for ViewConfig {
388 fn default() -> Self {
389 Self {
390 show_plot: false,
391 show_footer: true,
392 show_routes: false,
393 show_fft: false,
394 plot_window_seconds: 5.0,
395 plot_width_percent: 70,
396 axis_precision: 3,
397 follow_selection: true,
398 scroll: 0,
399 desc_width: 0,
400 units_width: 0,
401 theme: Theme::default(),
402 }
403 }
404}
405
406#[derive(Debug)]
407pub struct RpcReq {
408 pub route: DeviceRoute,
409 pub meta: Option<u16>,
410 pub method: String,
411 pub arg: Option<String>,
412}
413
414#[derive(Debug)]
415pub struct RpcResp {
416 pub result: Result<String, String>,
417}
418
419enum RpcWorkerReq {
420 FetchList(DeviceRoute),
421 Execute(RpcReq),
422}
423
424enum RpcWorkerResp {
425 List(RpcList),
426 RpcResult(RpcResp),
427}
428
429fn exec_rpc(client: &RpcClient, req: &RpcReq) -> Result<String, String> {
430 let meta = match req.meta {
431 Some(m) => m,
432 None => client
433 .rpc(&req.route, "rpc.info", &req.method)
434 .map_err(|_| format!("Unknown RPC: {}", req.method))?,
435 };
436
437 let spec = util::parse_rpc_spec(meta, req.method.clone());
438
439 let payload = if let Some(ref s) = req.arg {
440 util::rpc_encode_arg(s, &spec.data_kind).map_err(|e| format!("{:?}", e))?
441 } else {
442 Vec::new()
443 };
444
445 let reply_bytes = client
446 .raw_rpc(&req.route, &req.method, &payload)
447 .map_err(|e| format!("{:?}", e))?;
448
449 if reply_bytes.is_empty() {
450 return Ok("OK".to_string());
451 }
452
453 let value =
454 util::rpc_decode_reply(&reply_bytes, &spec.data_kind).map_err(|e| format!("{:?}", e))?;
455
456 Ok(util::format_rpc_value_for_cli(&value, &spec.data_kind))
457}
458
459pub struct App {
460 pub all: bool,
461 pub parent_route: DeviceRoute,
462 pub mode: Mode,
463 pub view: ViewConfig,
464
465 pub nav: Nav,
466 pub nav_items: Vec<NavPos>,
467
468 pub discovered_routes: HashSet<DeviceRoute>,
469 pub device_status: HashMap<DeviceRoute, DeviceStatus>,
470 pub last: BTreeMap<StreamKey, (Sample, Instant)>,
471 pub device_metadata: HashMap<DeviceRoute, DeviceFullMetadata>,
472 pub window_aligned: Option<AlignedWindow>,
473
474 pub footer_height: u16,
475 pub rpc_registries: HashMap<DeviceRoute, RpcRegistry>,
476 pub suggested_rpcs: VecDeque<String>,
477 pub suggested_rpcs_len: usize,
478 pub suggested_rpcs_ind: usize,
479
480 pub input_state: TextState<'static>,
481 pub current_completion: String,
482 pub cmd_history: Vec<String>,
483 pub history_ptr: usize,
484 pub present_command: String,
485 pub last_rpc_result: Option<(String, Color)>,
486 pub last_rpc_command: String,
487 pub blink_state: bool,
488 pub last_blink: Instant,
489}
490
491const RPCLIST_MAX_LEN: usize = 12;
492
493impl App {
494 pub fn new(all: bool, parent_route: &DeviceRoute) -> Self {
495 Self {
496 all,
497 parent_route: parent_route.clone(),
498 mode: Mode::Normal,
499 view: ViewConfig::default(),
500 nav: Nav::default(),
501 nav_items: Vec::new(),
502 discovered_routes: HashSet::new(),
503 device_status: HashMap::new(),
504 last: BTreeMap::new(),
505 device_metadata: HashMap::new(),
506 window_aligned: None,
507 footer_height: 0,
508 rpc_registries: HashMap::new(),
509 suggested_rpcs: VecDeque::from(vec![String::new()]),
510 suggested_rpcs_len: 1,
511 suggested_rpcs_ind: 0,
512 input_state: TextState::default(),
513 current_completion: String::new(),
514 cmd_history: Vec::new(),
515 history_ptr: 0,
516 present_command: String::new(),
517 last_rpc_result: None,
518 last_rpc_command: String::new(),
519 blink_state: true,
520 last_blink: Instant::now(),
521 }
522 }
523
524 fn update(&mut self, action: Action, rpc_tx: &Sender<RpcWorkerReq>) -> bool {
525 match action {
526 Action::Quit => return true,
527 Action::SetMode(Mode::Command) => {
528 self.input_state = TextState::default();
529 self.input_state.focus();
530 self.history_ptr = self.cmd_history.len();
531 self.update_command_list();
532 self.mode = Mode::Command;
533 }
534 Action::SetMode(Mode::Normal) => {
535 self.mode = Mode::Normal;
536 self.input_state.blur();
537 }
538 Action::AutoCompleteTab => self.tab_complete(),
539 Action::AutoCompleteBack => self.tab_back_complete(),
540 Action::NewCommandString => self.update_command_list(),
541 Action::SubmitCommand => self.submit_command(rpc_tx),
542 Action::AcceptCompletion => self.accept_completion(),
543 Action::HistoryNavigate(dir) => self.navigate_history(dir),
544 Action::NavUp => {
545 self.view.follow_selection = true;
546 self.nav.step_linear(&self.nav_items, true);
547 }
548 Action::NavDown => {
549 self.view.follow_selection = true;
550 self.nav.step_linear(&self.nav_items, false);
551 }
552 Action::NavLeft => {
553 self.view.follow_selection = true;
554 self.nav.step_within_stream(&self.nav_items, true);
555 }
556 Action::NavRight => {
557 self.view.follow_selection = true;
558 self.nav.step_within_stream(&self.nav_items, false);
559 }
560 Action::NavTabNext => {
561 self.view.follow_selection = true;
562 self.nav.step_device(&self.nav_items, false);
563 }
564 Action::NavTabPrev => {
565 self.view.follow_selection = true;
566 self.nav.step_device(&self.nav_items, true);
567 }
568 Action::NavScroll(delta) => {
569 self.view.follow_selection = false;
570 self.view.scroll = if delta < 0 {
571 self.view.scroll.saturating_sub(delta.abs() as u16)
572 } else {
573 self.view.scroll.saturating_add(delta as u16)
574 };
575 }
576 Action::NavHome => {
577 self.view.follow_selection = true;
578 self.nav.home(&self.nav_items);
579 }
580 Action::NavEnd => {
581 self.view.follow_selection = true;
582 self.nav.end(&self.nav_items);
583 }
584 Action::TogglePlot => {
585 if self.current_selection().is_some() {
586 self.view.show_plot = !self.view.show_plot;
587 }
588 }
589 Action::ClosePlot => {
590 self.view.show_plot = false;
591 }
592 Action::ToggleFft => {
593 if self.view.show_plot {
594 self.view.show_fft = !self.view.show_fft;
595 }
596 }
597 Action::ToggleFooter => self.view.show_footer = !self.view.show_footer,
598 Action::ToggleRoutes => self.view.show_routes = !self.view.show_routes,
599 Action::AdjustWindow(d) => {
600 self.view.plot_window_seconds = (self.view.plot_window_seconds + d).clamp(0.5, 10.0)
601 }
602 Action::AdjustPlotWidth(d) => {
603 self.view.plot_width_percent =
604 (self.view.plot_width_percent as i16 + d).clamp(20, 90) as u16
605 }
606 Action::AdjustPrecision(delta) => {
607 let new_p = self.view.axis_precision as i16 + delta as i16;
608 self.view.axis_precision = new_p.clamp(0, 5) as usize;
609 }
610 }
611 false
612 }
613
614 fn complete_command(&mut self) {
615 let rpc = self.suggested_rpcs[self.suggested_rpcs_ind].clone();
616 self.current_completion = match rpc.get(self.input_state.value().len()..) {
617 Some(s) => {
618 if self.input_state.value().is_empty() {
619 rpc.clone()
620 } else if rpc.starts_with(self.input_state.value()) {
621 s.to_string()
622 } else {
623 String::new()
624 }
625 }
626 None => String::new(),
627 };
628 self.input_state.focus();
629 self.input_state.move_end();
630 }
631
632 fn tab_complete(&mut self) {
633 let max = std::cmp::min(RPCLIST_MAX_LEN, (self.footer_height - 5).into());
634 self.suggested_rpcs_ind = match (self.suggested_rpcs_ind, self.suggested_rpcs_len) {
635 (i, l) if i == l - 1 => 0,
636 (i, l) if l <= max => i + 1,
637 (i, _) if i < max / 2 => i + 1,
638 (i, _) => {
639 let front = self.suggested_rpcs.pop_front().unwrap();
641 self.suggested_rpcs.push_back(front);
642 i
643 }
644 };
645 self.complete_command();
646 }
647
648 fn tab_back_complete(&mut self) {
649 let max = std::cmp::min(RPCLIST_MAX_LEN, (self.footer_height - 5).into());
650 self.suggested_rpcs_ind = match (self.suggested_rpcs_ind, self.suggested_rpcs_len) {
651 (0, l) if l <= max => l - 1,
652 (0, _) => {
653 let back = self.suggested_rpcs.pop_back().unwrap();
655 self.suggested_rpcs.push_front(back);
656 0
657 }
658 (i, _) => i - 1,
659 };
660 self.complete_command();
661 }
662
663 fn update_command_list(&mut self) {
664 self.suggested_rpcs_ind = 0;
665 self.current_completion = String::new();
666 let line = self.input_state.value().to_string();
667 let query = line.split_whitespace().next().unwrap_or("");
668
669 let suggestions: Vec<String> =
670 if let Some(registry) = self.rpc_registries.get(&self.current_route()) {
671 if query.is_empty() {
672 registry
673 .children_of("")
674 .into_iter()
675 .map(|s| s + "...")
676 .collect()
677 } else {
678 registry.search(query)
679 }
680 } else {
681 Vec::new()
682 };
683
684 self.suggested_rpcs = VecDeque::from(suggestions);
685 self.suggested_rpcs_len = self.suggested_rpcs.len();
686 if !(1..=RPCLIST_MAX_LEN).contains(&self.suggested_rpcs_len) {
687 self.suggested_rpcs.push_back(String::new());
688 self.suggested_rpcs_len += 1;
689 }
690 self.complete_command();
691 }
692
693 fn accept_completion(&mut self) {
694 let mut complete_command: String;
695 if self.current_completion.is_empty() {
696 complete_command = self.suggested_rpcs[self.suggested_rpcs_ind].clone();
697 } else {
698 complete_command = format!(
699 "{}{}",
700 self.input_state.value().to_string(),
701 self.current_completion
702 );
703 complete_command = complete_command.replace("...", ".");
704 }
705 self.input_state = TextState::new().with_value(complete_command);
706 self.input_state.focus();
707 self.input_state.move_end();
708 self.update_command_list();
709 }
710
711 fn submit_command(&mut self, rpc_tx: &Sender<RpcWorkerReq>) {
712 let line = self.input_state.value().to_string();
713 if !line.contains(' ') && self.suggested_rpcs.get(self.suggested_rpcs_ind) != Some(&line) {
715 return self.accept_completion();
716 }
717 if line.trim().is_empty() {
718 return;
719 }
720 if self.cmd_history.last() != Some(&line) {
721 self.cmd_history.push(line.clone());
722 }
723 self.history_ptr = self.cmd_history.len();
724
725 let mut parts = line.split_whitespace();
726 if let Some(method) = parts.next() {
727 self.last_rpc_command = method.to_string();
728 let remainder: Vec<&str> = parts.collect();
729 let arg = if remainder.is_empty() {
730 None
731 } else {
732 Some(remainder.join(" "))
733 };
734 let route = self.current_route();
735 let meta = self
736 .rpc_registries
737 .get(&route)
738 .and_then(|r| r.find(method))
739 .map(|d| d.meta_raw);
740 let _ = rpc_tx.send(RpcWorkerReq::Execute(RpcReq {
741 route: route.clone(),
742 meta,
743 method: method.to_string(),
744 arg,
745 }));
746 self.last_rpc_result = Some((format!("Sent to {}...", route), Color::Yellow));
747 self.input_state = TextState::default();
748 self.input_state.focus();
749 self.update_command_list();
750 self.present_command = String::new();
751 }
752 }
753
754 fn update_rpclists(&mut self, list: RpcList) {
755 let registry = RpcRegistry::from(&list);
756 self.rpc_registries.insert(list.route.clone(), registry);
757 }
758
759 fn navigate_history(&mut self, dir: HistDir) {
760 if self.history_ptr == self.cmd_history.len() {
761 self.present_command = self.input_state.value().to_string();
762 };
763 self.history_ptr = match dir {
764 HistDir::Up => self.history_ptr.saturating_sub(1),
765 HistDir::Down => min(self.cmd_history.len(), self.history_ptr + 1),
766 };
767
768 self.input_state = TextState::new().with_value(
769 self.cmd_history
770 .get(self.history_ptr)
771 .unwrap_or(&self.present_command)
772 .clone(),
773 );
774 self.input_state.focus();
775 self.input_state.move_end();
776 self.update_command_list();
777 }
778
779 pub fn visible_routes(&self) -> Vec<DeviceRoute> {
780 if self.all {
781 let mut routes: Vec<_> = self.discovered_routes.iter().cloned().collect();
782 routes.sort();
783 routes
784 } else {
785 vec![self.parent_route.clone()]
786 }
787 }
788
789 pub fn rebuild_nav_items(&mut self) {
790 let routes = self.visible_routes();
791 let mut new_items = Vec::new();
792
793 for (dev_idx, route) in routes.iter().enumerate() {
794 let mut stream_ids: Vec<_> = self
795 .last
796 .keys()
797 .filter(|k| &k.route == route)
798 .map(|k| k.stream_id)
799 .collect();
800 stream_ids.sort();
801 stream_ids.dedup();
802
803 if stream_ids.is_empty() {
804 new_items.push(NavPos::EmptyDevice {
806 device_idx: dev_idx,
807 route: route.clone(),
808 });
809 } else {
810 for (stream_idx, sid) in stream_ids.iter().enumerate() {
811 let key = StreamKey::new(route.clone(), *sid);
812 if let Some((sample, _)) = self.last.get(&key) {
813 for (column_idx, _) in sample.columns.iter().enumerate() {
814 new_items.push(NavPos::Column {
815 device_idx: dev_idx,
816 stream_idx,
817 spec: ColumnKey {
818 route: route.clone(),
819 stream_id: *sid,
820 column_id: column_idx,
821 },
822 });
823 }
824 }
825 }
826 }
827 }
828
829 self.nav_items = new_items;
830
831 if self.nav_items.is_empty() {
833 self.nav.idx = 0;
834 } else {
835 self.nav.idx = self.nav.idx.min(self.nav_items.len() - 1);
836 }
837 }
838
839 pub fn rpc_list_len(&self) -> u16 {
840 let length: usize = self.suggested_rpcs_len;
841 std::cmp::min(length, RPCLIST_MAX_LEN).try_into().unwrap()
842 }
843
844 pub fn current_pos(&self) -> Option<&NavPos> {
845 self.nav_items.get(self.nav.idx)
846 }
847
848 pub fn current_selection(&self) -> Option<ColumnKey> {
849 self.current_pos().and_then(|p| p.spec().cloned())
850 }
851
852 pub fn current_route(&self) -> DeviceRoute {
853 self.current_pos()
854 .map(|p| p.route().clone())
855 .unwrap_or_else(|| self.parent_route.clone())
856 }
857
858 pub fn current_device_index(&self) -> usize {
859 self.current_pos().map(|p| p.device_idx()).unwrap_or(0)
860 }
861
862 pub fn device_count(&self) -> usize {
863 self.visible_routes().len()
864 }
865
866 fn handle_event(&mut self, event: TreeEvent, rpc_tx: &Sender<RpcWorkerReq>) {
867 match event {
868 TreeEvent::RouteDiscovered(route) => {
869 self.discovered_routes.insert(route.clone());
870 let _ = rpc_tx.send(RpcWorkerReq::FetchList(route.clone()));
871 self.device_status.entry(route).or_default();
872 }
873 TreeEvent::Device {
874 route,
875 event: DeviceEvent::NewHash(hash),
876 } => {
877 match (self.rpc_registries.get(&route), hash) {
878 (Some(reg), Some(hash)) if reg.hash == Some(hash) => {}
879 _ => {
880 self.rpc_registries.remove(&route);
881 let _ = rpc_tx.send(RpcWorkerReq::FetchList(route));
882 }
883 };
884 }
885 TreeEvent::Device {
886 route,
887 event: DeviceEvent::Heartbeat { .. },
888 } => {
889 self.device_status.entry(route).or_default().on_heartbeat();
890 }
891 TreeEvent::Device {
892 route,
893 event: DeviceEvent::Status(status),
894 } => {
895 let dev_status = self.device_status.entry(route.clone()).or_default();
896 match status {
897 ProxyStatus::SensorDisconnected => dev_status.connected = false,
898 ProxyStatus::SensorReconnected => dev_status.connected = true,
899 _ => {}
900 }
901 }
902 TreeEvent::Device {
903 route,
904 event: DeviceEvent::MetadataReady(metadata),
905 } => {
906 self.device_metadata.insert(route, metadata);
907 }
908 TreeEvent::Device {
909 event: DeviceEvent::RpcInvalidated(_),
910 ..
911 } => {}
912 }
913 }
914
915 pub fn handle_sample(&mut self, sample: Sample, route: DeviceRoute, buffer: &mut Buffer) {
916 let stream_key = StreamKey::new(route.clone(), sample.stream.stream_id);
917 buffer.process_sample(sample.clone(), stream_key.clone());
918 self.last.insert(stream_key, (sample, Instant::now()));
919 }
920
921 pub fn update_plot_window(&mut self, buffer: &Buffer) {
922 if !self.view.show_plot {
923 self.window_aligned = None;
924 return;
925 }
926
927 self.window_aligned = self.current_selection().and_then(|col| {
928 let stream_key = col.stream_key();
929 let run = buffer.get_run(&stream_key)?;
930 let n_samples = (self.view.plot_window_seconds * run.effective_rate)
931 .ceil()
932 .max(10.0) as usize;
933 buffer.read_aligned_window(&[col], n_samples).ok()
934 });
935 }
936
937 pub fn get_plot_data(&self) -> Option<(Vec<(f64, f64)>, f64, f64)> {
938 let spec = self.current_selection()?;
939 let win = self.window_aligned.as_ref()?;
940 let batch = win.columns.get(&spec)?;
941 if win.timestamps.is_empty() {
942 return None;
943 }
944 let data: Vec<(f64, f64)> = match batch {
945 ColumnBatch::F64(v) => win
946 .timestamps
947 .iter()
948 .copied()
949 .zip(v.iter().copied())
950 .collect(),
951 ColumnBatch::I64(v) => win
952 .timestamps
953 .iter()
954 .copied()
955 .zip(v.iter().map(|&x| x as f64))
956 .collect(),
957 ColumnBatch::U64(v) => win
958 .timestamps
959 .iter()
960 .copied()
961 .zip(v.iter().map(|&x| x as f64))
962 .collect(),
963 };
964 if data.is_empty() {
965 return None;
966 }
967 let (cur_t, cur_v) = *data.last().unwrap();
968 Some((data, cur_v, cur_t))
969 }
970
971 pub fn get_spectral_density_data(&self) -> Option<(Vec<(f64, f64)>, f64)> {
972 let spec = self.current_selection()?;
973 let win = self.window_aligned.as_ref()?;
974 let stream_key = spec.stream_key();
975 let md = win.segment_metadata.get(&stream_key)?;
976 let sampling_hz = (md.sampling_rate / md.decimation) as f64;
977 let batch = win.columns.get(&spec)?;
978
979 let signal: Vec<f64> = match batch {
980 ColumnBatch::F64(v) => v.clone(),
981 ColumnBatch::I64(v) => v.iter().map(|&x| x as f64).collect(),
982 ColumnBatch::U64(v) => v.iter().map(|&x| x as f64).collect(),
983 };
984
985 if signal.len() < 128 {
986 return None;
987 }
988
989 let mean_val = signal.iter().sum::<f64>() / signal.len() as f64;
990 let detrended: Vec<f64> = signal.iter().map(|x| x - mean_val).collect();
991
992 let welch: SpectralDensity<f64> = SpectralDensity::builder(&detrended, sampling_hz).build();
993 let sd = welch.periodogram();
994 let raw: Vec<f64> = sd.iter().copied().collect();
995
996 let pts: Vec<(f64, f64)> = sd
997 .frequency()
998 .into_iter()
999 .zip(raw.into_iter())
1000 .filter(|(f, d)| *f > 0.0 && d.is_finite() && *d > 0.0)
1001 .collect();
1002
1003 if pts.is_empty() {
1004 return None;
1005 }
1006
1007 let mut densities: Vec<f64> = pts.iter().map(|(_, d)| *d).collect();
1008 densities.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1009 let median_asd = if densities.len() % 2 == 0 {
1010 (densities[densities.len() / 2 - 1] + densities[densities.len() / 2]) / 2.0
1011 } else {
1012 densities[densities.len() / 2]
1013 };
1014
1015 Some((pts, median_asd))
1016 }
1017
1018 pub fn get_focused_channel_info(&self) -> Option<(String, String)> {
1019 let spec = self.current_selection()?;
1020 let win = self.window_aligned.as_ref()?;
1021 let meta = win.column_metadata.get(&spec)?;
1022 Some((meta.description.clone(), meta.units.clone()))
1023 }
1024
1025 pub fn tick_blink(&mut self) {
1026 if self.last_blink.elapsed() >= Duration::from_millis(500) {
1027 self.blink_state = !self.blink_state;
1028 self.last_blink = Instant::now();
1029 }
1030 }
1031}
1032
1033fn get_action(ev: Event, app: &mut App) -> Option<Action> {
1034 if let Event::Key(k) = ev {
1035 if k.kind != KeyEventKind::Press {
1036 return None;
1037 }
1038 match app.mode {
1039 Mode::Command => match k.code {
1040 KeyCode::Esc => Some(Action::SetMode(Mode::Normal)),
1041 KeyCode::Char('c') if k.modifiers == KeyModifiers::CONTROL => {
1042 Some(Action::SetMode(Mode::Normal))
1043 }
1044 KeyCode::Tab => Some(Action::AutoCompleteTab),
1045 KeyCode::BackTab => Some(Action::AutoCompleteBack),
1046 KeyCode::Up => Some(Action::HistoryNavigate(HistDir::Up)),
1047 KeyCode::Down => Some(Action::HistoryNavigate(HistDir::Down)),
1048 KeyCode::Right if !app.current_completion.is_empty() => {
1049 Some(Action::AcceptCompletion)
1050 }
1051 KeyCode::Right => {
1052 app.input_state.handle_key_event(k);
1053 None
1054 }
1055 KeyCode::Left => {
1056 app.current_completion = String::new();
1057 app.input_state.handle_key_event(k);
1058 None
1059 }
1060 KeyCode::Enter => Some(Action::SubmitCommand),
1061 KeyCode::Char('a') if k.modifiers == KeyModifiers::CONTROL => {
1062 app.current_completion = String::new();
1063 app.input_state.handle_key_event(k);
1064 None
1065 }
1066 _ => {
1067 app.input_state.handle_key_event(k);
1068 Some(Action::NewCommandString)
1069 }
1070 },
1071 Mode::Normal => match k.code {
1072 KeyCode::Char(':') => Some(Action::SetMode(Mode::Command)),
1073 KeyCode::Char('q') => Some(Action::Quit),
1074 KeyCode::Char('c') if k.modifiers == KeyModifiers::CONTROL => Some(Action::Quit),
1075 KeyCode::Esc => Some(Action::ClosePlot),
1076 KeyCode::Up => Some(Action::NavUp),
1077 KeyCode::Down => Some(Action::NavDown),
1078 KeyCode::Left => Some(Action::NavLeft),
1079 KeyCode::Right => Some(Action::NavRight),
1080 KeyCode::BackTab => Some(Action::NavTabPrev),
1081 KeyCode::Tab => Some(Action::NavTabNext),
1082 KeyCode::PageUp => Some(Action::NavScroll(-10)),
1083 KeyCode::PageDown => Some(Action::NavScroll(10)),
1084 KeyCode::Home => Some(Action::NavHome),
1085 KeyCode::End => Some(Action::NavEnd),
1086 KeyCode::Enter => Some(Action::TogglePlot),
1087 KeyCode::Char('f') => Some(Action::ToggleFft),
1088 KeyCode::Char('h') => Some(Action::ToggleFooter),
1089 KeyCode::Char('r') => Some(Action::ToggleRoutes),
1090 KeyCode::Char('+') | KeyCode::Char('=') => Some(Action::AdjustWindow(0.5)),
1091 KeyCode::Char('-') | KeyCode::Char('_') => Some(Action::AdjustWindow(-0.5)),
1092 KeyCode::Char('[') => Some(Action::AdjustPlotWidth(5)),
1093 KeyCode::Char(']') => Some(Action::AdjustPlotWidth(-5)),
1094 KeyCode::Char(',') | KeyCode::Char('<') => Some(Action::AdjustPrecision(-1)),
1095 KeyCode::Char('.') | KeyCode::Char('>') => Some(Action::AdjustPrecision(1)),
1096 _ => None,
1097 },
1098 }
1099 } else {
1100 None
1101 }
1102}
1103
1104fn draw_ui(terminal: &mut DefaultTerminal, app: &mut App) -> Result<(), io::Error> {
1105 terminal.draw(|f| {
1106 let size = f.area();
1107 let height = size.height;
1108
1109 let (main_area, footer_area) = {
1110 let (main_constraint, footer_constraint) = if app.mode == Mode::Command {
1111 if height >= 18 {
1112 (
1113 Constraint::Min(10),
1114 Constraint::Length(5 + app.rpc_list_len()),
1115 )
1116 } else if height >= 12 {
1117 (Constraint::Min(2), Constraint::Length(8))
1118 } else if height >= 5 {
1119 (Constraint::Min(2), Constraint::Length(3))
1120 } else {
1121 (Constraint::Min(0), Constraint::Length(2))
1122 }
1123 } else if app.view.show_footer {
1124 (Constraint::Min(10), Constraint::Length(6))
1125 } else {
1126 (Constraint::Min(10), Constraint::Length(2))
1127 };
1128 let chunks = Layout::default()
1129 .direction(Direction::Vertical)
1130 .constraints([main_constraint, footer_constraint])
1131 .split(size);
1132 (chunks[0], Some(chunks[1]))
1133 };
1134
1135 let (left, right) = if app.mode == Mode::Command && height < 3 {
1136 (None, None)
1137 } else if app.view.show_plot {
1138 let chunks = Layout::default()
1139 .direction(Direction::Horizontal)
1140 .constraints([
1141 Constraint::Percentage(100 - app.view.plot_width_percent),
1142 Constraint::Percentage(app.view.plot_width_percent),
1143 ])
1144 .split(main_area);
1145 (Some(chunks[0]), Some(chunks[1]))
1146 } else {
1147 (Some(main_area), None)
1148 };
1149
1150 if let Some(l) = left {
1151 render_monitor_panel(f, app, l, Instant::now());
1152 }
1153 if let Some(r) = right {
1154 render_graphics_panel(f, app, r);
1155 }
1156 if let Some(foot) = footer_area {
1157 render_footer(f, app, foot);
1158 }
1159 })?;
1160 Ok(())
1161}
1162
1163fn render_monitor_panel(f: &mut Frame, app: &mut App, area: Rect, now: Instant) {
1164 let inner = Rect {
1165 x: area.x,
1166 y: area.y,
1167 width: area.width.saturating_sub(1),
1168 height: area.height,
1169 };
1170 let (lines, col_map) = build_left_lines(app, now);
1171 let total = lines.len();
1172 let view_h = inner.height as usize;
1173
1174 if app.view.follow_selection {
1175 if let Some(&line_idx) = col_map.get(&app.nav.idx) {
1176 if view_h > 0 && total > view_h {
1177 let cur = app.view.scroll as usize;
1178 if line_idx < cur || line_idx >= cur + view_h {
1179 app.view.scroll = line_idx
1180 .saturating_sub(view_h / 2)
1181 .min(total.saturating_sub(view_h))
1182 as u16;
1183 }
1184 } else {
1185 app.view.scroll = 0;
1186 }
1187 }
1188 }
1189 app.view.scroll = (app.view.scroll as usize).min(total.saturating_sub(view_h)) as u16;
1190 f.render_widget(Paragraph::new(lines).scroll((app.view.scroll, 0)), inner);
1191
1192 if total > view_h {
1193 let sb_area = Rect {
1194 x: area.x + area.width - 1,
1195 y: area.y,
1196 width: 1,
1197 height: area.height,
1198 };
1199 let track_len = view_h;
1200 let thumb_len = (track_len * track_len / total).max(1);
1201 let max_thumb_pos = track_len - thumb_len;
1202 let scroll_max = total - track_len;
1203 let thumb_pos = (app.view.scroll as usize * max_thumb_pos) / scroll_max;
1204
1205 for i in 0..track_len {
1206 let ch = if i >= thumb_pos && i < thumb_pos + thumb_len {
1207 "█"
1208 } else {
1209 "│"
1210 };
1211 f.render_widget(
1212 Paragraph::new(ch).style(Style::default().fg(Color::DarkGray)),
1213 Rect {
1214 x: sb_area.x,
1215 y: sb_area.y + i as u16,
1216 width: 1,
1217 height: 1,
1218 },
1219 );
1220 }
1221 }
1222}
1223
1224fn build_left_lines(app: &mut App, now: Instant) -> (Vec<Line<'static>>, HashMap<usize, usize>) {
1225 let mut lines = Vec::new();
1226 let mut map = HashMap::new();
1227
1228 let routes = app.visible_routes();
1229
1230 if routes.is_empty() {
1231 lines.push(Line::from("Waiting for data..."));
1232 return (lines, map);
1233 }
1234
1235 let mut global_idx = 0;
1236 app.view.desc_width = app
1237 .last
1238 .values()
1239 .flat_map(|(s, _)| s.columns.iter())
1240 .map(|c| c.desc.description.len())
1241 .max()
1242 .unwrap_or(0);
1243 app.view.units_width = app
1244 .last
1245 .values()
1246 .flat_map(|(s, _)| s.columns.iter())
1247 .map(|c| c.desc.units.len())
1248 .max()
1249 .unwrap_or(0);
1250
1251 for (dev_idx, route) in routes.iter().enumerate() {
1252 let dev = app.device_metadata.get(route).map(|m| m.device.as_ref());
1253
1254 let status = app.device_status.get(route);
1255 let is_alive = status
1256 .map(|s| s.is_alive(Duration::from_millis(300)))
1257 .unwrap_or(false);
1258
1259 let head_style = if dev_idx == app.current_device_index() {
1260 Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
1261 } else {
1262 Style::default().add_modifier(Modifier::BOLD)
1263 };
1264
1265 let header_text = if let Some(d) = dev {
1266 if d.serial_number.is_empty() {
1267 d.name.clone()
1268 } else {
1269 format!("{} Serial: {}", d.name, d.serial_number)
1270 }
1271 } else {
1272 format!("<{}>", route)
1273 };
1274
1275 let status_indicator = if is_alive { "●" } else { "○" };
1276 let status_color = if is_alive {
1277 Color::Green
1278 } else {
1279 Color::DarkGray
1280 };
1281
1282 let mut header_spans = vec![
1283 Span::styled(
1284 format!("{} ", status_indicator),
1285 Style::default().fg(status_color),
1286 ),
1287 Span::styled(header_text, head_style),
1288 ];
1289 if app.view.show_routes {
1290 header_spans.push(Span::raw(format!(" [{}]", route)));
1291 }
1292
1293 lines.push(Line::from(header_spans));
1294
1295 let mut stream_ids: Vec<_> = app
1296 .last
1297 .keys()
1298 .filter(|k| &k.route == route)
1299 .map(|k| k.stream_id)
1300 .collect();
1301 stream_ids.sort();
1302
1303 if stream_ids.is_empty() {
1304 map.insert(global_idx, lines.len());
1305 global_idx += 1;
1306
1307 lines.push(Line::from(Span::styled(
1308 " (no streams yet)",
1309 Style::default().fg(Color::DarkGray),
1310 )));
1311 }
1312
1313 for sid in stream_ids {
1314 let key = StreamKey::new(route.clone(), sid);
1315 if let Some((sample, seen)) = app.last.get(&key) {
1316 let is_stale = now.saturating_duration_since(*seen) > Duration::from_millis(1200);
1317 for col in &sample.columns {
1318 let nav_idx = global_idx;
1319 global_idx += 1;
1320 map.insert(nav_idx, lines.len());
1321
1322 let is_sel = app.nav.idx == nav_idx;
1323 let ctx = StyleContext::new()
1324 .stale(is_stale)
1325 .selected(is_sel)
1326 .plot_mode(app.view.show_plot);
1327
1328 let label_style = ctx.resolve();
1329 let (val_str, val_f64) = fmt_value(&col.value);
1330 let val_col = app
1331 .view
1332 .theme
1333 .get_value_color(&sample.stream.name, &col.desc.name, val_f64)
1334 .unwrap_or(Color::Reset);
1335 let val_style = ctx.color(val_col).resolve();
1336
1337 let mut desc = col.desc.description.clone();
1338 if desc.len() < app.view.desc_width {
1339 desc.push_str(&" ".repeat(app.view.desc_width - desc.len()));
1340 }
1341
1342 let units = col.desc.units.clone();
1343 let padded_units = if app.view.units_width > 0 && !units.is_empty() {
1344 format!("{:>width$}", units, width = app.view.units_width)
1345 } else if app.view.units_width > 0 {
1346 " ".repeat(app.view.units_width)
1347 } else {
1348 String::new()
1349 };
1350
1351 lines.push(Line::from(vec![
1352 Span::styled(desc, label_style),
1353 Span::raw(" "),
1354 Span::styled(val_str, val_style),
1355 Span::raw(" "),
1356 Span::styled(padded_units, val_style),
1357 ]));
1358 }
1359 }
1360 }
1361 lines.push(Line::from(""));
1362 }
1363 (lines, map)
1364}
1365
1366fn render_footer(f: &mut Frame, app: &mut App, area: Rect) {
1367 app.footer_height = area.height; if app.mode == Mode::Command {
1369 let chunks = Layout::default()
1370 .direction(Direction::Vertical)
1371 .constraints([
1372 Constraint::Max(app.rpc_list_len() + 2),
1373 Constraint::Length(std::cmp::min(1, app.footer_height - 1)),
1374 Constraint::Length(if app.footer_height > 2 { 2 } else { 1 }),
1375 ])
1376 .split(area);
1377
1378 if app.footer_height > 3 {
1379 let rpcs: Vec<Span> = if app.rpc_registries.get(&app.current_route()).is_some() {
1380 app.suggested_rpcs
1381 .iter()
1382 .map(|v| Span::raw(v.clone()))
1383 .enumerate()
1384 .map(|(i, v)| {
1385 if i == app.suggested_rpcs_ind {
1386 v.bold()
1387 } else {
1388 v.dim()
1389 }
1390 })
1391 .collect()
1392 } else {
1393 vec![Span::from("Generating RPC list...")]
1394 };
1395
1396 let rpc_block = Block::default()
1397 .borders(Borders::ALL)
1398 .title(Line::from(" RPCs ").left_aligned())
1399 .title(Line::from(" ↑ Shift+Tab | Tab ↓ ").right_aligned());
1400 f.render_widget(List::new(rpcs).block(rpc_block), chunks[0]);
1401 }
1402
1403 if app.footer_height > 1 {
1404 if let Some((msg, color)) = &app.last_rpc_result {
1405 f.render_widget(
1406 Paragraph::new(msg.as_str())
1407 .style(Style::default().fg(*color).add_modifier(Modifier::BOLD)),
1408 chunks[1],
1409 );
1410 }
1411 }
1412
1413 let target_route = app.current_route();
1414 let user_input = app.input_state.value();
1415 let cursor_idx = app.input_state.position().min(user_input.len());
1416
1417 let mut spans = vec![
1418 Span::styled(
1419 format!("[{}] ", target_route),
1420 Style::default().fg(Color::Blue),
1421 ),
1422 Span::raw(&user_input[0..cursor_idx]),
1423 ];
1424
1425 if cursor_idx < user_input.len() {
1426 spans.push(Span::styled(
1427 &user_input[cursor_idx..cursor_idx + 1],
1428 if app.blink_state {
1429 Style::default().bg(Color::White).fg(Color::Black)
1430 } else {
1431 Style::default()
1432 },
1433 ));
1434 spans.push(Span::raw(&user_input[cursor_idx + 1..]));
1435 } else if app.blink_state {
1436 spans.push(Span::styled(" ", Style::default().bg(Color::White)));
1437 if !app.current_completion.is_empty() {
1438 spans.push(Span::styled(
1439 &app.current_completion[1..],
1440 Style::default().fg(Color::Gray),
1441 ));
1442 }
1443 } else {
1444 spans.push(Span::styled(
1445 &app.current_completion,
1446 Style::default().fg(Color::Gray),
1447 ));
1448 }
1449
1450 let block = if app.footer_height < 3 {
1451 Block::default()
1452 } else {
1453 Block::default()
1454 .borders(Borders::TOP)
1455 .title(Line::from(" Command Mode ").left_aligned())
1456 .title(Line::from(" <Esc/Ctrl+C> ").right_aligned())
1457 };
1458
1459 f.render_widget(Paragraph::new(Line::from(spans)).block(block), chunks[2]);
1460 return;
1461 }
1462
1463 if !app.view.show_footer {
1464 let minimal = Line::from(vec![
1465 Span::raw(" "),
1466 key_span("h"),
1467 Span::raw(" Toggle Footer"),
1468 ]);
1469 f.render_widget(
1470 Paragraph::new(vec![minimal]).block(
1471 Block::default()
1472 .borders(Borders::TOP)
1473 .border_style(Style::default().fg(Color::DarkGray)),
1474 ),
1475 area,
1476 );
1477 return;
1478 }
1479
1480 let mut navigation_spans = vec![
1481 Span::styled(
1482 " Navigation ",
1483 Style::default()
1484 .fg(Color::Cyan)
1485 .add_modifier(Modifier::BOLD),
1486 ),
1487 key_span("↑"),
1488 key_sep(),
1489 key_span("↓"),
1490 Span::raw(" All "),
1491 key_span("←"),
1492 key_sep(),
1493 key_span("→"),
1494 Span::raw(" Columns"),
1495 ];
1496
1497 if app.device_count() > 1 {
1498 navigation_spans.push(Span::raw(" "));
1499 navigation_spans.push(key_span("Tab"));
1500 navigation_spans.push(key_sep());
1501 navigation_spans.push(key_span("Shift+Tab"));
1502 navigation_spans.push(Span::raw(" Devices"));
1503 }
1504 let navigation_line = Line::from(navigation_spans);
1505
1506 let toggle_line = Line::from(vec![
1507 Span::styled(
1508 " Toggle ",
1509 Style::default()
1510 .fg(Color::Green)
1511 .add_modifier(Modifier::BOLD),
1512 ),
1513 key_span("Enter"),
1514 Span::raw(" Plot "),
1515 key_span("f"),
1516 Span::raw(" FFT "),
1517 key_span("h"),
1518 Span::raw(" Footer "),
1519 key_span("r"),
1520 Span::raw(" Routes "),
1521 key_span(":"),
1522 Span::raw(" Cmd"),
1523 ]);
1524
1525 let window_line = Line::from(vec![
1526 Span::styled(
1527 " Plot ",
1528 Style::default()
1529 .fg(Color::Yellow)
1530 .add_modifier(Modifier::BOLD),
1531 ),
1532 key_span("+"),
1533 key_sep(),
1534 key_span("-"),
1535 Span::raw(" Window (0.5s) "),
1536 key_span("["),
1537 key_sep(),
1538 key_span("]"),
1539 Span::raw(" Plot Width "),
1540 key_span("<"),
1541 key_sep(),
1542 key_span(">"),
1543 Span::raw(" Plot Precision"),
1544 ]);
1545
1546 let scroll_line = Line::from(vec![
1547 Span::styled(
1548 " Scroll ",
1549 Style::default()
1550 .fg(Color::Magenta)
1551 .add_modifier(Modifier::BOLD),
1552 ),
1553 key_span("Home"),
1554 key_sep(),
1555 key_span("End"),
1556 key_sep(),
1557 key_span("PgUp"),
1558 key_sep(),
1559 key_span("PgDn"),
1560 ]);
1561
1562 let quit_line = Line::from(vec![
1563 Span::styled(
1564 " Quit ",
1565 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1566 ),
1567 key_span("q"),
1568 Span::raw(" / "),
1569 key_span("Ctrl+C"),
1570 Span::raw(" Quit"),
1571 ]);
1572
1573 let lines = vec![
1574 navigation_line,
1575 toggle_line,
1576 window_line,
1577 scroll_line,
1578 quit_line,
1579 ];
1580
1581 let block = Block::default()
1582 .borders(Borders::TOP)
1583 .border_style(Style::default().fg(Color::DarkGray))
1584 .title(Span::styled(
1585 " Controls ",
1586 Style::default().add_modifier(Modifier::BOLD),
1587 ));
1588
1589 f.render_widget(Paragraph::new(lines).block(block), area);
1590}
1591
1592fn key_span(text: &str) -> Span<'static> {
1593 Span::styled(
1594 format!(" {} ", text),
1595 Style::default()
1596 .fg(Color::White)
1597 .bg(Color::DarkGray)
1598 .add_modifier(Modifier::BOLD),
1599 )
1600}
1601
1602fn key_sep() -> Span<'static> {
1603 Span::raw(" ")
1604}
1605
1606fn render_graphics_panel(f: &mut Frame, app: &App, area: Rect) {
1607 if let (Some(pos), Some((desc, units))) = (app.current_pos(), app.get_focused_channel_info()) {
1608 let route = pos.route();
1609 if app.view.show_fft {
1610 if let Some((sd_data, median_asd)) = app.get_spectral_density_data() {
1611 let title = format!(
1612 "{} — {} (linear detrend {:.1}s) | Median ASD: {:.3e} {}/√Hz",
1613 route, desc, app.view.plot_window_seconds, median_asd, units
1614 );
1615 let block = Block::default().title(title).borders(Borders::ALL);
1616
1617 if !sd_data.is_empty() {
1618 let log_data: Vec<(f64, f64)> = sd_data
1619 .iter()
1620 .map(|(freq, val)| (freq.log10(), val.log10()))
1621 .collect();
1622
1623 let min_f = log_data.first().map(|(f, _)| *f).unwrap_or(0.0);
1624 let max_f = log_data.last().map(|(f, _)| *f).unwrap_or(1.0);
1625 let ds: Vec<f64> = log_data.iter().map(|(_, d)| *d).collect();
1626 let min_d = ds.iter().fold(f64::INFINITY, |a, &b| a.min(b));
1627 let max_d = ds.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
1628
1629 let y_pad = if (max_d - min_d) > 0.1 {
1630 (max_d - min_d) * 0.1
1631 } else {
1632 0.5
1633 };
1634
1635 let dataset = Dataset::default()
1636 .name(desc.as_str())
1637 .marker(symbols::Marker::Braille)
1638 .style(Style::default().fg(Color::Cyan))
1639 .graph_type(GraphType::Line)
1640 .data(&log_data);
1641
1642 let chart = Chart::new(vec![dataset])
1643 .block(block)
1644 .x_axis(
1645 Axis::default()
1646 .title("Freq [Hz] (log)")
1647 .bounds([min_f, max_f])
1648 .labels(generate_log_labels(
1649 min_f,
1650 max_f,
1651 5,
1652 app.view.axis_precision,
1653 )),
1654 )
1655 .y_axis(
1656 Axis::default()
1657 .title(format!("Val [{}/√Hz]", units))
1658 .bounds([min_d - y_pad, max_d + y_pad])
1659 .labels(generate_log_labels(
1660 min_d - y_pad,
1661 max_d + y_pad,
1662 5,
1663 app.view.axis_precision,
1664 )),
1665 );
1666 f.render_widget(chart, area);
1667 } else {
1668 f.render_widget(Paragraph::new("No valid FFT data").block(block), area);
1669 }
1670 } else {
1671 let block = Block::default()
1672 .title("Buffering FFT...")
1673 .borders(Borders::ALL);
1674 f.render_widget(Paragraph::new("Need >128 samples").block(block), area);
1675 }
1676 } else {
1677 let title = format!(
1678 "{} — {} ({:.1}s)",
1679 route, desc, app.view.plot_window_seconds
1680 );
1681 let block = Block::default().title(title).borders(Borders::ALL);
1682
1683 if let Some((data, _, _)) = app.get_plot_data() {
1684 let min_t = data.first().map(|(t, _)| *t).unwrap_or(0.0);
1685 let max_t = data.last().map(|(t, _)| *t).unwrap_or(1.0);
1686 let vs: Vec<f64> = data.iter().map(|(_, v)| *v).collect();
1687 let min_v = vs.iter().fold(f64::INFINITY, |a, &b| a.min(b));
1688 let max_v = vs.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
1689
1690 let pad = if (max_v - min_v).abs() > 1e-10 {
1691 (max_v - min_v) * 0.4
1692 } else {
1693 1.0
1694 };
1695
1696 let dataset = Dataset::default()
1697 .name(desc.as_str())
1698 .marker(symbols::Marker::Braille)
1699 .style(Style::default().fg(Color::Green))
1700 .graph_type(GraphType::Line)
1701 .data(&data);
1702
1703 let chart = Chart::new(vec![dataset])
1704 .block(block)
1705 .x_axis(
1706 Axis::default()
1707 .title("Time [s]")
1708 .bounds([min_t, max_t])
1709 .labels(generate_linear_labels(
1710 min_t,
1711 max_t,
1712 3,
1713 app.view.axis_precision,
1714 )),
1715 )
1716 .y_axis(
1717 Axis::default()
1718 .title(format!("Value [{}]", units))
1719 .bounds([min_v - pad, max_v + pad])
1720 .labels(generate_linear_labels(
1721 min_v - pad,
1722 max_v + pad,
1723 5,
1724 app.view.axis_precision,
1725 )),
1726 );
1727 f.render_widget(chart, area);
1728 } else {
1729 f.render_widget(Paragraph::new("Buffering...").block(block), area);
1730 }
1731 }
1732 } else {
1733 f.render_widget(
1734 Block::default()
1735 .title("Channel Detail")
1736 .borders(Borders::ALL),
1737 area,
1738 );
1739 }
1740}
1741
1742fn generate_linear_labels(
1743 min: f64,
1744 max: f64,
1745 count: usize,
1746 precision: usize,
1747) -> Vec<Span<'static>> {
1748 if count < 2 {
1749 return vec![];
1750 }
1751 let step = (max - min) / ((count - 1) as f64);
1752 (0..count)
1753 .map(|i| {
1754 let v = min + (i as f64 * step);
1755 Span::from(format!("{:>10.p$}", v, p = precision))
1756 })
1757 .collect()
1758}
1759
1760fn generate_log_labels(
1761 min_log: f64,
1762 max_log: f64,
1763 count: usize,
1764 precision: usize,
1765) -> Vec<Span<'static>> {
1766 if count < 2 {
1767 return vec![];
1768 }
1769 let step = (max_log - min_log) / ((count - 1) as f64);
1770 let max_val = 10f64.powf(max_log.max(min_log)).abs();
1771 let use_scientific = max_val < 0.01 || max_val >= 1000.0;
1772
1773 (0..count)
1774 .map(|i| {
1775 let log_val = min_log + (i as f64 * step);
1776 let real_val = 10f64.powf(log_val);
1777
1778 let s = if use_scientific {
1779 format!("{:.p$e}", real_val, p = precision)
1780 } else {
1781 format!("{:.p$}", real_val, p = precision)
1782 };
1783 Span::from(format!("{:>10}", s))
1784 })
1785 .collect()
1786}
1787
1788fn fmt_value(v: &ColumnData) -> (String, f64) {
1789 match v {
1790 ColumnData::Float(x) => (format!("{:15.4}", x), *x as f64),
1791 ColumnData::Int(x) => (format!("{:15}", x), *x as f64),
1792 ColumnData::UInt(x) => (format!("{:15}", x), *x as f64),
1793 _ => (" type?".to_string(), f64::NAN),
1794 }
1795}
1796
1797fn load_theme(path: &str) -> io::Result<Theme> {
1798 let mut s = String::new();
1799 File::open(path)?.read_to_string(&mut s)?;
1800 let doc =
1801 DocumentMut::from_str(&s).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
1802 let mut bounds = HashMap::new();
1803 for (k, v) in doc.get_values() {
1804 let col = k.iter().map(|k| k.get()).collect::<Vec<_>>().join(".");
1805 if let Value::InlineTable(it) = v {
1806 let (t, min) = if let Some(v) = get_num(it, "cold") {
1807 (true, v)
1808 } else {
1809 (false, get_num(it, "min").unwrap_or(f64::NEG_INFINITY))
1810 };
1811 let max = if let Some(v) = get_num(it, "hot") {
1812 v
1813 } else {
1814 get_num(it, "max").unwrap_or(f64::INFINITY)
1815 };
1816 bounds.insert(col, (min..=max, t));
1817 }
1818 }
1819 Ok(Theme {
1820 value_bounds: bounds,
1821 })
1822}
1823
1824fn get_num(it: &InlineTable, k: &str) -> Option<f64> {
1825 it.get(k)
1826 .and_then(|v| v.as_float().or(v.as_integer().map(|i| i as f64)))
1827}
1828pub fn run_monitor(tio: TioOpts, all: bool, fps: u32, colors: Option<String>) -> Result<(), ()> {
1829 let proxy = tio::proxy::Interface::new(&tio.root);
1830 let parent_route: DeviceRoute = tio.parse_route();
1831
1832 let (data_tx, data_rx) = channel::unbounded::<TreeItem>();
1834 let tree_for_data =
1835 DeviceTree::open(&proxy, parent_route.clone()).expect("Failed to open device tree");
1836 std::thread::spawn(move || {
1837 let mut tree = tree_for_data;
1838 loop {
1839 match tree.next_item() {
1840 Ok(item) => {
1841 if data_tx.send(item).is_err() {
1842 return;
1843 }
1844 }
1845 Err(_) => return,
1846 }
1847 }
1848 });
1849
1850 let rpc_client =
1852 RpcClient::open(&proxy, parent_route.clone()).expect("Failed to open RPC client");
1853 let (rpc_tx, rpc_rx) = channel::unbounded::<RpcWorkerReq>();
1854 let (rpc_resp_tx, rpc_resp_rx) = channel::unbounded::<RpcWorkerResp>();
1855
1856 std::thread::spawn(move || {
1857 while let Ok(req) = rpc_rx.recv() {
1858 let resp = match req {
1859 RpcWorkerReq::FetchList(route) => match rpc_client.rpc_list(&route) {
1860 Ok(list) => Some(RpcWorkerResp::List(list)),
1861 Err(_) => None,
1862 },
1863 RpcWorkerReq::Execute(rpc_req) => {
1864 let result = exec_rpc(&rpc_client, &rpc_req);
1865 Some(RpcWorkerResp::RpcResult(RpcResp { result }))
1866 }
1867 };
1868 if let Some(resp) = resp {
1869 if rpc_resp_tx.send(resp).is_err() {
1870 return;
1871 }
1872 }
1873 }
1874 });
1875
1876 let (key_tx, key_rx) = channel::unbounded();
1878 std::thread::spawn(move || loop {
1879 if let Ok(ev) = event::read() {
1880 if key_tx.send(ev).is_err() {
1881 return;
1882 }
1883 }
1884 });
1885
1886 let mut app = App::new(all, &parent_route);
1888 if let Some(path) = &colors {
1889 if let Ok(theme) = load_theme(path) {
1890 app.view.theme = theme;
1891 } else {
1892 eprintln!("Failed to load theme");
1893 }
1894 }
1895
1896 let mut buffer = Buffer::new(100_000);
1897
1898 let mut term = ratatui::init();
1900 let _ = term.hide_cursor();
1901 let ui_tick = channel::tick(Duration::from_millis(1000 / fps as u64));
1902
1903 'main: loop {
1904 crossbeam::select! {
1905 recv(data_rx) -> item => {
1906 match item {
1907 Ok(TreeItem::Sample(sample, route)) => {
1908 app.handle_sample(sample, route, &mut buffer);
1909 }
1910 Ok(TreeItem::Event(event)) => {
1911 app.handle_event(event, &rpc_tx);
1912 }
1913 Err(_) => break 'main,
1914 }
1915 }
1916
1917 recv(key_rx) -> ev => {
1918 if let Ok(ev) = ev {
1919 if let Some(act) = get_action(ev, &mut app) {
1920 if app.update(act, &rpc_tx) {
1921 break 'main;
1922 }
1923 }
1924 }
1925 }
1926
1927 recv(rpc_resp_rx) -> resp => {
1928 if let Ok(resp) = resp {
1929 match resp {
1930 RpcWorkerResp::List(list) => {
1931 app.update_rpclists(list);
1932 }
1933 RpcWorkerResp::RpcResult(res) => {
1934 let (msg, col) = match res.result {
1935 Ok(s) => (format!("{}: {}", app.last_rpc_command, s), Color::Green),
1936 Err(s) => (format!("ERR: {}", s), Color::Red),
1937 };
1938 app.last_rpc_result = Some((msg, col));
1939 }
1940 }
1941 }
1942 }
1943
1944 recv(ui_tick) -> _ => {
1945 app.update_plot_window(&buffer);
1946 app.rebuild_nav_items();
1947 app.tick_blink();
1948
1949 if draw_ui(&mut term, &mut app).is_err() {
1950 break 'main;
1951 }
1952 }
1953 }
1954 }
1955
1956 ratatui::restore();
1957 Ok(())
1958}