Skip to main content

twinleaf_tools/tools/
monitor.rs

1// tio monitor
2// Live sensor data display with plot and FFT capabilities
3// Build: cargo run --release -- <tio-url> [options]
4
5use 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    /// Up/Down: linear traversal through flattened tree
122    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    /// Left/Right: cycle within current stream's columns
135    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    /// Tab: jump to next/prev device, find best matching position
174    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        // Find all unique device indices
185        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        // Find current device position and move to next/prev
194        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        // Find best match on target device
207        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        // Preserve the previous selection by identity when possible; positional
648        // idx shifts if new items appear in front of it on the next rebuild.
649        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    // floor of 1200
1074    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; // how many lines the footer has to work with
1221    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    // Key thread
1703    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    // App state — subtree visible by default; limit with --depth.
1713    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    // UI
1725    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}