Skip to content

Commit 09781c2

Browse files
committed
Improve mobile UI and device management
Mobile UI improvements: - Move toolbar to bottom of screen to avoid curved edge cutoff - Add narrower right panel (180px) for device details - Start with left panel closed on mobile - Wrap device ID on its own line for readability - Shorten position labels (X:, Y:, Z:) on mobile - Auto-hide both panels when touching the canvas area - Add two-finger drag gesture for panning Device management improvements: - Add collision-aware auto-positioning for new devices - Create MCU entry in HCDF when updating device position - Auto-save HCDF after position changes - Add periodic device sync (5 second interval) for WebView reliability Signed-off-by: Benjamin Perseghetti <bperseghetti@rudislabs.com>
1 parent a229883 commit 09781c2

File tree

8 files changed

+737
-139
lines changed

8 files changed

+737
-139
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ dendrite.local.toml
3333

3434
# Fragment cache (downloaded HCDFs and models)
3535
fragments/cache/
36+
dendrite.hcdf

crates/dendrite-daemon/src/api.rs

Lines changed: 196 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -382,10 +382,36 @@ pub async fn update_device_position(
382382
}
383383

384384
if !found {
385-
tracing::warn!(device_id = %id, mcu_count = mcu_count, "MCU not found in HCDF, pose_cg will be saved on next upsert_device");
385+
// MCU doesn't exist in HCDF yet - create a minimal entry
386+
// This ensures position is persisted even before full device discovery completes
387+
use dendrite_core::hcdf::Mcu;
388+
let new_mcu = Mcu {
389+
name: updated_device.name.clone(),
390+
hwid: Some(id.clone()),
391+
description: None,
392+
pose_cg: Some(format!(
393+
"{} {} {} {} {} {}",
394+
pose[0], pose[1], pose[2], pose[3], pose[4], pose[5]
395+
)),
396+
mass: None,
397+
board: updated_device.info.board.clone(),
398+
software: None,
399+
discovered: None,
400+
model: None,
401+
visual: Vec::new(),
402+
frame: Vec::new(),
403+
network: None,
404+
};
405+
hcdf.mcu.push(new_mcu);
406+
tracing::warn!(device_id = %id, mcu_count = mcu_count, "Created new MCU in HCDF with position");
386407
}
387408
}
388409

410+
// Auto-save HCDF to persist position changes
411+
if let Err(e) = state.save_hcdf().await {
412+
tracing::warn!(error = %e, "Failed to auto-save HCDF after position update");
413+
}
414+
389415
// Broadcast device update via WebSocket
390416
state.scanner.broadcast_device_update(updated_device).await;
391417

@@ -846,7 +872,7 @@ pub async fn import_hcdf(
846872
Json(req): Json<HcdfImportRequest>,
847873
) -> impl IntoResponse {
848874
use dendrite_core::{Hcdf, Device, DeviceId, DeviceStatus, DeviceInfo, FirmwareInfo, parse_pose_string};
849-
use dendrite_core::device::{DiscoveryInfo, DiscoveryMethod};
875+
use dendrite_core::device::{DiscoveryInfo, DiscoveryMethod, DeviceVisual, DeviceFrame};
850876
use chrono::{DateTime, Utc};
851877
use std::net::IpAddr;
852878

@@ -862,22 +888,89 @@ pub async fn import_hcdf(
862888
}
863889
};
864890

865-
// Collect MCUs to convert to devices
891+
// Collect MCUs and Comps to convert to devices
866892
let mcus_to_import: Vec<_> = imported_hcdf.mcu.clone();
893+
let comps_to_import: Vec<_> = imported_hcdf.comp.clone();
867894
let mcu_count = mcus_to_import.len();
895+
let comp_count = comps_to_import.len();
868896

