Bevy+Ratutui powered Monitoring of Pico-Strike devices
at main 360 lines 11 kB view raw
1use core::net::SocketAddr; 2 3use bevy::{ 4 ecs::{ 5 entity::Entity, 6 error::Result, 7 message::MessageReader, 8 name::Name, 9 query::With, 10 resource::Resource, 11 system::{Commands, Query, Res, ResMut}, 12 world::World, 13 }, 14 state::state::NextState, 15 time::{Real, Time, Timer}, 16}; 17use bevy_ratatui::RatatuiContext; 18use jiff::SignedDuration; 19use ratatui::{ 20 layout::{Constraint, HorizontalAlignment, Layout}, 21 style::{Color, Stylize}, 22 text::{Line, Span, Text, ToSpan}, 23 widgets::{Axis, Block, Chart, Dataset, Padding, Paragraph, Widget}, 24}; 25use striker_proto::{Response, StrikerRequest, StrikerResponse, Update}; 26 27use crate::{ 28 device::{ 29 ConnectedDevice, ConnectionState, Device, DeviceDetector, DeviceSocket, SignalAverage, 30 SignalPeaks, SignalSource, Signals, StormLevel, StormLevels, StormSignal, StormSource, 31 Timestamp, 32 }, 33 messages::StrikeMessage, 34 net::{StrikeAction, StrikeActions, StrikeRequests, StrikeUpdateState, StrikeUpdates}, 35 state::AppState, 36}; 37 38#[derive(Debug, Resource)] 39pub struct DataClear(pub Timer); 40 41pub fn monitoring_message_handler( 42 mut strike_reader: MessageReader<StrikeMessage>, 43 mut commands: Commands, 44) { 45 for message in strike_reader.read() { 46 if let StrikeMessage::StopMonitoring = message { 47 commands.queue(|world: &mut World| { 48 let mut next = world.resource_mut::<NextState<AppState>>(); 49 next.set(AppState::Home); 50 }); 51 } 52 } 53} 54 55pub fn enter_monitoring( 56 connected: Res<ConnectedDevice>, 57 signal: Res<StrikeActions>, 58 request: Res<StrikeRequests>, 59 q_devices: Query<&DeviceSocket, With<Device>>, 60 mut commands: Commands, 61) -> Result { 62 let addr = q_devices.get(connected.device)?; 63 64 signal 65 .0 66 .try_send(StrikeAction::Connect(SocketAddr::new(addr.ip, addr.port)))?; 67 68 request.0.force_send(StrikerRequest { 69 request: striker_proto::Request::DetectorInfo, 70 })?; 71 72 commands.insert_resource(DataClear(Timer::from_seconds( 73 60.0 * 1.0, 74 bevy::time::TimerMode::Repeating, 75 ))); 76 77 Ok(()) 78} 79 80pub fn exit_monitoring(signal: Res<StrikeActions>, mut commands: Commands) -> Result { 81 signal.0.try_send(StrikeAction::Disconnect)?; 82 commands.remove_resource::<DataClear>(); 83 84 Ok(()) 85} 86 87pub fn clear_device_data( 88 mut timer: ResMut<DataClear>, 89 time: Res<Time<Real>>, 90 mut commands: Commands, 91) { 92 timer.0.tick(time.delta()); 93 94 if timer.0.just_finished() { 95 commands.queue(|world: &mut World| { 96 let now = jiff::Timestamp::now(); 97 let to_remove: Vec<_> = world 98 .query_filtered::<(Entity, &Timestamp), With<StormLevel>>() 99 .iter(world) 100 .filter_map(|(entity, time)| { 101 (time.0.duration_since(now) > SignedDuration::from_mins(5)).then_some(entity) 102 }) 103 .collect(); 104 105 for remove in to_remove { 106 world.despawn(remove); 107 } 108 }); 109 } 110} 111 112pub fn update_device_data( 113 updates: Res<StrikeUpdates>, 114 mut connected: ResMut<ConnectedDevice>, 115 mut commands: Commands, 116) -> Result { 117 let mut entity = commands.entity(connected.device); 118 119 while let Ok(update) = updates.0.try_recv() { 120 match update { 121 StrikeUpdateState::Connected => { 122 connected.connection_state = ConnectionState::Connected; 123 } 124 StrikeUpdateState::Connecting => { 125 connected.connection_state = ConnectionState::Connecting; 126 } 127 StrikeUpdateState::Updating(response) => match response { 128 StrikerResponse::Response(Response::DetectorInfo { 129 blip_threshold, 130 blip_size, 131 }) => { 132 entity.insert(DeviceDetector { 133 blip_threshold, 134 blip_size, 135 }); 136 } 137 StrikerResponse::Update(Update::Warning { timestamp, level }) => { 138 entity.with_related::<StormSource>(( 139 Timestamp::from_microseconds(timestamp)?, 140 StormLevel(level), 141 )); 142 } 143 StrikerResponse::Update(Update::Strike { 144 timestamp, 145 peaks, 146 samples, 147 average, 148 }) => { 149 entity.with_related::<SignalSource>(( 150 Timestamp::from_microseconds(timestamp)?, 151 SignalPeaks::new(peaks), 152 StormSignal::new(samples), 153 SignalAverage::new(average), 154 )); 155 } 156 _ => {} 157 }, 158 StrikeUpdateState::Disconnected => { 159 connected.connection_state = ConnectionState::Disconnected; 160 } 161 } 162 } 163 164 Ok(()) 165} 166 167pub fn monitoring_view( 168 mut context: ResMut<RatatuiContext>, 169 connected: Res<ConnectedDevice>, 170 q_devices: Query< 171 ( 172 &Name, 173 Option<&DeviceDetector>, 174 Option<&StormLevels>, 175 Option<&Signals>, 176 ), 177 With<Device>, 178 >, 179 q_levels: Query<&StormLevel>, 180 q_signals: Query<(&Timestamp, &StormSignal, &SignalPeaks)>, 181) -> Result { 182 context.draw(|frame| { 183 let [mid, bottom] = 184 Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]).areas(frame.area()); 185 186 let [chart_block, side_block] = 187 Layout::horizontal([Constraint::Fill(3), Constraint::Fill(1)]).areas(mid); 188 189 let [device_block, detector_block] = 190 Layout::vertical([Constraint::Fill(1), Constraint::Fill(1)]).areas(side_block); 191 192 let (device, detector, levels, signals) = q_devices.get(connected.device).unwrap(); 193 194 let latest_level = levels.and_then(|c| { 195 c.last() 196 .copied() 197 .and_then(|entity| q_levels.get(entity).ok()) 198 }); 199 let latest_signal = signals 200 .and_then(|s| s.last().copied()) 201 .and_then(|entity| q_signals.get(entity).ok()); 202 203 let timestamp = latest_signal.map(|(t, _, _)| t); 204 205 let info = DeviceInfo { 206 name: device, 207 connected: &connected, 208 timestamp, 209 level: latest_level, 210 }; 211 212 let signal_chart = SignalChart { 213 signal: latest_signal, 214 }; 215 216 let help = Paragraph::new("Keys: 'q'/ESC Quit, BACKSPACE Return to Device select") 217 .block( 218 Block::bordered() 219 .padding(Padding::horizontal(2)) 220 .border_style(Color::LightGreen), 221 ) 222 .white(); 223 224 let detector = detector.copied().unwrap_or_default(); 225 226 frame.render_widget(info, device_block); 227 frame.render_widget(signal_chart, chart_block); 228 frame.render_widget(detector, detector_block); 229 frame.render_widget(help, bottom); 230 })?; 231 232 Ok(()) 233} 234 235struct DeviceInfo<'a> { 236 name: &'a Name, 237 connected: &'a ConnectedDevice, 238 timestamp: Option<&'a Timestamp>, 239 level: Option<&'a StormLevel>, 240} 241 242impl Widget for DeviceInfo<'_> { 243 fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) 244 where 245 Self: Sized, 246 { 247 let lines = [ 248 Line::from(self.name.to_span()), 249 Line::from_iter([ 250 Span::raw("Status: "), 251 self.connected.connection_state.to_span(), 252 ]), 253 ] 254 .into_iter() 255 .chain(self.timestamp.map(|t| Line::from(t.0.to_span()))) 256 .chain( 257 self.level 258 .map(|level| Line::from_iter([Span::raw("Level: "), level.0.to_span()])), 259 ); 260 261 let info = Paragraph::new(Text::from_iter(lines)) 262 .block( 263 Block::bordered() 264 .padding(Padding::horizontal(1)) 265 .title("Device") 266 .title_alignment(HorizontalAlignment::Center) 267 .border_style(Color::LightGreen), 268 ) 269 .white(); 270 271 info.render(area, buf); 272 } 273} 274 275struct SignalChart<'a> { 276 signal: Option<(&'a Timestamp, &'a StormSignal, &'a SignalPeaks)>, 277} 278 279impl Widget for SignalChart<'_> { 280 fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) 281 where 282 Self: Sized, 283 { 284 let (data, detected) = self 285 .signal 286 .map(|(_, samples, signals)| { 287 ( 288 samples 289 .iter() 290 .enumerate() 291 .map(|(x, y)| (x as f64, *y as f64)) 292 .collect(), 293 signals.iter().fold(None, |acc, el| { 294 Some(acc.map_or_else( 295 || (el.0, el.0 + 32), 296 |prev: (usize, usize)| (prev.0, prev.1.max(el.0 + 32)), 297 )) 298 }), 299 ) 300 }) 301 .unwrap_or_else(|| (Vec::new(), None)); 302 303 let y_bounds = data 304 .iter() 305 .map(|(_, y)| y.abs()) 306 .reduce(f64::max) 307 .unwrap_or(0.0); 308 309 let detected = detected.map_or_else(Vec::new, |signal| { 310 (signal.0..=signal.1) 311 .map(|i| (i as f64, y_bounds)) 312 .collect() 313 }); 314 315 let datasets = vec![ 316 Dataset::default() 317 .graph_type(ratatui::widgets::GraphType::Line) 318 .marker(ratatui::symbols::Marker::Braille) 319 .red() 320 .data(&data), 321 ]; 322 323 let block = Block::bordered() 324 .title("Details") 325 .border_style(Color::LightGreen); 326 327 let chart_labels = [ 328 (-y_bounds).to_string(), 329 "0".to_string(), 330 y_bounds.to_string(), 331 ]; 332 333 let chart2 = Chart::new(vec![ 334 Dataset::default() 335 .graph_type(ratatui::widgets::GraphType::Bar) 336 .marker(ratatui::symbols::Marker::Dot) 337 .style(Color::Indexed(22)) 338 .data(&detected), 339 ]) 340 .x_axis(Axis::default().bounds([0.0, 512.0])) 341 .y_axis( 342 Axis::default() 343 .bounds([0.0, 1.0]) 344 .labels(chart_labels.clone()), 345 ) 346 .block(block.clone()); 347 348 let chart = Chart::new(datasets) 349 .x_axis(Axis::default().bounds([0.0, 512.0])) 350 .y_axis( 351 Axis::default() 352 .bounds([-y_bounds, y_bounds]) 353 .labels(chart_labels), 354 ) 355 .block(block); 356 357 chart2.render(area, buf); 358 chart.render(area, buf); 359 } 360}