microtop.rs
1 //! Microtop — full-screen TUI dashboard over SSH. 2 //! 3 //! Launched via the `microtop` shell command. Uses ratatui with a custom 4 //! ANSI backend that writes escape sequences to the SSH channel. 5 6 use alloc::string::String as AllocString; 7 use core::fmt::Write as FmtWrite; 8 9 use ratatui::backend::Backend; 10 use ratatui::buffer::Cell; 11 use ratatui::layout::{Constraint, Direction, Layout, Position, Size}; 12 use ratatui::style::{Color, Modifier, Style}; 13 use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; 14 use ratatui::Terminal; 15 16 use esp_hal::clock; 17 18 use crate::config::app; 19 use crate::services::{identity, system}; 20 21 use embassy_time::Instant; 22 23 #[derive(Debug)] 24 struct AnsiError; 25 26 impl core::fmt::Display for AnsiError { 27 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 28 write!(f, "ANSI backend error") 29 } 30 } 31 32 impl core::error::Error for AnsiError {} 33 34 /// Ratatui backend that writes ANSI escape sequences to a byte buffer. 35 struct AnsiBackend { 36 buf: AllocString, 37 width: u16, 38 height: u16, 39 } 40 41 impl AnsiBackend { 42 fn new(width: u16, height: u16) -> Self { 43 Self { 44 buf: AllocString::new(), 45 width, 46 height, 47 } 48 } 49 50 fn take_output(&mut self) -> AllocString { 51 core::mem::replace(&mut self.buf, AllocString::new()) 52 } 53 54 fn ansi_fg(&mut self, color: Color) { 55 match color { 56 Color::Reset => { 57 let _ = write!(self.buf, "\x1b[39m"); 58 } 59 Color::Black => { 60 let _ = write!(self.buf, "\x1b[30m"); 61 } 62 Color::Red => { 63 let _ = write!(self.buf, "\x1b[31m"); 64 } 65 Color::Green => { 66 let _ = write!(self.buf, "\x1b[32m"); 67 } 68 Color::Yellow => { 69 let _ = write!(self.buf, "\x1b[33m"); 70 } 71 Color::Blue => { 72 let _ = write!(self.buf, "\x1b[34m"); 73 } 74 Color::Magenta => { 75 let _ = write!(self.buf, "\x1b[35m"); 76 } 77 Color::Cyan => { 78 let _ = write!(self.buf, "\x1b[36m"); 79 } 80 Color::White => { 81 let _ = write!(self.buf, "\x1b[37m"); 82 } 83 Color::Gray => { 84 let _ = write!(self.buf, "\x1b[90m"); 85 } 86 Color::DarkGray => { 87 let _ = write!(self.buf, "\x1b[90m"); 88 } 89 Color::LightRed => { 90 let _ = write!(self.buf, "\x1b[91m"); 91 } 92 Color::LightGreen => { 93 let _ = write!(self.buf, "\x1b[92m"); 94 } 95 Color::LightYellow => { 96 let _ = write!(self.buf, "\x1b[93m"); 97 } 98 Color::LightBlue => { 99 let _ = write!(self.buf, "\x1b[94m"); 100 } 101 Color::LightMagenta => { 102 let _ = write!(self.buf, "\x1b[95m"); 103 } 104 Color::LightCyan => { 105 let _ = write!(self.buf, "\x1b[96m"); 106 } 107 Color::Indexed(i) => { 108 let _ = write!(self.buf, "\x1b[38;5;{}m", i); 109 } 110 Color::Rgb(r, g, b) => { 111 let _ = write!(self.buf, "\x1b[38;2;{};{};{}m", r, g, b); 112 } 113 } 114 } 115 116 fn ansi_bg(&mut self, color: Color) { 117 match color { 118 Color::Reset => { 119 let _ = write!(self.buf, "\x1b[49m"); 120 } 121 Color::Black => { 122 let _ = write!(self.buf, "\x1b[40m"); 123 } 124 Color::Red => { 125 let _ = write!(self.buf, "\x1b[41m"); 126 } 127 Color::Green => { 128 let _ = write!(self.buf, "\x1b[42m"); 129 } 130 Color::Yellow => { 131 let _ = write!(self.buf, "\x1b[43m"); 132 } 133 Color::Blue => { 134 let _ = write!(self.buf, "\x1b[44m"); 135 } 136 Color::Magenta => { 137 let _ = write!(self.buf, "\x1b[45m"); 138 } 139 Color::Cyan => { 140 let _ = write!(self.buf, "\x1b[46m"); 141 } 142 Color::White => { 143 let _ = write!(self.buf, "\x1b[47m"); 144 } 145 Color::Gray | Color::DarkGray => { 146 let _ = write!(self.buf, "\x1b[100m"); 147 } 148 Color::LightRed => { 149 let _ = write!(self.buf, "\x1b[101m"); 150 } 151 Color::LightGreen => { 152 let _ = write!(self.buf, "\x1b[102m"); 153 } 154 Color::LightYellow => { 155 let _ = write!(self.buf, "\x1b[103m"); 156 } 157 Color::LightBlue => { 158 let _ = write!(self.buf, "\x1b[104m"); 159 } 160 Color::LightMagenta => { 161 let _ = write!(self.buf, "\x1b[105m"); 162 } 163 Color::LightCyan => { 164 let _ = write!(self.buf, "\x1b[106m"); 165 } 166 Color::Indexed(i) => { 167 let _ = write!(self.buf, "\x1b[48;5;{}m", i); 168 } 169 Color::Rgb(r, g, b) => { 170 let _ = write!(self.buf, "\x1b[48;2;{};{};{}m", r, g, b); 171 } 172 } 173 } 174 } 175 176 impl Backend for AnsiBackend { 177 type Error = AnsiError; 178 179 fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error> 180 where 181 I: Iterator<Item = (u16, u16, &'a Cell)>, 182 { 183 let mut last_x: u16 = u16::MAX; 184 let mut last_y: u16 = u16::MAX; 185 let mut last_fg = Color::Reset; 186 let mut last_bg = Color::Reset; 187 188 for (x, y, cell) in content { 189 if y != last_y || x != last_x + 1 { 190 let _ = write!(self.buf, "\x1b[{};{}H", y + 1, x + 1); 191 } 192 193 if cell.fg != last_fg { 194 self.ansi_fg(cell.fg); 195 last_fg = cell.fg; 196 } 197 if cell.bg != last_bg { 198 self.ansi_bg(cell.bg); 199 last_bg = cell.bg; 200 } 201 202 if cell.modifier.contains(Modifier::BOLD) { 203 let _ = write!(self.buf, "\x1b[1m"); 204 } 205 if cell.modifier.contains(Modifier::DIM) { 206 let _ = write!(self.buf, "\x1b[2m"); 207 } 208 209 let _ = write!(self.buf, "{}", cell.symbol()); 210 211 if cell.modifier.intersects(Modifier::BOLD | Modifier::DIM) { 212 let _ = write!(self.buf, "\x1b[22m"); 213 } 214 215 last_x = x; 216 last_y = y; 217 } 218 219 let _ = write!(self.buf, "\x1b[0m"); 220 Ok(()) 221 } 222 223 fn hide_cursor(&mut self) -> Result<(), Self::Error> { 224 let _ = write!(self.buf, "\x1b[?25l"); 225 Ok(()) 226 } 227 228 fn show_cursor(&mut self) -> Result<(), Self::Error> { 229 let _ = write!(self.buf, "\x1b[?25h"); 230 Ok(()) 231 } 232 233 fn get_cursor_position(&mut self) -> Result<Position, Self::Error> { 234 Ok(Position::new(0, 0)) 235 } 236 237 fn set_cursor_position<P: Into<Position>>(&mut self, pos: P) -> Result<(), Self::Error> { 238 let pos = pos.into(); 239 let _ = write!(self.buf, "\x1b[{};{}H", pos.y + 1, pos.x + 1); 240 Ok(()) 241 } 242 243 fn clear(&mut self) -> Result<(), Self::Error> { 244 let _ = write!(self.buf, "\x1b[2J\x1b[H"); 245 Ok(()) 246 } 247 248 fn clear_region( 249 &mut self, 250 _clear_type: ratatui::backend::ClearType, 251 ) -> Result<(), Self::Error> { 252 let _ = write!(self.buf, "\x1b[2J\x1b[H"); 253 Ok(()) 254 } 255 256 fn size(&self) -> Result<Size, Self::Error> { 257 Ok(Size::new(self.width, self.height)) 258 } 259 260 fn window_size(&mut self) -> Result<ratatui::backend::WindowSize, Self::Error> { 261 Ok(ratatui::backend::WindowSize { 262 columns_rows: Size::new(self.width, self.height), 263 pixels: Size::new(0, 0), 264 }) 265 } 266 267 fn flush(&mut self) -> Result<(), Self::Error> { 268 Ok(()) 269 } 270 } 271 272 /// Render one frame of the microtop dashboard and return it as an ANSI string. 273 pub fn render_frame(width: u16, height: u16) -> AllocString { 274 let backend = AnsiBackend::new(width, height); 275 let mut terminal = Terminal::new(backend).unwrap(); 276 277 let system_snapshot = system::snapshot(); 278 let sensor_inventory = &system_snapshot.sensors.inventory; 279 let carbon_dioxide = system_snapshot.sensors.carbon_dioxide; 280 let secs = Instant::now().as_secs(); 281 let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60); 282 283 let mut title_str = AllocString::new(); 284 let _ = write!( 285 title_str, 286 " {} @ {} | Uptime: {}h {}m {}s ", 287 identity::ssh_user(), 288 identity::hostname(), 289 h, 290 m, 291 s 292 ); 293 294 let mut cpu_str = AllocString::new(); 295 let _ = write!(cpu_str, "Xtensa LX7 @ {} MHz", clock::cpu_clock().as_mhz()); 296 let mut ip_str = AllocString::new(); 297 let _ = write!( 298 ip_str, 299 "{}.{}.{}.{}", 300 system_snapshot.network.station.ipv4_address[0], 301 system_snapshot.network.station.ipv4_address[1], 302 system_snapshot.network.station.ipv4_address[2], 303 system_snapshot.network.station.ipv4_address[3] 304 ); 305 let mut ports_str = AllocString::new(); 306 let _ = write!( 307 ports_str, 308 "SSH:{} HTTP:{} OTA:{}", 309 app::ssh::PORT, 310 app::http::PORT, 311 app::ota::PORT 312 ); 313 314 let heap_free = esp_alloc::HEAP.free(); 315 let mut mem_str = AllocString::new(); 316 let _ = write!(mem_str, "{} KiB free", heap_free / 1024); 317 318 let mut disk_str = AllocString::new(); 319 if system_snapshot.storage.sd_card_size_mb > 0 { 320 let gb = system_snapshot.storage.sd_card_size_mb as f32 / 1024.0; 321 let _ = write!(disk_str, "{:.1} GiB ({})", gb, app::sd_card::FS_TYPE); 322 } else { 323 let _ = write!(disk_str, "not detected"); 324 } 325 326 let mut co2_str = AllocString::new(); 327 let mut temp_str = AllocString::new(); 328 let mut rh_str = AllocString::new(); 329 if carbon_dioxide.ok { 330 let _ = write!(co2_str, "{:.1} ppm", carbon_dioxide.co2_ppm); 331 let _ = write!(temp_str, "{:.1}\u{00b0}C", carbon_dioxide.temperature); 332 let _ = write!(rh_str, "{:.1}%", carbon_dioxide.humidity); 333 } 334 335 let _ = terminal.draw(|frame| { 336 let area = frame.area(); 337 338 let chunks = Layout::default() 339 .direction(Direction::Vertical) 340 .constraints([ 341 Constraint::Length(3), 342 Constraint::Min(8), 343 Constraint::Length(3), 344 ]) 345 .split(area); 346 347 let title_block = Block::default() 348 .borders(Borders::ALL) 349 .style(Style::default().fg(Color::Cyan)); 350 frame.render_widget( 351 Paragraph::new(title_str.as_str()).block(title_block), 352 chunks[0], 353 ); 354 355 let mid = Layout::default() 356 .direction(Direction::Horizontal) 357 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) 358 .split(chunks[1]); 359 360 let sys_block = Block::default() 361 .title(" System ") 362 .borders(Borders::ALL) 363 .style(Style::default().fg(Color::Yellow)); 364 365 let sys_rows = [ 366 Row::new(["Host", "ESP32-S3"]), 367 Row::new(["CPU", cpu_str.as_str()]), 368 Row::new(["Memory", mem_str.as_str()]), 369 Row::new(["Disk", disk_str.as_str()]), 370 Row::new(["IP", ip_str.as_str()]), 371 Row::new(["Ports", ports_str.as_str()]), 372 ]; 373 374 let widths = [Constraint::Length(8), Constraint::Min(20)]; 375 let sys_table = Table::new(sys_rows, widths).block(sys_block); 376 frame.render_widget(sys_table, mid[0]); 377 378 let sensor_block = Block::default() 379 .title(" Sensors ") 380 .borders(Borders::ALL) 381 .style(Style::default().fg(Color::Green)); 382 383 let mut sensor_rows: heapless::Vec<Row, 8> = heapless::Vec::new(); 384 if !co2_str.is_empty() { 385 let _ = sensor_rows.push(Row::new(["CO2", co2_str.as_str()])); 386 let _ = sensor_rows.push(Row::new(["Temp", temp_str.as_str()])); 387 let _ = sensor_rows.push(Row::new(["Humidity", rh_str.as_str()])); 388 } else { 389 let _ = sensor_rows.push(Row::new(["Status", "No readings"])); 390 } 391 392 for sensor in sensor_inventory.iter() { 393 let _ = sensor_rows.push(Row::new([ 394 sensor.model, 395 sensor.transport_summary().bus_name, 396 ])); 397 } 398 399 let sensor_table = Table::new(sensor_rows, widths).block(sensor_block); 400 frame.render_widget(sensor_table, mid[1]); 401 402 let footer_block = Block::default() 403 .borders(Borders::ALL) 404 .style(Style::default().fg(Color::DarkGray)); 405 frame.render_widget( 406 Paragraph::new(" Press 'q' to exit microtop") 407 .style(Style::default().fg(Color::DarkGray)) 408 .block(footer_block), 409 chunks[2], 410 ); 411 }); 412 413 let _ = terminal.hide_cursor(); 414 terminal.backend_mut().take_output() 415 }