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