@@ -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}
0 commit comments