869-
// Update HCDF state
897+
// Update HCDF state - always merge to preserve existing devices
870898
{
871899
let mut hcdf = state.hcdf.write().await;
872-
if req.merge {
873-
// TODO: Implement proper merge logic
874-
// For now, just warn and replace
875-
info!("HCDF merge not yet implemented, replacing instead");
900+
901+
// Merge MCUs by hwid (update if exists, add if new)
902+
for mcu in &mcus_to_import {
903+
if let Some(hwid) = &mcu.hwid {
904+
if let Some(existing) = hcdf.mcu.iter_mut().find(|m| m.hwid.as_deref() == Some(hwid)) {
905+
// Update existing MCU
906+
*existing = mcu.clone();
907+
debug!("Updated existing MCU '{}' (hwid: {})", mcu.name, hwid);
908+
} else {
909+
// Add new MCU
910+
hcdf.mcu.push(mcu.clone());
911+
debug!("Added new MCU '{}' (hwid: {})", mcu.name, hwid);
912+
}
913+
} else {
914+
// MCU without hwid - add by name match or append
915+
if let Some(existing) = hcdf.mcu.iter_mut().find(|m| m.name == mcu.name && m.hwid.is_none()) {
916+
*existing = mcu.clone();
917+
debug!("Updated existing MCU '{}' (no hwid)", mcu.name);
918+
} else {
919+
hcdf.mcu.push(mcu.clone());
920+
debug!("Added new MCU '{}' (no hwid)", mcu.name);
921+
}
922+
}
923+
}
924+
925+
// Merge Comps by hwid or name
926+
for comp in &comps_to_import {
927+
let comp_key = comp.hwid.as_ref()
928+
.map(|h| format!("hwid:{}", h))
929+
.unwrap_or_else(|| format!("name:{}", comp.name));
930+
931+
let existing = if let Some(hwid) = &comp.hwid {
932+
hcdf.comp.iter_mut().find(|c| c.hwid.as_deref() == Some(hwid))
933+
} else {
934+
hcdf.comp.iter_mut().find(|c| c.name == comp.name && c.hwid.is_none())
935+
};
936+
937+
if let Some(existing) = existing {
938+
*existing = comp.clone();
939+
debug!("Updated existing comp '{}'", comp_key);
940+
} else {
941+
hcdf.comp.push(comp.clone());
942+
debug!("Added new comp '{}'", comp_key);
943+
}
944+
}
945+
946+
// Merge links, sensors, motors, power from imported HCDF
947+
for link in &imported_hcdf.link {
948+
if !hcdf.link.iter().any(|l| l.name == link.name) {
949+
hcdf.link.push(link.clone());
950+
}
951+
}
952+
for sensor in &imported_hcdf.sensor {
953+
if !hcdf.sensor.iter().any(|s| s.name == sensor.name) {
954+
hcdf.sensor.push(sensor.clone());
955+
}
876956
}
877-
*hcdf = imported_hcdf;
878-
info!("Replaced HCDF with imported data ({} MCUs)", mcu_count);
957+
for motor in &imported_hcdf.motor {
958+
if !hcdf.motor.iter().any(|m| m.name == motor.name) {
959+
hcdf.motor.push(motor.clone());
960+
}
961+
}
962+
for power in &imported_hcdf.power {
963+
if !hcdf.power.iter().any(|p| p.name == power.name) {
964+
hcdf.power.push(power.clone());
965+
}
966+
}
967+
968+
info!("Merged HCDF data ({} MCUs, {} Comps imported, now {} MCUs, {} Comps total)",
969+
mcu_count, comp_count, hcdf.mcu.len(), hcdf.comp.len());
879970
}
880971

972+
let mut devices_imported = 0;
973+
881974
// Convert MCUs to Devices and add to scanner (which broadcasts events)
882975
for mcu in mcus_to_import {
883976
// Need hwid to create device ID
@@ -970,13 +1063,104 @@ pub async fn import_hcdf(
9701063

9711064
// Add to scanner (this broadcasts DeviceDiscovered event to WebSocket clients)
9721065
state.scanner.add_device(device).await;
973-
info!("Imported device '{}' from HCDF", mcu.name);
1066+
info!("Imported device '{}' from HCDF MCU", mcu.name);
1067+
devices_imported += 1;
1068+
}
1069+
1070+
// Convert Comps with visuals to "scene objects" (devices with placeholder network info)
1071+
for comp in comps_to_import {
1072+
// Skip comps without visuals - nothing to render
1073+
if comp.visual.is_empty() {
1074+
debug!("Skipping comp '{}' - no visuals", comp.name);
1075+
continue;
1076+
}
1077+
1078+
// Create a synthetic device ID from comp name (or hwid if present)
1079+
let device_id = comp.hwid.as_ref()
1080+
.map(|h| DeviceId::from_hwid(h))
1081+
.unwrap_or_else(|| DeviceId::from_hwid(&format!("comp-{}", comp.name)));
1082+
1083+
let now = Utc::now();
1084+
1085+
// Convert comp visuals to device visuals
1086+
// Model paths in HCDF are relative to hcdf.cognipilot.org root
1087+
let visuals: Vec<DeviceVisual> = comp.visual.iter().map(|v| {
1088+
let model_path = v.model.as_ref().map(|m| {
1089+
// If the model href is relative, prepend the HCDF CDN base URL
1090+
if m.href.starts_with("http://") || m.href.starts_with("https://") {
1091+
m.href.clone()
1092+
} else {
1093+
format!("https://hcdf.cognipilot.org/{}", m.href.trim_start_matches("./"))
1094+
}
1095+
});
1096+
DeviceVisual {
1097+
name: v.name.clone(),
1098+
toggle: v.toggle.clone(),
1099+
pose: v.pose.as_ref().and_then(|p| parse_pose_string(p)).map(|p| [p.x, p.y, p.z, p.roll, p.pitch, p.yaw]),
1100+
model_path,
1101+
model_sha: v.model.as_ref().and_then(|m| m.sha.clone()),
1102+
}
1103+
}).collect();
1104+
1105+
// Convert comp frames to device frames
1106+
let frames: Vec<DeviceFrame> = comp.frame.iter().map(|f| {
1107+
DeviceFrame {
1108+
name: f.name.clone(),
1109+
description: f.description.clone(),
1110+
pose: f.pose.as_ref().and_then(|p| parse_pose_string(p)).map(|p| [p.x, p.y, p.z, p.roll, p.pitch, p.yaw]),
1111+
}
1112+
}).collect();
1113+
1114+
// Parse pose from pose_cg string
1115+
let pose: Option<[f64; 6]> = comp.pose_cg.as_ref().and_then(|s| {
1116+
parse_pose_string(s).map(|p| [p.x, p.y, p.z, p.roll, p.pitch, p.yaw])
1117+
});
1118+
1119+
// Create device (scene object) with placeholder network info
1120+
let device = Device {
1121+
id: device_id,
1122+
name: comp.name.clone(),
1123+
status: DeviceStatus::Offline, // Static scene object - use Offline so it can be deleted
1124+
discovery: DiscoveryInfo {
1125+
ip: "127.0.0.1".parse().unwrap(), // Placeholder - not a real device
1126+
port: 0,
1127+
switch_port: None,
1128+
mac: None,
1129+
first_seen: now,
1130+
last_seen: now,
1131+
discovery_method: DiscoveryMethod::Manual,
1132+
},
1133+
info: DeviceInfo {
1134+
os_name: None,
1135+
board: comp.board.clone(),
1136+
processor: None,
1137+
bootloader: None,
1138+
mcuboot_mode: None,
1139+
},
1140+
firmware: FirmwareInfo::default(),
1141+
firmware_status: Default::default(),
1142+
firmware_manifest_uri: None,
1143+
parent_id: None,
1144+
model_path: comp.model.as_ref().map(|m| m.href.clone()),
1145+
pose,
1146+
visuals,
1147+
frames,
1148+
ports: Vec::new(), // TODO: Convert comp.port if needed
1149+
sensors: Vec::new(), // TODO: Convert comp.sensor if needed
1150+
};
1151+
1152+
// Add to scanner (this broadcasts DeviceDiscovered event to WebSocket clients)
1153+
state.scanner.add_device(device).await;
1154+
info!("Imported scene object '{}' from HCDF comp ({} visuals)", comp.name, comp.visual.len());
1155+
devices_imported += 1;
9741156
}
9751157

9761158
Json(serde_json::json!({
9771159
"status": "imported",
9781160
"merge": req.merge,
979-
"devices_imported": mcu_count
1161+
"mcu_count": mcu_count,
1162+
"comp_count": comp_count,
1163+
"devices_imported": devices_imported
9801164
}))
9811165
.into_response()
9821166
}

crates/dendrite-daemon/src/state.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,10 +226,15 @@ impl AppState {
226226
}
227227
}
228228

