use std::time::Duration;
use crate::{
ActuatorConfig, ActuatorFeedback, ActuatorHealth, ActuatorProtocol, ActuatorSnapshot, CanBus,
CanFrame, CanFrameRecord, CanMonitor, Result, sample_can_monitor,
};
use scrin::effects::{EffectKind, EffectPlayer};
use scrin::widgets::Widget;
use scrin::{Buffer, Color, Frame, Rect};
use scrin_widgets::{AislingPalette, List};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum TuiMode {
View,
ControlArmed,
}
impl TuiMode {
pub const fn label(self) -> &'static str {
match self {
Self::View => "VIEW",
Self::ControlArmed => "CONTROL ARMED",
}
}
pub const fn is_armed(self) -> bool {
matches!(self, Self::ControlArmed)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TuiTarget {
pub id: u8,
pub protocol: ActuatorProtocol,
}
impl TuiTarget {
pub const fn from_config(config: &ActuatorConfig) -> Self {
Self {
id: config.id,
protocol: config.protocol,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum TuiCommand {
SelectNext,
SelectPrevious,
RefreshSelected,
ArmControl,
DisarmControl,
ToggleControlArm,
EnableSelected,
StopSelected,
ZeroSelected,
EmergencyStopAll,
Quit,
}
impl TuiCommand {
pub const fn from_key(key: char) -> Option<Self> {
match key {
'j' | 'n' => Some(Self::SelectNext),
'k' | 'p' => Some(Self::SelectPrevious),
'r' => Some(Self::RefreshSelected),
'a' => Some(Self::ToggleControlArm),
'e' => Some(Self::EnableSelected),
's' => Some(Self::StopSelected),
'z' => Some(Self::ZeroSelected),
'!' => Some(Self::EmergencyStopAll),
'q' => Some(Self::Quit),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum TuiControlIntent {
None,
Refresh(TuiTarget),
Enable(TuiTarget),
Stop(TuiTarget),
ZeroPosition(TuiTarget),
EmergencyStopAll,
Quit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CanMonitorCommand {
SelectNext,
SelectPrevious,
Pause,
Resume,
TogglePause,
Clear,
Quit,
}
impl CanMonitorCommand {
pub const fn from_key(key: char) -> Option<Self> {
match key {
'j' | 'n' => Some(Self::SelectNext),
'k' | 'p' => Some(Self::SelectPrevious),
' ' => Some(Self::TogglePause),
'c' => Some(Self::Clear),
'q' => Some(Self::Quit),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TuiActuatorAnalytics {
pub samples: u64,
pub fault_samples: u64,
pub first_tick: Option<u64>,
pub last_tick: Option<u64>,
pub min_position: Option<f64>,
pub max_position: Option<f64>,
pub peak_abs_effort: Option<f64>,
}
impl TuiActuatorAnalytics {
pub const fn empty() -> Self {
Self {
samples: 0,
fault_samples: 0,
first_tick: None,
last_tick: None,
min_position: None,
max_position: None,
peak_abs_effort: None,
}
}
pub fn observe(&mut self, snapshot: &ActuatorSnapshot, tick: u64) {
if snapshot.feedback.is_none() {
return;
}
self.samples = self.samples.saturating_add(1);
self.first_tick.get_or_insert(tick);
self.last_tick = Some(tick);
if snapshot.health() == ActuatorHealth::Fault {
self.fault_samples = self.fault_samples.saturating_add(1);
}
if let Some(position) = snapshot_position(snapshot) {
self.min_position = Some(match self.min_position {
Some(current) => current.min(position),
None => position,
});
self.max_position = Some(match self.max_position {
Some(current) => current.max(position),
None => position,
});
}
if let Some(effort) = snapshot_effort(snapshot) {
self.peak_abs_effort = Some(match self.peak_abs_effort {
Some(current) => current.max(effort.abs()),
None => effort.abs(),
});
}
}
pub fn age_ticks(self, current_tick: u64) -> Option<u64> {
self.last_tick.map(|tick| current_tick.saturating_sub(tick))
}
pub fn position_span(self) -> Option<f64> {
Some(self.max_position? - self.min_position?)
}
}
impl Default for TuiActuatorAnalytics {
fn default() -> Self {
Self::empty()
}
}
#[derive(Debug, Clone)]
pub struct TuiCanMonitor {
monitor: CanMonitor,
selected_index: usize,
tick: u64,
paused: bool,
last_message: Option<String>,
palette: AislingPalette,
}
impl TuiCanMonitor {
pub fn new(monitor: CanMonitor) -> Self {
Self {
monitor,
selected_index: 0,
tick: 0,
paused: false,
last_message: None,
palette: AislingPalette::cypherpunk(),
}
}
pub fn sample() -> Result<Self> {
Ok(Self::new(sample_can_monitor()?))
}
pub fn with_palette(mut self, palette: AislingPalette) -> Self {
self.palette = palette;
self
}
pub const fn selected_index(&self) -> usize {
self.selected_index
}
pub const fn is_paused(&self) -> bool {
self.paused
}
pub fn monitor(&self) -> &CanMonitor {
&self.monitor
}
pub fn monitor_mut(&mut self) -> &mut CanMonitor {
&mut self.monitor
}
pub fn last_message(&self) -> Option<&str> {
self.last_message.as_deref()
}
pub fn observe(&mut self, frame: CanFrame) {
self.monitor.observe(frame);
self.clamp_selection();
}
pub fn poll_available<B>(
&mut self,
bus: &mut B,
timeout: Duration,
max_frames: usize,
) -> Result<usize>
where
B: CanBus,
{
if self.paused {
return Ok(0);
}
let received = self.monitor.poll_available(bus, timeout, max_frames)?;
if received > 0 {
self.last_message = Some(format!("received {received} frame(s)"));
}
self.clamp_selection();
Ok(received)
}
pub fn apply_command(&mut self, command: CanMonitorCommand) -> bool {
match command {
CanMonitorCommand::SelectNext => {
self.select_next();
false
}
CanMonitorCommand::SelectPrevious => {
self.select_previous();
false
}
CanMonitorCommand::Pause => {
self.paused = true;
self.last_message = Some("CAN capture paused".to_owned());
false
}
CanMonitorCommand::Resume => {
self.paused = false;
self.last_message = Some("CAN capture resumed".to_owned());
false
}
CanMonitorCommand::TogglePause => {
if self.paused {
self.apply_command(CanMonitorCommand::Resume)
} else {
self.apply_command(CanMonitorCommand::Pause)
}
}
CanMonitorCommand::Clear => {
self.monitor.clear();
self.selected_index = 0;
self.last_message = Some("CAN log cleared".to_owned());
false
}
CanMonitorCommand::Quit => true,
}
}
pub fn advance_tick(&mut self) {
self.tick = self.tick.wrapping_add(1);
}
pub fn select_next(&mut self) {
let count = self.monitor.records().len();
if count > 0 {
self.selected_index = (self.selected_index + 1) % count;
}
}
pub fn select_previous(&mut self) {
let count = self.monitor.records().len();
if count == 0 {
return;
}
self.selected_index = self.selected_index.checked_sub(1).unwrap_or(count - 1);
}
pub fn selected_record(&self) -> Option<&CanFrameRecord> {
self.monitor.records().iter().rev().nth(self.selected_index)
}
pub fn row_strings(&self) -> Vec<String> {
let rows = self.monitor.rows();
if rows.is_empty() {
return vec!["waiting for CAN frames".to_owned()];
}
rows.into_iter()
.enumerate()
.map(|(index, row)| {
let marker = if index == self.selected_index {
">"
} else {
" "
};
format!("{marker} {row}")
})
.collect()
}
pub fn render_to_buffer(&self, buffer: &mut Buffer) {
let width = buffer.width().min(usize::from(u16::MAX)) as u16;
let height = buffer.height().min(usize::from(u16::MAX)) as u16;
self.render(buffer, Rect::new(0, 0, width, height));
}
pub fn render_frame(&self, frame: &mut Frame<'_>) {
let area = frame.area();
self.render(frame.buffer(), area);
}
pub fn render_plain(&self, width: usize, height: usize) -> String {
let mut buffer = Buffer::new(width, height);
self.render_to_buffer(&mut buffer);
buffer.to_plain_string()
}
pub fn render(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
buffer.fill(area, ' ', self.palette.low, Some(Color::BLACK));
let header_height = if area.height >= 16 { 7 } else { 5 }.min(area.height);
let footer_height = if area.height >= 9 { 3 } else { 0 };
let content_height = area
.height
.saturating_sub(header_height)
.saturating_sub(footer_height);
let header = Rect::new(area.x, area.y, area.width, header_height);
let content = Rect::new(
area.x,
area.y.saturating_add(header_height),
area.width,
content_height,
);
let footer = Rect::new(
area.x,
area.bottom().saturating_sub(footer_height),
area.width,
footer_height,
);
self.render_can_header(buffer, header);
if !content.is_empty() {
if content.width >= 100 && content.height >= 8 {
let list_width = content.width.saturating_mul(64) / 100;
let list_area = Rect::new(content.x, content.y, list_width, content.height);
let detail_area = Rect::new(
content.x.saturating_add(list_width),
content.y,
content.width.saturating_sub(list_width),
content.height,
);
self.render_can_list(buffer, list_area);
self.render_can_detail(buffer, detail_area);
} else {
let list_height = (content.height.saturating_mul(2) / 3).max(3);
let list_height = list_height.min(content.height);
let list_area = Rect::new(content.x, content.y, content.width, list_height);
let detail_area = Rect::new(
content.x,
content.y.saturating_add(list_height),
content.width,
content.height.saturating_sub(list_height),
);
self.render_can_list(buffer, list_area);
self.render_can_detail(buffer, detail_area);
}
}
self.render_can_footer(buffer, footer);
}
fn clamp_selection(&mut self) {
let count = self.monitor.records().len();
if count == 0 {
self.selected_index = 0;
} else if self.selected_index >= count {
self.selected_index = count - 1;
}
}
fn render_can_header(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let title_right = if self.paused { "PAUSED" } else { "LIVE" };
let block = self
.palette
.block("CAN monitor")
.with_title_right(title_right);
let inner = block.inner(area);
block.render(buffer, area);
let stats = self.monitor.stats();
let summary = format!(
"frames={} retained={} dropped={} std={} ext={} bytes={}",
stats.frames_seen,
stats.frames_retained,
stats.frames_dropped,
stats.standard_frames,
stats.extended_frames,
stats.bytes_seen
);
draw_line(buffer, inner, 0, &summary, self.palette.high);
if inner.height > 2 {
let effect_area = Rect::new(
inner.x,
inner.y.saturating_add(1),
inner.width,
inner.height.saturating_sub(2),
);
self.render_signal_effect(buffer, effect_area);
}
if let Some(message) = &self.last_message {
draw_line(
buffer,
inner,
inner.height.saturating_sub(1),
message,
self.palette.pulse,
);
}
}
fn render_signal_effect(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let mut effect = EffectPlayer::new(EffectKind::Matrix, "CAN BUS DATA")
.with_size(usize::from(area.width), usize::from(area.height))
.with_duration(96);
for _ in 0..(self.tick % 96) {
effect.advance();
}
effect.render_to_buffer(buffer, area);
}
fn render_can_list(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let selected = (!self.monitor.records().is_empty()).then_some(self.selected_index);
let list = List::new()
.items(self.row_strings())
.selected(selected)
.tick(self.tick)
.palette(self.palette)
.block(self.palette.block("Frames"));
list.render(buffer, area);
}
fn render_can_detail(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let block = self.palette.block("Frame data");
let inner = block.inner(area);
block.render(buffer, area);
for (index, line) in self.detail_lines().iter().enumerate() {
draw_line(buffer, inner, index as u16, line, self.palette.high);
}
}
fn render_can_footer(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let block = self.palette.block("Keys");
let inner = block.inner(area);
block.render(buffer, area);
draw_line(
buffer,
inner,
0,
"j/k select, space pause/resume, c clear, q quit",
self.palette.pulse,
);
}
fn detail_lines(&self) -> Vec<String> {
let Some(record) = self.selected_record() else {
return vec!["no CAN frame selected".to_owned()];
};
let formatted = record.formatted();
vec![
format!("sequence: {}", record.sequence),
format!("elapsed: {:.3}s", record.elapsed.as_secs_f64()),
format!("id: {}", formatted.id),
format!("len: {}", formatted.len),
format!("class: {}", formatted.class),
format!("hex: {}", formatted.data),
format!("ascii: {}", formatted.ascii),
]
}
}
#[derive(Debug, Clone)]
pub struct TuiDashboard {
snapshots: Vec<ActuatorSnapshot>,
analytics: Vec<TuiActuatorAnalytics>,
selected_index: usize,
mode: TuiMode,
controls_enabled: bool,
tick: u64,
last_message: Option<String>,
palette: AislingPalette,
}
impl TuiDashboard {
pub fn new(snapshots: Vec<ActuatorSnapshot>) -> Self {
let mut dashboard = Self {
analytics: Vec::with_capacity(snapshots.len()),
snapshots,
selected_index: 0,
mode: TuiMode::View,
controls_enabled: true,
tick: 0,
last_message: None,
palette: AislingPalette::phosphor(),
};
dashboard.rebuild_analytics_from_current_snapshots();
dashboard
}
pub fn with_palette(mut self, palette: AislingPalette) -> Self {
self.palette = palette;
self
}
pub const fn with_controls_enabled(mut self, enabled: bool) -> Self {
self.controls_enabled = enabled;
if !enabled {
self.mode = TuiMode::View;
}
self
}
pub fn motor_count(&self) -> usize {
self.snapshots.len()
}
pub const fn mode(&self) -> TuiMode {
self.mode
}
pub const fn controls_enabled(&self) -> bool {
self.controls_enabled
}
pub const fn selected_index(&self) -> usize {
self.selected_index
}
pub fn snapshots(&self) -> &[ActuatorSnapshot] {
&self.snapshots
}
pub fn analytics(&self) -> &[TuiActuatorAnalytics] {
&self.analytics
}
pub fn selected_snapshot(&self) -> Option<&ActuatorSnapshot> {
self.snapshots.get(self.selected_index)
}
pub fn selected_analytics(&self) -> Option<&TuiActuatorAnalytics> {
self.analytics.get(self.selected_index)
}
pub fn last_message(&self) -> Option<&str> {
self.last_message.as_deref()
}
pub fn advance_tick(&mut self) {
self.tick = self.tick.wrapping_add(1);
}
pub fn set_snapshots(&mut self, snapshots: Vec<ActuatorSnapshot>) {
let analytics = snapshots
.iter()
.map(|snapshot| {
let mut analytics = self
.snapshot_index(snapshot)
.and_then(|index| self.analytics.get(index).copied())
.unwrap_or_default();
analytics.observe(snapshot, self.tick);
analytics
})
.collect();
self.snapshots = snapshots;
self.analytics = analytics;
self.clamp_selection();
self.lock_control_if_armed("control commands locked after actuator update");
}
pub fn upsert_snapshot(&mut self, snapshot: ActuatorSnapshot) {
if let Some(index) = self.snapshot_index(&snapshot) {
self.snapshots[index] = snapshot;
self.analytics[index].observe(&self.snapshots[index], self.tick);
} else {
let mut analytics = TuiActuatorAnalytics::empty();
analytics.observe(&snapshot, self.tick);
self.snapshots.push(snapshot);
self.analytics.push(analytics);
}
self.clamp_selection();
self.lock_control_if_armed("control commands locked after actuator update");
}
pub fn select_next(&mut self) {
self.lock_control_if_armed("control commands locked after navigation");
if !self.snapshots.is_empty() {
self.selected_index = (self.selected_index + 1) % self.snapshots.len();
}
}
pub fn select_previous(&mut self) {
self.lock_control_if_armed("control commands locked after navigation");
if self.snapshots.is_empty() {
return;
}
self.selected_index = self
.selected_index
.checked_sub(1)
.unwrap_or(self.snapshots.len() - 1);
}
pub fn apply_command(&mut self, command: TuiCommand) -> TuiControlIntent {
match command {
TuiCommand::SelectNext => {
self.select_next();
TuiControlIntent::None
}
TuiCommand::SelectPrevious => {
self.select_previous();
TuiControlIntent::None
}
TuiCommand::RefreshSelected => self
.selected_target()
.map(TuiControlIntent::Refresh)
.unwrap_or(TuiControlIntent::None),
TuiCommand::ArmControl => {
if !self.controls_enabled {
self.last_message =
Some("control commands disabled in read-only dashboard".to_owned());
return TuiControlIntent::None;
}
if self.snapshots.is_empty() {
self.last_message =
Some("cannot arm control; no actuators configured".to_owned());
return TuiControlIntent::None;
}
self.mode = TuiMode::ControlArmed;
self.last_message = Some("control commands armed".to_owned());
TuiControlIntent::None
}
TuiCommand::DisarmControl => {
self.mode = TuiMode::View;
self.last_message = Some("control commands locked".to_owned());
TuiControlIntent::None
}
TuiCommand::ToggleControlArm => {
if self.mode.is_armed() {
self.apply_command(TuiCommand::DisarmControl)
} else {
self.apply_command(TuiCommand::ArmControl)
}
}
TuiCommand::EnableSelected => self.armed_target_intent(TuiControlIntent::Enable),
TuiCommand::StopSelected => self.armed_target_intent(TuiControlIntent::Stop),
TuiCommand::ZeroSelected => self.armed_target_intent(TuiControlIntent::ZeroPosition),
TuiCommand::EmergencyStopAll => {
if !self.controls_enabled {
self.last_message =
Some("control commands disabled in read-only dashboard".to_owned());
return TuiControlIntent::None;
}
self.mode = TuiMode::View;
self.last_message = Some("emergency stop requested for all actuators".to_owned());
TuiControlIntent::EmergencyStopAll
}
TuiCommand::Quit => TuiControlIntent::Quit,
}
}
pub fn row_strings(&self) -> Vec<String> {
if self.snapshots.is_empty() {
return vec!["no actuators configured".to_owned()];
}
self.snapshots
.iter()
.enumerate()
.map(|(index, snapshot)| {
let marker = if index == self.selected_index {
">"
} else {
" "
};
format!(
"{marker} {:<18} {:<14} {}",
snapshot.config.display_name(),
snapshot.config.protocol.family_name(),
snapshot.status_text()
)
})
.collect()
}
pub fn render_to_buffer(&self, buffer: &mut Buffer) {
let width = buffer.width().min(usize::from(u16::MAX)) as u16;
let height = buffer.height().min(usize::from(u16::MAX)) as u16;
self.render(buffer, Rect::new(0, 0, width, height));
}
pub fn render_frame(&self, frame: &mut Frame<'_>) {
let area = frame.area();
self.render(frame.buffer(), area);
}
pub fn render_plain(&self, width: usize, height: usize) -> String {
let mut buffer = Buffer::new(width, height);
self.render_to_buffer(&mut buffer);
buffer.to_plain_string()
}
pub fn render(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
buffer.fill(area, ' ', self.palette.low, Some(Color::BLACK));
let header_height = area.height.min(5);
let footer_height = if area.height >= 9 { 3 } else { 0 };
let content_height = area
.height
.saturating_sub(header_height)
.saturating_sub(footer_height);
let header = Rect::new(area.x, area.y, area.width, header_height);
let content = Rect::new(
area.x,
area.y.saturating_add(header_height),
area.width,
content_height,
);
let footer = Rect::new(
area.x,
area.bottom().saturating_sub(footer_height),
area.width,
footer_height,
);
self.render_header(buffer, header);
if !content.is_empty() {
if content.width >= 112 && content.height >= 12 && self.snapshots.len() > 1 {
self.render_pane_wall(buffer, content);
} else if content.width >= 88 && content.height >= 8 {
let list_width = content.width.saturating_mul(45) / 100;
let detail_width = content.width.saturating_sub(list_width);
let list_area = Rect::new(content.x, content.y, list_width, content.height);
let detail_area = Rect::new(
content.x.saturating_add(list_width),
content.y,
detail_width,
content.height,
);
self.render_list(buffer, list_area);
self.render_detail(buffer, detail_area);
} else {
let list_height = (content.height / 2).max(3);
let list_height = list_height.min(content.height);
let list_area = Rect::new(content.x, content.y, content.width, list_height);
let detail_area = Rect::new(
content.x,
content.y.saturating_add(list_height),
content.width,
content.height.saturating_sub(list_height),
);
self.render_list(buffer, list_area);
self.render_detail(buffer, detail_area);
}
}
self.render_footer(buffer, footer);
}
fn render_pane_wall(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
if self.snapshots.is_empty() {
draw_line(buffer, area, 0, "no actuators configured", self.palette.mid);
return;
}
let columns = if area.width >= 150 {
3
} else if area.width >= 112 {
2
} else {
1
};
let rows = self.snapshots.len().div_ceil(columns);
let pane_width = area.width / columns as u16;
let pane_height = (area.height / rows.max(1) as u16).max(6);
for (index, snapshot) in self.snapshots.iter().enumerate() {
let column = index % columns;
let row = index / columns;
let x = area
.x
.saturating_add(pane_width.saturating_mul(column as u16));
let y = area
.y
.saturating_add(pane_height.saturating_mul(row as u16));
if y >= area.bottom() {
break;
}
let width = if column + 1 == columns {
area.x.saturating_add(area.width).saturating_sub(x)
} else {
pane_width
};
let height = pane_height.min(area.bottom().saturating_sub(y));
self.render_snapshot_pane(
index,
snapshot,
index == self.selected_index,
buffer,
Rect::new(x, y, width, height),
);
}
}
fn render_snapshot_pane(
&self,
index: usize,
snapshot: &ActuatorSnapshot,
selected: bool,
buffer: &mut Buffer,
area: Rect,
) {
if area.is_empty() {
return;
}
let health = snapshot.health();
let block_title = if selected { "Selected" } else { "Actuator" };
let block = self
.palette
.block(block_title)
.with_title_right(health.label());
let inner = block.inner(area);
block.render(buffer, area);
draw_line(
buffer,
inner,
0,
&snapshot.config.display_name(),
self.health_color(health),
);
draw_line(
buffer,
inner,
1,
snapshot.config.protocol.model_name(),
self.palette.mid,
);
for (row, metric) in snapshot
.metrics()
.iter()
.take(inner.height.saturating_sub(4) as usize)
.enumerate()
{
let line = format!("{:<9} {:>10} {}", metric.label, metric.value, metric.unit);
draw_line(
buffer,
inner,
row as u16 + 2,
&line,
self.health_color(metric.health),
);
}
if let Some(message) = &self.last_message {
draw_line(
buffer,
inner,
inner.height.saturating_sub(1),
message,
self.palette.pulse,
);
} else if let Some(analytics) = self.analytics.get(index) {
if inner.height >= 4 {
draw_line(
buffer,
inner,
inner.height.saturating_sub(2),
&self.analytics_summary_line(analytics),
self.palette.mid,
);
if let Some(line) = self.analytics_range_line(snapshot, analytics) {
draw_line(
buffer,
inner,
inner.height.saturating_sub(1),
&line,
self.palette.mid,
);
}
}
}
}
fn selected_target(&self) -> Option<TuiTarget> {
self.selected_snapshot()
.map(|snapshot| TuiTarget::from_config(&snapshot.config))
}
fn armed_target_intent(
&mut self,
build: fn(TuiTarget) -> TuiControlIntent,
) -> TuiControlIntent {
if !self.controls_enabled {
self.last_message = Some("control commands disabled in read-only dashboard".to_owned());
return TuiControlIntent::None;
}
if !self.mode.is_armed() {
self.last_message = Some("control command blocked; press 'a' to arm".to_owned());
return TuiControlIntent::None;
}
let Some(target) = self.selected_target() else {
self.mode = TuiMode::View;
self.last_message = Some("no actuator selected; control commands locked".to_owned());
return TuiControlIntent::None;
};
self.mode = TuiMode::View;
self.last_message = Some("control intent emitted; commands locked".to_owned());
build(target)
}
fn lock_control_if_armed(&mut self, message: &str) {
if self.mode.is_armed() {
self.mode = TuiMode::View;
self.last_message = Some(message.to_owned());
}
}
fn clamp_selection(&mut self) {
if self.snapshots.is_empty() {
self.selected_index = 0;
} else if self.selected_index >= self.snapshots.len() {
self.selected_index = self.snapshots.len() - 1;
}
}
fn snapshot_index(&self, snapshot: &ActuatorSnapshot) -> Option<usize> {
self.snapshots.iter().position(|candidate| {
candidate.config.id == snapshot.config.id
&& candidate.config.protocol == snapshot.config.protocol
})
}
fn rebuild_analytics_from_current_snapshots(&mut self) {
self.analytics = self
.snapshots
.iter()
.map(|snapshot| {
let mut analytics = TuiActuatorAnalytics::empty();
analytics.observe(snapshot, self.tick);
analytics
})
.collect();
}
fn render_header(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let block = self
.palette
.block("kcan")
.with_title_right(self.mode.label());
let inner = block.inner(area);
block.render(buffer, area);
let selected = self
.selected_snapshot()
.map(|snapshot| snapshot.config.display_name())
.unwrap_or_else(|| "none".to_owned());
let summary = format!(
"motors={} selected={} mode={}",
self.motor_count(),
selected,
self.mode.label()
);
draw_line(buffer, inner, 0, &summary, self.palette.high);
draw_line(
buffer,
inner,
1,
"safe view mode starts locked; arm before direct control intents",
self.palette.mid,
);
}
fn render_list(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let selected = (!self.snapshots.is_empty()).then_some(self.selected_index);
let list = List::new()
.items(self.row_strings())
.selected(selected)
.tick(self.tick)
.palette(self.palette)
.block(self.palette.block("Actuators"));
list.render(buffer, area);
}
fn render_detail(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let block = self.palette.block("Selected");
let inner = block.inner(area);
block.render(buffer, area);
let lines = self.detail_lines();
for (index, line) in lines.iter().enumerate() {
draw_line(buffer, inner, index as u16, line, self.palette.high);
}
}
fn render_footer(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let block = self.palette.block("Controls");
let inner = block.inner(area);
block.render(buffer, area);
let controls = if !self.controls_enabled {
"READ ONLY: j/k select, r refresh, q quit"
} else if self.mode.is_armed() {
"ARMED one-shot: e enable, s stop, z zero, a lock, ! emergency stop, q quit"
} else {
"j/k select, r refresh, a arm controls, ! emergency stop, q quit"
};
draw_line(buffer, inner, 0, controls, self.palette.pulse);
}
fn detail_lines(&self) -> Vec<String> {
let mut lines = Vec::new();
match self.selected_snapshot() {
Some(snapshot) => {
lines.push(format!("name: {}", snapshot.config.display_name()));
lines.push(format!(
"protocol: {} / {}",
snapshot.config.protocol.family_name(),
snapshot.config.protocol.model_name()
));
lines.push(format!("id: 0x{:02X}", snapshot.config.id));
lines.push(format!("health: {}", snapshot.health().label()));
lines.push(format!("status: {}", snapshot.status_text()));
if let Some(analytics) = self.selected_analytics() {
lines.push(format!(
"analytics: {}",
self.analytics_summary_line(analytics)
));
if let Some(line) = self.analytics_range_line(snapshot, analytics) {
lines.push(format!("analytics: {line}"));
}
}
for metric in snapshot.metrics() {
lines.push(format!(
"metric: {:<9} {:>10} {} [{}]",
metric.label,
metric.value,
metric.unit,
metric.health.label()
));
}
}
None => lines.push("no actuator selected".to_owned()),
}
if let Some(message) = &self.last_message {
lines.push(format!("message: {message}"));
}
lines
}
fn analytics_summary_line(&self, analytics: &TuiActuatorAnalytics) -> String {
let age = analytics
.age_ticks(self.tick)
.map(|ticks| format!("{ticks}t"))
.unwrap_or_else(|| "n/a".to_owned());
format!(
"samples={} age={} faults={}",
analytics.samples, age, analytics.fault_samples
)
}
fn analytics_range_line(
&self,
snapshot: &ActuatorSnapshot,
analytics: &TuiActuatorAnalytics,
) -> Option<String> {
let mut parts = Vec::new();
if let Some(span) = analytics.position_span() {
parts.push(format!(
"span={span:.3} {}",
position_unit(snapshot.config.protocol)
));
}
if let Some(peak) = analytics.peak_abs_effort {
parts.push(format!(
"peak={peak:.2} {}",
effort_unit(snapshot.config.protocol)
));
}
(!parts.is_empty()).then(|| parts.join(" "))
}
fn health_color(&self, health: ActuatorHealth) -> Color {
match health {
ActuatorHealth::Nominal => self.palette.high,
ActuatorHealth::Warning => self.palette.pulse,
ActuatorHealth::Fault => self.palette.pulse,
ActuatorHealth::Stale => self.palette.mid,
}
}
}
fn snapshot_position(snapshot: &ActuatorSnapshot) -> Option<f64> {
match snapshot.feedback.as_ref()? {
ActuatorFeedback::CubeMars(feedback) => Some(feedback.position_degrees),
ActuatorFeedback::RobStride(feedback) => Some(feedback.position_rad),
}
}
fn snapshot_effort(snapshot: &ActuatorSnapshot) -> Option<f64> {
match snapshot.feedback.as_ref()? {
ActuatorFeedback::CubeMars(feedback) => Some(feedback.current_amps),
ActuatorFeedback::RobStride(feedback) => Some(feedback.torque_nm),
}
}
fn position_unit(protocol: ActuatorProtocol) -> &'static str {
match protocol {
ActuatorProtocol::CubeMars(_) => "deg",
ActuatorProtocol::RobStride(_) => "rad",
}
}
fn effort_unit(protocol: ActuatorProtocol) -> &'static str {
match protocol {
ActuatorProtocol::CubeMars(_) => "A",
ActuatorProtocol::RobStride(_) => "Nm",
}
}
fn draw_line(buffer: &mut Buffer, area: Rect, row: u16, text: &str, color: Color) {
if row >= area.height || area.width == 0 {
return;
}
let clipped = clip_text(text, usize::from(area.width));
buffer.set_str(
usize::from(area.x),
usize::from(area.y.saturating_add(row)),
&clipped,
color,
Some(Color::BLACK),
);
}
fn clip_text(text: &str, width: usize) -> String {
text.chars().take(width).collect()
}