/ firmware / src / programs / microtop.rs
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  }