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