Bevy+Ratutui powered Monitoring of Pico-Strike devices
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}