Bevy+Ratutui powered Monitoring of Pico-Strike devices

Tidy mdns loop, better keybinds messaging, initial monitoring view

+291 -112
+4 -12
src/device.rs
··· 1 - use std::net::IpAddr; 1 + use core::net::IpAddr; 2 2 3 3 use bevy::{ 4 4 app::{Plugin, Update}, ··· 11 11 system::{Commands, Res, ResMut}, 12 12 world::DeferredWorld, 13 13 }, 14 - time::Timer, 15 14 }; 16 15 use rapidhash::RapidHashSet; 17 16 ··· 28 27 pub struct DeviceSocket { 29 28 pub address: String, 30 29 pub port: u16, 30 + pub ip: IpAddr, 31 31 } 32 32 33 33 fn on_remove_device(mut world: DeferredWorld, context: HookContext) { ··· 62 62 #[derive(Debug, Resource)] 63 63 pub struct ConnectedDevice(pub Entity); 64 64 65 - #[derive(Debug, Resource, Default)] 66 - pub struct SearchingDevices { 67 - pub searching: Option<Timer>, 68 - } 69 - 70 65 fn register_devices( 71 66 incoming: Res<DiscoverResponse>, 72 67 mut unique: ResMut<UniqueDevices>, ··· 78 73 let device_addr = DeviceSocket { 79 74 address: discovered.address, 80 75 port: discovered.port, 76 + ip: discovered.ip, 81 77 }; 82 78 83 79 if !unique.0.contains(&device_addr) { 84 80 unique.0.insert(device_addr.clone()); 85 - devices.push(( 86 - Device, 87 - Name::new(discovered.host), 88 - device_addr, 89 - )); 81 + devices.push((Device, Name::new(discovered.host), device_addr)); 90 82 } 91 83 } 92 84
+32 -35
src/lib.rs
··· 5 5 mod views; 6 6 7 7 use bevy::{ 8 - app::{AppExit, Plugin, PreUpdate, Update}, 8 + app::{AppExit, Plugin, PreUpdate}, 9 9 ecs::{ 10 10 message::{MessageReader, MessageWriter}, 11 - system::{Res, ResMut}, 11 + system::Res, 12 12 }, 13 - state::app::AppExtStates, 14 - time::{Real, Time, Timer}, 13 + state::{app::AppExtStates, state::State}, 15 14 }; 16 15 use bevy_ratatui::event::KeyMessage; 17 16 18 17 use crate::{ 19 - device::{DevicePlugin, SearchingDevices}, 20 - net::{MdnsSignaler, NetPlugin}, 18 + device::DevicePlugin, 19 + messages::StrikeMessage, 20 + net::NetPlugin, 21 21 state::AppState, 22 - views::HomeViewPlugin, 22 + views::{HomeViewPlugin, MonitoringViewPlugin}, 23 23 }; 24 24 25 25 #[derive(Debug)] ··· 27 27 28 28 impl Plugin for StrikerPlugin { 29 29 fn build(&self, app: &mut bevy::app::App) { 30 - app.init_resource::<SearchingDevices>() 31 - .init_state::<AppState>() 32 - .add_plugins((NetPlugin, DevicePlugin, HomeViewPlugin)) 33 - .add_systems(PreUpdate, keybinds) 34 - .add_systems(Update, search_timer); 30 + app.init_state::<AppState>() 31 + .add_message::<StrikeMessage>() 32 + .add_plugins(( 33 + NetPlugin, 34 + DevicePlugin, 35 + HomeViewPlugin, 36 + MonitoringViewPlugin, 37 + )) 38 + .add_systems(PreUpdate, keybinds); 35 39 } 36 40 } 37 41 38 42 fn keybinds( 39 - signal: Res<MdnsSignaler>, 43 + state: Res<State<AppState>>, 40 44 mut key_reader: MessageReader<KeyMessage>, 41 - mut is_searching: Option<ResMut<SearchingDevices>>, 45 + mut strike_writer: MessageWriter<StrikeMessage>, 42 46 mut app_exit: MessageWriter<AppExit>, 43 47 ) { 44 48 use ratatui::crossterm::event::KeyCode; 45 49 for message in key_reader.read() { 46 50 match message.code { 47 - KeyCode::Char('s') => { 48 - if let Some(is_searching) = is_searching.as_deref_mut() { 49 - if let Some(_) = is_searching.searching { 50 - is_searching.searching = None; 51 - } else { 52 - is_searching.searching = 53 - Some(Timer::from_seconds(1.0, bevy::time::TimerMode::Once)); 54 - } 55 - let _ = signal.0.try_send(()); 56 - } 51 + KeyCode::Char('s') if state.get() == &AppState::Home => { 52 + strike_writer.write(StrikeMessage::ToggleSearch); 53 + } 54 + KeyCode::Up if state.get() == &AppState::Home => { 55 + strike_writer.write(StrikeMessage::PrevDevice); 56 + } 57 + KeyCode::Down if state.get() == &AppState::Home => { 58 + strike_writer.write(StrikeMessage::NextDevice); 59 + } 60 + KeyCode::Enter | KeyCode::Char(' ') if state.get() == &AppState::Home => { 61 + strike_writer.write(StrikeMessage::MonitorDevice); 62 + } 63 + KeyCode::Backspace if state.get() == &AppState::Monitoring => { 64 + strike_writer.write(StrikeMessage::StopMonitoring); 57 65 } 58 66 KeyCode::Char('q') | KeyCode::Esc => { 59 67 app_exit.write(AppExit::Success); ··· 62 70 } 63 71 } 64 72 } 65 - 66 - fn search_timer(mut is_searching: Option<ResMut<SearchingDevices>>, time: Res<Time<Real>>) { 67 - if let Some(s) = is_searching.as_deref_mut() 68 - && let Some(timer) = &mut s.searching 69 - { 70 - timer.tick(time.delta()); 71 - if timer.is_finished() { 72 - s.searching = None; 73 - } 74 - } 75 - }
+4 -7
src/messages.rs
··· 1 - /// Messages are events that should effect some update 2 - /// to component states, or prompt Actions to be submitted. 1 + use bevy::ecs::message::Message; 2 + 3 3 /// Messages can be user input or from network updates. 4 - #[derive(Debug, PartialEq, Eq)] 4 + #[derive(Debug, PartialEq, Eq, Message)] 5 5 pub enum StrikeMessage { 6 - StartSearch, 7 - FinishSearch, 8 - FoundDevice, 6 + ToggleSearch, 9 7 NextDevice, 10 8 PrevDevice, 11 9 MonitorDevice, 12 10 StopMonitoring, 13 - Finish, 14 11 }
+49 -45
src/net.rs
··· 1 + use core::net::IpAddr; 1 2 use std::{ 2 3 net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket}, 3 4 time::Duration, ··· 7 8 use async_io::{Async, Timer}; 8 9 use bevy::{ 9 10 app::{Plugin, Startup}, 10 - ecs::{resource::Resource, system::Commands}, 11 + ecs::{error::Result, resource::Resource, system::Commands}, 11 12 tasks::IoTaskPool, 12 13 }; 13 14 use futures_concurrency::future::Race; ··· 25 26 pub host: String, 26 27 pub address: String, 27 28 pub port: u16, 29 + pub ip: IpAddr, 28 30 } 29 31 30 32 #[derive(Debug, Resource)] ··· 39 41 Async::new_nonblocking(udp_socket) 40 42 } 41 43 42 - pub fn setup_mdns_task(mut commands: Commands) { 44 + pub fn setup_mdns_task(mut commands: Commands) -> Result { 43 45 let io = IoTaskPool::get(); 44 46 45 47 let (signal_tx, signal_rx) = async_channel::bounded(1); 46 48 let (resp_tx, resp_rx) = async_channel::bounded(64); 47 49 48 - io.spawn(async move { 49 - let mut buf = vec![0u8; 4096]; 50 - 51 - let udp_socket = create_mdns_socket().unwrap(); 50 + let udp_socket = create_mdns_socket()?; 52 51 53 - loop { 54 - if signal_rx.recv().await.is_ok() { 55 - let query_fut = async { 56 - let query = query_service("_picostrike._tcp.local", &mut buf).unwrap(); 52 + io.spawn(async move { 53 + let mut buf = vec![0u8; 1028]; 54 + let mut query_buf = vec![0u8; 128]; 55 + let query = query_service("_picostrike._tcp.local", &mut query_buf).unwrap(); 57 56 57 + while signal_rx.recv().await.is_ok() { 58 + let send_fut = async { 59 + for _ in 0..3 { 58 60 udp_socket.send_to(query, GROUP_SOCK_V4).await.ok(); 61 + Timer::after(Duration::from_millis(250)).await; 62 + } 63 + }; 59 64 60 - while let Ok((read, _)) = udp_socket.recv_from(&mut buf).await { 61 - let input = &buf[..read]; 62 - let resp = Response::parse(&mut &*input, input).unwrap(); 65 + let recv_fut = async { 66 + while let Ok((read, socket)) = udp_socket.recv_from(&mut buf).await { 67 + let input = &buf[..read]; 68 + let Ok(resp) = Response::parse(&mut &*input, input) else { 69 + continue; 70 + }; 63 71 64 - if resp 65 - .answers 66 - .iter() 67 - .find(|answer| { 68 - if let Record::PTR(_) = &answer.record { 69 - answer.name == "_picostrike._tcp.local" 70 - } else { 71 - false 72 - } 73 - }) 74 - .is_some() 75 - && let Some(instance) = resp.additional.iter().find_map(|answer| { 76 - if let Record::SRV(srv) = &answer.record { 77 - Some(InstanceDetails { 78 - host: answer.name.to_string(), 79 - address: srv.target.to_string(), 80 - port: srv.port, 81 - }) 82 - } else { 83 - None 84 - } 72 + if resp.answers.iter().any(|answer| { 73 + if let Record::PTR(_) = &answer.record { 74 + answer.name == "_picostrike._tcp.local" 75 + } else { 76 + false 77 + } 78 + }) && let Some(instance) = resp.additional.iter().find_map(|answer| { 79 + if let Record::SRV(srv) = &answer.record { 80 + Some(InstanceDetails { 81 + host: answer.name.to_string(), 82 + address: srv.target.to_string(), 83 + port: srv.port, 84 + ip: socket.ip(), 85 85 }) 86 - { 87 - resp_tx.send(instance).await.ok(); 86 + } else { 87 + None 88 88 } 89 + }) { 90 + resp_tx.send(instance).await.ok(); 89 91 } 90 - }; 92 + } 93 + }; 91 94 92 - let timer = async { 93 - Timer::after(Duration::from_millis(1000)).await; 94 - }; 95 + let timer = async { 96 + Timer::after(Duration::from_millis(1000)).await; 97 + }; 95 98 96 - let cancel = async { 97 - signal_rx.recv().await.ok(); 98 - }; 99 + let cancel = async { 100 + signal_rx.recv().await.ok(); 101 + }; 99 102 100 - (query_fut, timer, cancel).race().await; 101 - } 103 + (send_fut, recv_fut, timer, cancel).race().await; 102 104 } 103 105 }) 104 106 .detach(); 105 107 106 108 commands.insert_resource(DiscoverResponse(resp_rx)); 107 109 commands.insert_resource(MdnsSignaler(signal_tx)); 110 + 111 + Ok(()) 108 112 } 109 113 110 114 pub struct NetPlugin;
+33 -2
src/views.rs
··· 1 1 use bevy::{ 2 2 app::{Plugin, PostUpdate}, 3 3 ecs::schedule::IntoScheduleConfigs, 4 - state::condition::in_state, 4 + state::{ 5 + condition::in_state, 6 + state::{OnEnter, OnExit}, 7 + }, 5 8 }; 6 9 7 10 use crate::state::AppState; 8 11 9 12 pub mod home; 13 + pub mod monitoring; 10 14 11 15 pub struct HomeViewPlugin; 12 16 13 17 impl Plugin for HomeViewPlugin { 14 18 fn build(&self, app: &mut bevy::app::App) { 15 - app.add_systems(PostUpdate, home::home_view.run_if(in_state(AppState::Home))); 19 + app.add_systems(OnEnter(AppState::Home), home::setup_home_view) 20 + .add_systems(OnExit(AppState::Home), home::cleanup_home_view) 21 + .add_systems( 22 + PostUpdate, 23 + ( 24 + home::search_timer, 25 + home::home_message_handler, 26 + home::home_view, 27 + ) 28 + .chain() 29 + .run_if(in_state(AppState::Home)), 30 + ); 31 + } 32 + } 33 + 34 + pub struct MonitoringViewPlugin; 35 + 36 + impl Plugin for MonitoringViewPlugin { 37 + fn build(&self, app: &mut bevy::app::App) { 38 + app.add_systems( 39 + PostUpdate, 40 + ( 41 + monitoring::monitoring_message_handler, 42 + monitoring::monitoring_view, 43 + ) 44 + .chain() 45 + .run_if(in_state(AppState::Monitoring)), 46 + ); 16 47 } 17 48 }
+101 -11
src/views/home.rs
··· 1 - use bevy::ecs::{ 2 - error::Result, 3 - name::Name, 4 - query::With, 5 - system::{Query, Res, ResMut}, 1 + use bevy::{ 2 + ecs::{ 3 + entity::Entity, 4 + error::Result, 5 + message::MessageReader, 6 + name::Name, 7 + query::With, 8 + resource::Resource, 9 + system::{Commands, Query, Res, ResMut}, 10 + world::World, 11 + }, 12 + state::state::NextState, 13 + time::{Real, Time, Timer}, 6 14 }; 7 15 use bevy_ratatui::RatatuiContext; 8 16 use ratatui::{ 9 17 layout::{Constraint, HorizontalAlignment, Layout}, 10 18 style::Color, 11 - widgets::{Block, List, ListDirection, ListItem, Padding, Paragraph}, 19 + widgets::{Block, List, ListDirection, ListItem, ListState, Padding, Paragraph}, 12 20 }; 13 21 14 - use crate::device::{Device, DeviceSocket, SearchingDevices}; 22 + use crate::{ 23 + device::{ConnectedDevice, Device, DeviceSocket}, 24 + messages::StrikeMessage, 25 + net::MdnsSignaler, 26 + state::AppState, 27 + }; 28 + 29 + #[derive(Debug, Default, Resource)] 30 + pub struct DeviceListState(ListState); 31 + 32 + #[derive(Debug, Default, Resource)] 33 + pub struct SearchingDevices { 34 + pub searching: Option<Timer>, 35 + } 36 + 37 + pub fn setup_home_view(mut commands: Commands) { 38 + commands.init_resource::<SearchingDevices>(); 39 + commands.init_resource::<DeviceListState>(); 40 + } 41 + 42 + pub fn cleanup_home_view(mut commands: Commands) { 43 + commands.remove_resource::<DeviceListState>(); 44 + commands.remove_resource::<SearchingDevices>(); 45 + } 46 + 47 + pub fn home_message_handler( 48 + signal: Res<MdnsSignaler>, 49 + mut list_state: ResMut<DeviceListState>, 50 + mut is_searching: ResMut<SearchingDevices>, 51 + mut strike_reader: MessageReader<StrikeMessage>, 52 + mut commands: Commands, 53 + ) { 54 + for message in strike_reader.read() { 55 + match message { 56 + StrikeMessage::ToggleSearch => { 57 + if is_searching.searching.is_some() { 58 + is_searching.searching = None; 59 + } else { 60 + is_searching.searching = 61 + Some(Timer::from_seconds(1.0, bevy::time::TimerMode::Once)); 62 + } 63 + let _ = signal.0.try_send(()); 64 + } 65 + StrikeMessage::NextDevice if is_searching.searching.is_none() => { 66 + list_state.0.select_next(); 67 + } 68 + StrikeMessage::PrevDevice if is_searching.searching.is_none() => { 69 + list_state.0.select_previous(); 70 + } 71 + StrikeMessage::MonitorDevice if is_searching.searching.is_none() => { 72 + let offset = list_state.0.offset(); 73 + 74 + commands.queue(move |world: &mut World| -> Result { 75 + let device = world 76 + .query_filtered::<Entity, With<Device>>() 77 + .iter(world) 78 + .nth(offset) 79 + .unwrap(); 80 + world.insert_resource(ConnectedDevice(device)); 81 + let mut next_state = world.resource_mut::<NextState<AppState>>(); 82 + next_state.set(AppState::Monitoring); 83 + 84 + Ok(()) 85 + }); 86 + } 87 + _ => {} 88 + } 89 + } 90 + } 15 91 16 92 pub fn home_view( 17 93 mut context: ResMut<RatatuiContext>, 18 94 is_searching: Res<SearchingDevices>, 95 + mut list_state: ResMut<DeviceListState>, 19 96 q_devices: Query<(&Name, &DeviceSocket), With<Device>>, 20 97 ) -> Result { 21 98 context.draw(|frame| { ··· 35 112 .border_style(Color::LightBlue), 36 113 ); 37 114 38 - let items = q_devices 39 - .iter() 40 - .map(|(name, addr)| ListItem::new(format!("{}, {}:{}", name, addr.address, addr.port))); 115 + let items = q_devices.iter().map(|(name, addr)| { 116 + ListItem::new(format!( 117 + "{}, {}:{}, {:?}", 118 + name, addr.address, addr.port, addr.ip 119 + )) 120 + }); 41 121 42 122 let list = List::new(items) 123 + .highlight_symbol(">> ") 43 124 .direction(ListDirection::TopToBottom) 44 125 .block( 45 126 Block::bordered() ··· 49 130 ); 50 131 51 132 frame.render_widget(paragraph, top); 52 - frame.render_widget(list, bottom); 133 + frame.render_stateful_widget(list, bottom, &mut list_state.0); 53 134 })?; 54 135 55 136 Ok(()) 56 137 } 138 + 139 + pub fn search_timer(mut is_searching: ResMut<SearchingDevices>, time: Res<Time<Real>>) { 140 + if let Some(timer) = &mut is_searching.searching { 141 + timer.tick(time.delta()); 142 + if timer.is_finished() { 143 + is_searching.searching = None; 144 + } 145 + } 146 + }
+68
src/views/monitoring.rs
··· 1 + use bevy::{ 2 + ecs::{ 3 + error::Result, 4 + message::MessageReader, 5 + name::Name, 6 + query::With, 7 + system::{Commands, Query, Res, ResMut}, 8 + world::World, 9 + }, 10 + state::state::NextState, 11 + }; 12 + use bevy_ratatui::RatatuiContext; 13 + use ratatui::{ 14 + layout::{Constraint, HorizontalAlignment, Layout}, 15 + style::Color, 16 + widgets::{Block, Padding, Paragraph}, 17 + }; 18 + 19 + use crate::{ 20 + device::{ConnectedDevice, Device}, 21 + messages::StrikeMessage, 22 + state::AppState, 23 + }; 24 + 25 + pub fn monitoring_message_handler( 26 + mut strike_reader: MessageReader<StrikeMessage>, 27 + mut commands: Commands, 28 + ) { 29 + for message in strike_reader.read() { 30 + if let StrikeMessage::StopMonitoring = message { 31 + commands.queue(|world: &mut World| { 32 + let mut next = world.resource_mut::<NextState<AppState>>(); 33 + next.set(AppState::Home); 34 + }); 35 + } 36 + } 37 + } 38 + 39 + pub fn monitoring_view( 40 + mut context: ResMut<RatatuiContext>, 41 + connected: Res<ConnectedDevice>, 42 + q_devices: Query<&Name, With<Device>>, 43 + ) -> Result { 44 + context.draw(|frame| { 45 + let [top, bottom] = 46 + Layout::vertical([Constraint::Length(3), Constraint::Fill(1)]).areas(frame.area()); 47 + 48 + let device = q_devices.get(connected.0).unwrap(); 49 + 50 + let paragraph = Paragraph::new(device.as_str()).block( 51 + Block::bordered() 52 + .padding(Padding::horizontal(2)) 53 + .title("Device") 54 + .title_alignment(HorizontalAlignment::Center) 55 + .border_style(Color::LightGreen), 56 + ); 57 + 58 + let block = Block::bordered() 59 + .title("Details") 60 + .padding(Padding::new(2, 2, 1, 1)) 61 + .border_style(Color::LightGreen); 62 + 63 + frame.render_widget(paragraph, top); 64 + frame.render_widget(block, bottom); 65 + })?; 66 + 67 + Ok(()) 68 + }