From 76a6e5629a48167c2968c1bd80cb12d581c8ba2d Mon Sep 17 00:00:00 2001 From: Sachymetsu Date: Thu, 8 Jan 2026 12:23:47 +0100 Subject: [PATCH] Tidy mdns loop, better keybinds messaging, initial monitoring view --- src/device.rs | 16 ++---- src/lib.rs | 67 ++++++++++++------------ src/messages.rs | 11 ++-- src/net.rs | 98 ++++++++++++++++++----------------- src/views.rs | 35 ++++++++++++- src/views/home.rs | 112 ++++++++++++++++++++++++++++++++++++---- src/views/monitoring.rs | 68 ++++++++++++++++++++++++ 7 files changed, 293 insertions(+), 114 deletions(-) create mode 100644 src/views/monitoring.rs diff --git a/src/device.rs b/src/device.rs index edd7b7f..1b7153f 100644 --- a/src/device.rs +++ b/src/device.rs @@ -1,4 +1,4 @@ -use std::net::IpAddr; +use core::net::IpAddr; use bevy::{ app::{Plugin, Update}, @@ -11,7 +11,6 @@ use bevy::{ system::{Commands, Res, ResMut}, world::DeferredWorld, }, - time::Timer, }; use rapidhash::RapidHashSet; @@ -28,6 +27,7 @@ pub struct Device; pub struct DeviceSocket { pub address: String, pub port: u16, + pub ip: IpAddr, } fn on_remove_device(mut world: DeferredWorld, context: HookContext) { @@ -62,11 +62,6 @@ pub struct StormLevels { #[derive(Debug, Resource)] pub struct ConnectedDevice(pub Entity); -#[derive(Debug, Resource, Default)] -pub struct SearchingDevices { - pub searching: Option, -} - fn register_devices( incoming: Res, mut unique: ResMut, @@ -78,15 +73,12 @@ fn register_devices( let device_addr = DeviceSocket { address: discovered.address, port: discovered.port, + ip: discovered.ip, }; if !unique.0.contains(&device_addr) { unique.0.insert(device_addr.clone()); - devices.push(( - Device, - Name::new(discovered.host), - device_addr, - )); + devices.push((Device, Name::new(discovered.host), device_addr)); } } diff --git a/src/lib.rs b/src/lib.rs index 1cf88e7..c2c6498 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,21 +5,21 @@ mod state; mod views; use bevy::{ - app::{AppExit, Plugin, PreUpdate, Update}, + app::{AppExit, Plugin, PreUpdate}, ecs::{ message::{MessageReader, MessageWriter}, - system::{Res, ResMut}, + system::Res, }, - state::app::AppExtStates, - time::{Real, Time, Timer}, + state::{app::AppExtStates, state::State}, }; use bevy_ratatui::event::KeyMessage; use crate::{ - device::{DevicePlugin, SearchingDevices}, - net::{MdnsSignaler, NetPlugin}, + device::DevicePlugin, + messages::StrikeMessage, + net::NetPlugin, state::AppState, - views::HomeViewPlugin, + views::{HomeViewPlugin, MonitoringViewPlugin}, }; #[derive(Debug)] @@ -27,33 +27,41 @@ pub struct StrikerPlugin; impl Plugin for StrikerPlugin { fn build(&self, app: &mut bevy::app::App) { - app.init_resource::() - .init_state::() - .add_plugins((NetPlugin, DevicePlugin, HomeViewPlugin)) - .add_systems(PreUpdate, keybinds) - .add_systems(Update, search_timer); + app.init_state::() + .add_message::() + .add_plugins(( + NetPlugin, + DevicePlugin, + HomeViewPlugin, + MonitoringViewPlugin, + )) + .add_systems(PreUpdate, keybinds); } } fn keybinds( - signal: Res, + state: Res>, mut key_reader: MessageReader, - mut is_searching: Option>, + mut strike_writer: MessageWriter, mut app_exit: MessageWriter, ) { use ratatui::crossterm::event::KeyCode; for message in key_reader.read() { match message.code { - KeyCode::Char('s') => { - if let Some(is_searching) = is_searching.as_deref_mut() { - if let Some(_) = is_searching.searching { - is_searching.searching = None; - } else { - is_searching.searching = - Some(Timer::from_seconds(1.0, bevy::time::TimerMode::Once)); - } - let _ = signal.0.try_send(()); - } + KeyCode::Char('s') if state.get() == &AppState::Home => { + strike_writer.write(StrikeMessage::ToggleSearch); + } + KeyCode::Up if state.get() == &AppState::Home => { + strike_writer.write(StrikeMessage::PrevDevice); + } + KeyCode::Down if state.get() == &AppState::Home => { + strike_writer.write(StrikeMessage::NextDevice); + } + KeyCode::Enter | KeyCode::Char(' ') if state.get() == &AppState::Home => { + strike_writer.write(StrikeMessage::MonitorDevice); + } + KeyCode::Backspace if state.get() == &AppState::Monitoring => { + strike_writer.write(StrikeMessage::StopMonitoring); } KeyCode::Char('q') | KeyCode::Esc => { app_exit.write(AppExit::Success); @@ -62,14 +70,3 @@ fn keybinds( } } } - -fn search_timer(mut is_searching: Option>, time: Res>) { - if let Some(s) = is_searching.as_deref_mut() - && let Some(timer) = &mut s.searching - { - timer.tick(time.delta()); - if timer.is_finished() { - s.searching = None; - } - } -} diff --git a/src/messages.rs b/src/messages.rs index 5eaed6b..36000aa 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -1,14 +1,11 @@ -/// Messages are events that should effect some update -/// to component states, or prompt Actions to be submitted. +use bevy::ecs::message::Message; + /// Messages can be user input or from network updates. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Message)] pub enum StrikeMessage { - StartSearch, - FinishSearch, - FoundDevice, + ToggleSearch, NextDevice, PrevDevice, MonitorDevice, StopMonitoring, - Finish, } diff --git a/src/net.rs b/src/net.rs index ab5921c..9329c51 100644 --- a/src/net.rs +++ b/src/net.rs @@ -1,3 +1,4 @@ +use core::net::IpAddr; use std::{ net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket}, time::Duration, @@ -7,7 +8,7 @@ use async_channel::{Receiver, Sender}; use async_io::{Async, Timer}; use bevy::{ app::{Plugin, Startup}, - ecs::{resource::Resource, system::Commands}, + ecs::{error::Result, resource::Resource, system::Commands}, tasks::IoTaskPool, }; use futures_concurrency::future::Race; @@ -25,6 +26,7 @@ pub struct InstanceDetails { pub host: String, pub address: String, pub port: u16, + pub ip: IpAddr, } #[derive(Debug, Resource)] @@ -39,72 +41,74 @@ fn create_mdns_socket() -> std::io::Result> { Async::new_nonblocking(udp_socket) } -pub fn setup_mdns_task(mut commands: Commands) { +pub fn setup_mdns_task(mut commands: Commands) -> Result { let io = IoTaskPool::get(); let (signal_tx, signal_rx) = async_channel::bounded(1); let (resp_tx, resp_rx) = async_channel::bounded(64); - io.spawn(async move { - let mut buf = vec![0u8; 4096]; - - let udp_socket = create_mdns_socket().unwrap(); + let udp_socket = create_mdns_socket()?; - loop { - if signal_rx.recv().await.is_ok() { - let query_fut = async { - let query = query_service("_picostrike._tcp.local", &mut buf).unwrap(); + io.spawn(async move { + let mut buf = vec![0u8; 1028]; + let mut query_buf = vec![0u8; 128]; + let query = query_service("_picostrike._tcp.local", &mut query_buf).unwrap(); + while signal_rx.recv().await.is_ok() { + let send_fut = async { + for _ in 0..3 { udp_socket.send_to(query, GROUP_SOCK_V4).await.ok(); - - while let Ok((read, _)) = udp_socket.recv_from(&mut buf).await { - let input = &buf[..read]; - let resp = Response::parse(&mut &*input, input).unwrap(); - - if resp - .answers - .iter() - .find(|answer| { - if let Record::PTR(_) = &answer.record { - answer.name == "_picostrike._tcp.local" - } else { - false - } - }) - .is_some() - && let Some(instance) = resp.additional.iter().find_map(|answer| { - if let Record::SRV(srv) = &answer.record { - Some(InstanceDetails { - host: answer.name.to_string(), - address: srv.target.to_string(), - port: srv.port, - }) - } else { - None - } + Timer::after(Duration::from_millis(250)).await; + } + }; + + let recv_fut = async { + while let Ok((read, socket)) = udp_socket.recv_from(&mut buf).await { + let input = &buf[..read]; + let Ok(resp) = Response::parse(&mut &*input, input) else { + continue; + }; + + if resp.answers.iter().any(|answer| { + if let Record::PTR(_) = &answer.record { + answer.name == "_picostrike._tcp.local" + } else { + false + } + }) && let Some(instance) = resp.additional.iter().find_map(|answer| { + if let Record::SRV(srv) = &answer.record { + Some(InstanceDetails { + host: answer.name.to_string(), + address: srv.target.to_string(), + port: srv.port, + ip: socket.ip(), }) - { - resp_tx.send(instance).await.ok(); + } else { + None } + }) { + resp_tx.send(instance).await.ok(); } - }; + } + }; - let timer = async { - Timer::after(Duration::from_millis(1000)).await; - }; + let timer = async { + Timer::after(Duration::from_millis(1000)).await; + }; - let cancel = async { - signal_rx.recv().await.ok(); - }; + let cancel = async { + signal_rx.recv().await.ok(); + }; - (query_fut, timer, cancel).race().await; - } + (send_fut, recv_fut, timer, cancel).race().await; } }) .detach(); commands.insert_resource(DiscoverResponse(resp_rx)); commands.insert_resource(MdnsSignaler(signal_tx)); + + Ok(()) } pub struct NetPlugin; diff --git a/src/views.rs b/src/views.rs index 2db632d..8d72bae 100644 --- a/src/views.rs +++ b/src/views.rs @@ -1,17 +1,48 @@ use bevy::{ app::{Plugin, PostUpdate}, ecs::schedule::IntoScheduleConfigs, - state::condition::in_state, + state::{ + condition::in_state, + state::{OnEnter, OnExit}, + }, }; use crate::state::AppState; pub mod home; +pub mod monitoring; pub struct HomeViewPlugin; impl Plugin for HomeViewPlugin { fn build(&self, app: &mut bevy::app::App) { - app.add_systems(PostUpdate, home::home_view.run_if(in_state(AppState::Home))); + app.add_systems(OnEnter(AppState::Home), home::setup_home_view) + .add_systems(OnExit(AppState::Home), home::cleanup_home_view) + .add_systems( + PostUpdate, + ( + home::search_timer, + home::home_message_handler, + home::home_view, + ) + .chain() + .run_if(in_state(AppState::Home)), + ); + } +} + +pub struct MonitoringViewPlugin; + +impl Plugin for MonitoringViewPlugin { + fn build(&self, app: &mut bevy::app::App) { + app.add_systems( + PostUpdate, + ( + monitoring::monitoring_message_handler, + monitoring::monitoring_view, + ) + .chain() + .run_if(in_state(AppState::Monitoring)), + ); } } diff --git a/src/views/home.rs b/src/views/home.rs index 884c048..b7bb4d6 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -1,21 +1,98 @@ -use bevy::ecs::{ - error::Result, - name::Name, - query::With, - system::{Query, Res, ResMut}, +use bevy::{ + ecs::{ + entity::Entity, + error::Result, + message::MessageReader, + name::Name, + query::With, + resource::Resource, + system::{Commands, Query, Res, ResMut}, + world::World, + }, + state::state::NextState, + time::{Real, Time, Timer}, }; use bevy_ratatui::RatatuiContext; use ratatui::{ layout::{Constraint, HorizontalAlignment, Layout}, style::Color, - widgets::{Block, List, ListDirection, ListItem, Padding, Paragraph}, + widgets::{Block, List, ListDirection, ListItem, ListState, Padding, Paragraph}, }; -use crate::device::{Device, DeviceSocket, SearchingDevices}; +use crate::{ + device::{ConnectedDevice, Device, DeviceSocket}, + messages::StrikeMessage, + net::MdnsSignaler, + state::AppState, +}; + +#[derive(Debug, Default, Resource)] +pub struct DeviceListState(ListState); + +#[derive(Debug, Default, Resource)] +pub struct SearchingDevices { + pub searching: Option, +} + +pub fn setup_home_view(mut commands: Commands) { + commands.init_resource::(); + commands.init_resource::(); +} + +pub fn cleanup_home_view(mut commands: Commands) { + commands.remove_resource::(); + commands.remove_resource::(); +} + +pub fn home_message_handler( + signal: Res, + mut list_state: ResMut, + mut is_searching: ResMut, + mut strike_reader: MessageReader, + mut commands: Commands, +) { + for message in strike_reader.read() { + match message { + StrikeMessage::ToggleSearch => { + if is_searching.searching.is_some() { + is_searching.searching = None; + } else { + is_searching.searching = + Some(Timer::from_seconds(1.0, bevy::time::TimerMode::Once)); + } + let _ = signal.0.try_send(()); + } + StrikeMessage::NextDevice if is_searching.searching.is_none() => { + list_state.0.select_next(); + } + StrikeMessage::PrevDevice if is_searching.searching.is_none() => { + list_state.0.select_previous(); + } + StrikeMessage::MonitorDevice if is_searching.searching.is_none() => { + let offset = list_state.0.offset(); + + commands.queue(move |world: &mut World| -> Result { + let device = world + .query_filtered::>() + .iter(world) + .nth(offset) + .unwrap(); + world.insert_resource(ConnectedDevice(device)); + let mut next_state = world.resource_mut::>(); + next_state.set(AppState::Monitoring); + + Ok(()) + }); + } + _ => {} + } + } +} pub fn home_view( mut context: ResMut, is_searching: Res, + mut list_state: ResMut, q_devices: Query<(&Name, &DeviceSocket), With>, ) -> Result { context.draw(|frame| { @@ -35,11 +112,15 @@ pub fn home_view( .border_style(Color::LightBlue), ); - let items = q_devices - .iter() - .map(|(name, addr)| ListItem::new(format!("{}, {}:{}", name, addr.address, addr.port))); + let items = q_devices.iter().map(|(name, addr)| { + ListItem::new(format!( + "{}, {}:{}, {:?}", + name, addr.address, addr.port, addr.ip + )) + }); let list = List::new(items) + .highlight_symbol(">> ") .direction(ListDirection::TopToBottom) .block( Block::bordered() @@ -49,8 +130,17 @@ pub fn home_view( ); frame.render_widget(paragraph, top); - frame.render_widget(list, bottom); + frame.render_stateful_widget(list, bottom, &mut list_state.0); })?; Ok(()) } + +pub fn search_timer(mut is_searching: ResMut, time: Res>) { + if let Some(timer) = &mut is_searching.searching { + timer.tick(time.delta()); + if timer.is_finished() { + is_searching.searching = None; + } + } +} diff --git a/src/views/monitoring.rs b/src/views/monitoring.rs new file mode 100644 index 0000000..51950ae --- /dev/null +++ b/src/views/monitoring.rs @@ -0,0 +1,68 @@ +use bevy::{ + ecs::{ + error::Result, + message::MessageReader, + name::Name, + query::With, + system::{Commands, Query, Res, ResMut}, + world::World, + }, + state::state::NextState, +}; +use bevy_ratatui::RatatuiContext; +use ratatui::{ + layout::{Constraint, HorizontalAlignment, Layout}, + style::Color, + widgets::{Block, Padding, Paragraph}, +}; + +use crate::{ + device::{ConnectedDevice, Device}, + messages::StrikeMessage, + state::AppState, +}; + +pub fn monitoring_message_handler( + mut strike_reader: MessageReader, + mut commands: Commands, +) { + for message in strike_reader.read() { + if let StrikeMessage::StopMonitoring = message { + commands.queue(|world: &mut World| { + let mut next = world.resource_mut::>(); + next.set(AppState::Home); + }); + } + } +} + +pub fn monitoring_view( + mut context: ResMut, + connected: Res, + q_devices: Query<&Name, With>, +) -> Result { + context.draw(|frame| { + let [top, bottom] = + Layout::vertical([Constraint::Length(3), Constraint::Fill(1)]).areas(frame.area()); + + let device = q_devices.get(connected.0).unwrap(); + + let paragraph = Paragraph::new(device.as_str()).block( + Block::bordered() + .padding(Padding::horizontal(2)) + .title("Device") + .title_alignment(HorizontalAlignment::Center) + .border_style(Color::LightGreen), + ); + + let block = Block::bordered() + .title("Details") + .padding(Padding::new(2, 2, 1, 1)) + .border_style(Color::LightGreen); + + frame.render_widget(paragraph, top); + frame.render_widget(block, bottom); + })?; + + Ok(()) +} -- 2.51.2