Skip to content

Commit 8aeab41

Browse files
committed
Add toggleable heartbeat connection checking
- Add heartbeat_enabled bool to scanner config (disabled by default) - Add /api/heartbeat GET/POST endpoints to control connectivity checking - Add "Check connection" checkbox in Discovery UI section - Update device status display based on heartbeat state: - Online devices show green when checking, white/"Unknown" when not - Offline devices always show red/"Offline" (they were seen offline) - Selection bounding box color reflects device status and heartbeat state - Show "Last Seen: Now" for online devices, timestamp for offline - Update README with new heartbeat configuration and API endpoints Signed-off-by: Benjamin Perseghetti <bperseghetti@rudislabs.com>
1 parent a23444e commit 8aeab41

File tree

9 files changed

+313
-63
lines changed

9 files changed

+313
-63
lines changed

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ The daemon will:
115115
2. Serve the WASM frontend at `/`
116116
3. Provide REST API at `/api/*`
117117
4. Provide WebSocket at `/ws`
118-
5. Periodically check device health via ARP
118+
5. Optionally check device connectivity via ARP (disabled by default)
119119

120120
### QR Code Generator
121121

@@ -155,7 +155,8 @@ Create a `dendrite.toml` file in the working directory:
155155
```toml
156156
[daemon]
157157
bind = "0.0.0.0:8080"
158-
heartbeat_interval_secs = 2 # ARP health check interval
158+
heartbeat_interval_secs = 2 # ARP connectivity check interval
159+
heartbeat_enabled = false # Disable connectivity checking by default
159160

160161
[discovery]
161162
subnet = "192.168.1.0" # Network to scan
@@ -183,6 +184,8 @@ path = "./dendrite.hcdf" # Output HCDF file
183184
| `/api/interfaces` | GET | List network interfaces |
184185
| `/api/subnet` | POST | Update scan subnet |
185186
| `/api/scan` | POST | Trigger network scan |
187+
| `/api/heartbeat` | GET | Get connectivity check status |
188+
| `/api/heartbeat` | POST | Enable/disable connectivity checking |
186189

187190
## WebSocket
188191

@@ -233,8 +236,10 @@ Place glTF/GLB models in `assets/models/`. Models are loaded based on fragment d
233236
- Orbit camera (drag to rotate)
234237
- Pan (shift+drag or two-finger drag)
235238
- Zoom (scroll or pinch)
236-
- Device selection (click/tap)
239+
- Device selection (click/tap on 3D models)
237240
- Position/rotation editing
241+
- Connection status indicators (green=online, red=offline, white=unknown)
242+
- Toggle connectivity checking via "Check connection" checkbox
238243

239244
## GitHub Pages Deployment
240245

crates/dendrite-daemon/src/api.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,33 @@ pub async fn update_subnet(
278278
}))
279279
.into_response()
280280
}
281+
282+
/// Request to toggle heartbeat (connection checking)
283+
#[derive(Deserialize)]
284+
pub struct HeartbeatRequest {
285+
pub enabled: bool,
286+
}
287+
288+
/// Enable or disable heartbeat connection checking
289+
pub async fn set_heartbeat(
290+
State(state): State<Arc<AppState>>,
291+
Json(req): Json<HeartbeatRequest>,
292+
) -> impl IntoResponse {
293+
info!(enabled = req.enabled, "Setting heartbeat checking");
294+
state.scanner.set_heartbeat_enabled(req.enabled).await;
295+
296+
Json(serde_json::json!({
297+
"status": "updated",
298+
"heartbeat_enabled": req.enabled
299+
}))
300+
}
301+
302+
/// Get heartbeat status
303+
pub async fn get_heartbeat(
304+
State(state): State<Arc<AppState>>,
305+
) -> impl IntoResponse {
306+
let enabled = state.scanner.is_heartbeat_enabled().await;
307+
Json(serde_json::json!({
308+
"heartbeat_enabled": enabled
309+
}))
310+
}

crates/dendrite-daemon/src/config.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ pub struct DaemonConfig {
3737
/// Heartbeat interval in seconds (lightweight status check)
3838
#[serde(default = "default_heartbeat_interval")]
3939
pub heartbeat_interval_secs: u64,
40+
/// Whether heartbeat checking is enabled (sends ARP/ping to check connectivity)
41+
#[serde(default)]
42+
pub heartbeat_enabled: bool,
4043
/// TLS configuration (optional - enables HTTPS when present)
4144
#[serde(default)]
4245
pub tls: Option<TlsConfig>,
@@ -48,6 +51,7 @@ impl Default for DaemonConfig {
4851
bind: default_bind(),
4952
discovery_interval_secs: default_interval(),
5053
heartbeat_interval_secs: default_heartbeat_interval(),
54+
heartbeat_enabled: false, // Disabled by default
5155
tls: None,
5256
}
5357
}
@@ -71,7 +75,7 @@ fn default_interval() -> u64 {
7175
}
7276

7377
fn default_heartbeat_interval() -> u64 {
74-
2 // Lightweight ARP/ping check every 2 seconds
78+
2 // Lightweight ARP/ping check every 2 seconds (when enabled)
7579
}
7680

7781
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -220,6 +224,7 @@ impl Config {
220224
mcumgr_port: self.discovery.mcumgr_port,
221225
interval_secs: self.daemon.discovery_interval_secs,
222226
heartbeat_interval_secs: self.daemon.heartbeat_interval_secs,
227+
heartbeat_enabled: self.daemon.heartbeat_enabled,
223228
use_lldp: self.discovery.use_lldp,
224229
use_arp: self.discovery.use_arp,
225230
parent: self.parent.as_ref().map(|p| ParentConfig {

crates/dendrite-daemon/src/server.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ pub async fn run(state: Arc<AppState>, bind: &str, tls: Option<&TlsConfig>) -> R
3131
.route("/api/config", get(api::get_config))
3232
.route("/api/interfaces", get(api::list_interfaces))
3333
.route("/api/subnet", post(api::update_subnet))
34+
.route("/api/heartbeat", get(api::get_heartbeat))
35+
.route("/api/heartbeat", post(api::set_heartbeat))
3436
// WebSocket for real-time updates
3537
.route("/ws", get(ws::websocket_handler))
3638
// Serve models

crates/dendrite-discovery/src/scanner.rs

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ pub struct ScannerConfig {
2929
pub interval_secs: u64,
3030
/// Heartbeat interval in seconds (lightweight status check)
3131
pub heartbeat_interval_secs: u64,
32+
/// Whether heartbeat checking is enabled (sends ARP/ping to check connectivity)
33+
pub heartbeat_enabled: bool,
3234
/// Use LLDP for port detection
3335
pub use_lldp: bool,
3436
/// Use ARP scanning
@@ -63,6 +65,7 @@ impl Default for ScannerConfig {
6365
mcumgr_port: MCUMGR_PORT,
6466
interval_secs: 60, // Full scan every 60 seconds
6567
heartbeat_interval_secs: 2, // Lightweight ARP/ping check every 2 seconds
68+
heartbeat_enabled: false, // Disabled by default (no network traffic until user enables)
6669
use_lldp: true,
6770
use_arp: true,
6871
parent: None,
@@ -119,6 +122,18 @@ impl DiscoveryScanner {
119122
self.config.read().await.clone()
120123
}
121124

125+
/// Enable or disable heartbeat checking (ARP/ping connectivity checks)
126+
pub async fn set_heartbeat_enabled(&self, enabled: bool) {
127+
let mut config = self.config.write().await;
128+
config.heartbeat_enabled = enabled;
129+
info!(enabled = enabled, "Heartbeat checking {}", if enabled { "enabled" } else { "disabled" });
130+
}
131+
132+
/// Check if heartbeat is enabled
133+
pub async fn is_heartbeat_enabled(&self) -> bool {
134+
self.config.read().await.heartbeat_enabled
135+
}
136+
122137
/// Subscribe to discovery events
123138
pub fn subscribe(&self) -> broadcast::Receiver<DiscoveryEvent> {
124139
self.event_tx.subscribe()
@@ -338,24 +353,28 @@ impl DiscoveryScanner {
338353
pub async fn run(&self) -> Result<()> {
339354
use tokio::time::interval;
340355

341-
let config = self.config.read().await.clone();
342-
343356
// Do initial full scan on startup
344357
info!("Running initial MCUmgr discovery scan");
345358
if let Err(e) = self.scan_once().await {
346359
warn!(error = %e, "Initial discovery scan failed");
347360
}
348361

349-
// Only run heartbeat checks on a timer - full scans are manual
350-
let mut heartbeat_interval = interval(Duration::from_secs(config.heartbeat_interval_secs));
362+
// Use a fixed 2-second interval, but check config each time to see if heartbeat is enabled
363+
let mut heartbeat_interval = interval(Duration::from_secs(2));
351364

352-
info!(
353-
heartbeat_secs = config.heartbeat_interval_secs,
354-
"Heartbeat scheduler started (MCUmgr scans are manual only)"
355-
);
365+
info!("Heartbeat scheduler started (MCUmgr scans are manual only)");
356366

357367
loop {
358368
heartbeat_interval.tick().await;
369+
370+
// Check if heartbeat is enabled (config may have changed at runtime)
371+
let config = self.config.read().await;
372+
if !config.heartbeat_enabled {
373+
// Heartbeat is disabled, skip this iteration
374+
continue;
375+
}
376+
drop(config);
377+
359378
debug!("Running heartbeat check");
360379
if let Err(e) = self.heartbeat().await {
361380
warn!(error = %e, "Heartbeat check failed");

crates/dendrite-web/src/app.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
use bevy::prelude::*;
44
use bevy_egui::EguiPlugin;
5-
use bevy_picking::DefaultPickingPlugins;
5+
use bevy_picking::{DefaultPickingPlugins, prelude::MeshPickingPlugin};
66

77
use crate::models::ModelsPlugin;
88
use crate::network::NetworkPlugin;
@@ -27,6 +27,7 @@ pub struct DeviceData {
2727
pub version: Option<String>,
2828
pub position: Option<[f64; 3]>,
2929
pub model_path: Option<String>,
30+
pub last_seen: Option<String>,
3031
}
3132

3233
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -245,9 +246,11 @@ pub fn run() {
245246
})
246247
)
247248
// Add bevy_picking from the crate (required for bevy_egui picking feature)
248-
// DefaultPickingPlugins includes MeshPickingPlugin when bevy_mesh_picking_backend feature is enabled
249-
// This must be added BEFORE EguiPlugin so it can detect PickingPlugin
249+
// DefaultPickingPlugins provides core picking (PointerInputPlugin, PickingPlugin, InteractionPlugin)
250+
// MeshPickingPlugin must be added separately for 3D mesh raycasting
251+
// These must be added BEFORE EguiPlugin so it can detect PickingPlugin
250252
.add_plugins(DefaultPickingPlugins)
253+
.add_plugins(MeshPickingPlugin)
251254
.add_plugins(EguiPlugin::default())
252255
.init_resource::<DeviceRegistry>()
253256
.init_resource::<SelectedDevice>()

crates/dendrite-web/src/network.rs

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,24 @@ pub struct NetworkInterfaces {
108108
pub scan_in_progress: bool,
109109
}
110110

111+
/// Resource storing heartbeat (connection checking) state
112+
#[derive(Resource)]
113+
pub struct HeartbeatState {
114+
/// Whether connection checking is enabled
115+
pub enabled: bool,
116+
/// Whether we're waiting for initial state from server
117+
pub loading: bool,
118+
}
119+
120+
impl Default for HeartbeatState {
121+
fn default() -> Self {
122+
Self {
123+
enabled: false, // Default to off (no network traffic)
124+
loading: true, // Loading initial state
125+
}
126+
}
127+
}
128+
111129
/// Request to update subnet (used by trigger_scan_on_interface)
112130
#[derive(Serialize)]
113131
#[allow(dead_code)]
@@ -126,9 +144,11 @@ impl Plugin for NetworkPlugin {
126144
.init_resource::<PendingMessages>()
127145
.init_resource::<NetworkInterfaces>()
128146
.init_resource::<PendingInterfaceData>()
147+
.init_resource::<HeartbeatState>()
148+
.init_resource::<PendingHeartbeatData>()
129149
.add_message::<ReconnectEvent>()
130-
.add_systems(Startup, (connect_websocket, fetch_initial_devices, fetch_network_interfaces))
131-
.add_systems(Update, (process_messages, process_interface_data, handle_reconnect));
150+
.add_systems(Startup, (connect_websocket, fetch_initial_devices, fetch_network_interfaces, fetch_heartbeat_state))
151+
.add_systems(Update, (process_messages, process_interface_data, process_heartbeat_data, handle_reconnect));
132152
}
133153
}
134154

@@ -278,6 +298,10 @@ fn refetch_interfaces(daemon_config: &DaemonConfig, pending: &PendingInterfaceDa
278298
#[derive(Resource, Default)]
279299
pub struct PendingInterfaceData(pub Arc<Mutex<Option<Vec<NetworkInterfaceInfo>>>>);
280300

301+
/// Pending heartbeat data from async fetch
302+
#[derive(Resource, Default)]
303+
pub struct PendingHeartbeatData(pub Arc<Mutex<Option<bool>>>);
304+
281305
/// Shared message queue between WebSocket callback and Bevy
282306
#[derive(Resource, Default, Clone)]
283307
pub struct PendingMessages(pub Arc<Mutex<Vec<WsMessage>>>);
@@ -340,6 +364,7 @@ pub struct DiscoveryJson {
340364
#[allow(dead_code)]
341365
pub port: u16,
342366
pub switch_port: Option<u8>,
367+
pub last_seen: Option<String>,
343368
}
344369

345370
#[derive(Debug, Clone, Deserialize)]
@@ -368,6 +393,7 @@ impl From<DeviceJson> for DeviceData {
368393
version: json.firmware.version,
369394
position: json.pose.map(|p| [p[0], p[1], p[2]]),
370395
model_path: json.model_path,
396+
last_seen: json.discovery.last_seen,
371397
}
372398
}
373399
}
@@ -551,6 +577,86 @@ fn process_interface_data(
551577
}
552578
}
553579

580+
/// Fetch initial heartbeat state from backend
581+
fn fetch_heartbeat_state(pending: Res<PendingHeartbeatData>, daemon_config: Res<DaemonConfig>) {
582+
#[cfg(target_arch = "wasm32")]
583+
{
584+
use wasm_bindgen_futures::spawn_local;
585+
586+
let pending_clone = pending.0.clone();
587+
let base_url = daemon_config.http_url.clone();
588+
589+
spawn_local(async move {
590+
let url = format!("{}/api/heartbeat", base_url);
591+
592+
tracing::info!("Fetching heartbeat state from: {}", url);
593+
594+
match gloo_net::http::Request::get(&url).send().await {
595+
Ok(response) => {
596+
if let Ok(text) = response.text().await {
597+
tracing::debug!("Heartbeat response: {}", text);
598+
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
599+
if let Some(enabled) = json.get("heartbeat_enabled").and_then(|v| v.as_bool()) {
600+
if let Ok(mut data) = pending_clone.lock() {
601+
*data = Some(enabled);
602+
}
603+
}
604+
}
605+
}
606+
}
607+
Err(e) => {
608+
tracing::error!("Failed to fetch heartbeat state: {:?}", e);
609+
}
610+
}
611+
});
612+
}
613+
}
614+
615+
/// Process pending heartbeat data
616+
fn process_heartbeat_data(
617+
pending: Res<PendingHeartbeatData>,
618+
mut heartbeat_state: ResMut<HeartbeatState>,
619+
) {
620+
if let Ok(mut data) = pending.0.lock() {
621+
if let Some(enabled) = data.take() {
622+
heartbeat_state.enabled = enabled;
623+
heartbeat_state.loading = false;
624+
}
625+
}
626+
}
627+
628+
/// Toggle heartbeat checking (called from UI)
629+
pub fn toggle_heartbeat(enabled: bool, base_url: &str) {
630+
#[cfg(target_arch = "wasm32")]
631+
{
632+
use wasm_bindgen_futures::spawn_local;
633+
634+
let base_url = base_url.to_string();
635+
636+
spawn_local(async move {
637+
let url = format!("{}/api/heartbeat", base_url);
638+
let body = serde_json::json!({ "enabled": enabled });
639+
640+
tracing::info!("Setting heartbeat to: {}", enabled);
641+
642+
match gloo_net::http::Request::post(&url)
643+
.header("Content-Type", "application/json")
644+
.body(body.to_string())
645+
.unwrap()
646+
.send()
647+
.await
648+
{
649+
Ok(_) => {
650+
tracing::info!("Heartbeat set to: {}", enabled);
651+
}
652+
Err(e) => {
653+
tracing::error!("Failed to set heartbeat: {:?}", e);
654+
}
655+
}
656+
});
657+
}
658+
}
659+
554660
/// Trigger a scan on the selected interface (called from UI)
555661
pub fn trigger_scan_on_interface(subnet: &str, prefix_len: u8, base_url: &str) {
556662
#[cfg(target_arch = "wasm32")]

0 commit comments

Comments
 (0)