229-
// Update HCDF
230-
{
229+
// Update HCDF - but skip for comp-derived scene objects (they're already in hcdf.comp)
230+
// Comp-derived devices have IDs like "comp-rtk-gnss-assembly" or "hwid:comp-..."
231+
let is_comp_derived = device.id.as_str().starts_with("comp-")
232+
|| device.id.as_str().starts_with("hwid:comp-");
233+
if !is_comp_derived {
231234
let mut hcdf = self.hcdf.write().await;
232235
hcdf.upsert_device(&device, parent_name);
236+
} else {
237+
debug!(device = %device.id, "Skipping HCDF upsert for comp-derived device");
233238
}
234239

235240
// Rebuild topology

crates/dendrite-web/src/app.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,15 +261,21 @@ pub struct FrameVisibility {
261261
pub device_frames: std::collections::HashMap<String, bool>,
262262
/// Currently hovered frame (device_id:frame_name)
263263
pub hovered_frame: Option<String>,
264+
/// Whether the frame hover came from a click/tap (sticky until next click)
265+
pub hovered_frame_from_click: bool,
264266
/// Per-device, per-toggle-group hidden state: (device_id, toggle_group) -> is_hidden
265267
/// Default is visible (not hidden), so only hidden groups are tracked
266268
pub hidden_toggles: std::collections::HashMap<(String, String), bool>,
267269
/// Per-device sensor (FOV) visibility (device_id -> show_sensors)
268270
pub device_sensors: std::collections::HashMap<String, bool>,
269271
/// Currently hovered sensor axis frame (device_id:sensor_name)
270272
pub hovered_sensor_axis: Option<String>,
273+
/// Whether the sensor axis hover came from a click/tap (sticky until next click)
274+
pub hovered_sensor_axis_from_click: bool,
271275
/// Currently hovered sensor FOV (device_id:sensor_name)
272276
pub hovered_sensor_fov: Option<String>,
277+
/// Whether the sensor FOV hover came from a click/tap (sticky until next click)
278+
pub hovered_sensor_fov_from_click: bool,
273279
/// Currently hovered sensor from UI panel (device_id:sensor_name)
274280
/// When set, other sensors reduce to 30% alpha
275281
pub hovered_sensor_from_ui: Option<String>,
@@ -512,21 +518,38 @@ impl UiLayout {
512518
self.screen_height = height;
513519

514520
// Consider mobile if width < 800 or if it's a portrait orientation with width < 600
521+
let was_mobile = self.is_mobile;
515522
self.is_mobile = width < 800.0 || (width < height && width < 600.0);
516523

517-
// Scale up UI elements on mobile for better touch targets
518-
self.ui_scale = if self.is_mobile { 1.3 } else { 1.0 };
524+
// On first detection of mobile mode, close the left panel
525+
if self.is_mobile && !was_mobile {
526+
self.show_left_panel = false;
527+
}
528+
529+
// Keep scale at 1.0 for mobile - smaller, more compact UI
530+
self.ui_scale = 1.0;
519531
}
520532

521-
/// Get the width for side panels
533+
/// Get the width for the left panel (device list)
522534
pub fn panel_width(&self) -> f32 {
523535
if self.is_mobile {
524-
// On mobile, panels take more of the screen when shown
525-
(self.screen_width * 0.85).min(350.0)
536+
// On mobile, panel is ~45% of screen width for compact display
537+
(self.screen_width * 0.45).min(200.0)
526538
} else {
527539
250.0
528540
}
529541
}
542+
543+
/// Get the width for the right panel (device details) - narrower on mobile
544+
pub fn right_panel_width(&self) -> f32 {
545+
if self.is_mobile {
546+
// On mobile, narrower panel - wide enough for labels + input boxes with suffix
547+
// Label (~40px) + input box with " m" suffix (~100px) + padding (~40px) = ~180px
548+
180.0
549+
} else {
550+
300.0
551+
}
552+
}
530553
}
531554

532555
/// Connection dialog state for remote daemon configuration

0 commit comments

Comments
 (0)