← Commit history

DisplayLink warning on readiness, tour and import; the EAGLE import stops on a crash dialog (issue #121)

Colby Knox ·4629b5b355 ·16d ago ·parent b2cfe0f
4 files changed +120−5
CHANGELOG.md+9
@@ -64,6 +64,15 @@ The board model, to the [EDA Bridge Standard](https://wiki.adom.inc/adom/eda-bri   export, in the background, and switches to the board itself when the schematic or 3D view is in front. The same   names now feed `fusion_board_bom` (which read only `LCSC_PN`) and `fusion_component_data`. Live on BQ25792: 109   parts, 45 lines, LCSC on 63 parts, MPN on 28.+- **DisplayLink warning** (page issue #121): when Fusion has detected an indirect display adapter+  (`IndirectDisplayAdapterDetected=1` in its `NMachineSpecificOptions.xml`), Fusion Electronics can die in its+  D3D12 canvas on any monitor. `fusion_readiness` now carries `displayWarning`, and the guided tour and+  `fusion_new_electronics_from_eagle` say so (reply and hint) before they start.+- **The EAGLE import stops on a crash dialog** (page issue #121): `fusion_new_electronics_from_eagle` checks for+  Fusion's D3D12 device-loss and "unexpected condition" dialogs while it waits for each Open dialog, before it+  types a path, while the path is submitted and while the design comes up. It stops at once, cancels its Open+  dialog and answers `fusion_graphics_error` (or `blocked_by_dialog`) with the dialog, instead of sitting at+  `stage: started` for minutes. - Layer arguments take the standard's names: `F.Cu` / `B.Cu` as well as 1 / 16 (`fusion_route_net`, pours). - Net names with `!` (active-low, `!CE`) are accepted by the routing verbs; they were refused as invalid. - Tests: `tests/test_board_model.py` (16 cases over every demo board, against the standard's schemas, copied
rust/bridge/src/display.rsadded+78
@@ -0,0 +1,78 @@+//! Fusion's own graphics facts, read from its machine options file: when Fusion has detected an+//! indirect display adapter (DisplayLink and other USB displays) it switches its graphics driver+//! for the whole process, and Fusion Electronics then dies in its D3D12 canvas+//! (SkiaWindowContextDx12, "unrecoverable d3d12") whatever monitor Fusion is on (page issue #121:+//! three crashes with the adapters plugged in, none without). The bridge cannot fix that; it can+//! say so before a tour or an import starts instead of after the crash.+use serde_json::{json, Value};++/// `%APPDATA%/Autodesk/Neutron Platform/Options/NMachineSpecificOptions.xml` (Fusion writes it UTF-16).+fn options_path() -> Option<std::path::PathBuf> {+    std::env::var_os("APPDATA").map(|a| std::path::Path::new(&a).join("Autodesk").join("Neutron Platform").join("Options").join("NMachineSpecificOptions.xml"))+}++fn text_of(bytes: &[u8]) -> String {+    if bytes.starts_with(&[0xFF, 0xFE]) {+        let units: Vec<u16> = bytes[2..].chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();+        String::from_utf16_lossy(&units)+    } else {+        String::from_utf8_lossy(bytes).into_owned()+    }+}++fn value_of(text: &str, element: &str) -> Option<String> {+    let re = regex::Regex::new(&format!(r#"<{element}\b[^>]*\bValue="([^"]*)""#)).ok()?;+    re.captures(text).map(|c| c[1].to_string())+}++/// The warning for an options file's bytes, or None when Fusion has not detected an indirect adapter.+pub fn parse(bytes: &[u8]) -> Option<Value> {+    let text = text_of(bytes);+    if value_of(&text, "IndirectDisplayAdapterDetected").as_deref() != Some("1") { return None; }+    let driver = value_of(&text, "driverOptionId");+    Some(json!({+        "indirectDisplayAdapter": true,+        "graphicsDriver": driver,+        "risk": "Fusion Electronics may crash with an unrecoverable D3D12 error (SkiaWindowContextDx12), on any monitor",+        "fix": "Unplug the DisplayLink (USB) display adapters and restart Fusion; Fusion re-detects at launch.",+        "source": "Fusion's NMachineSpecificOptions.xml: IndirectDisplayAdapterDetected=1",+    }))+}++/// The warning for this machine, read fresh each call (the file is small and Fusion rewrites it at launch).+pub fn warning() -> Option<Value> {+    options_path().and_then(|p| std::fs::read(p).ok()).and_then(|b| parse(&b))+}++/// Add the warning to a reply (and to its hint) when there is one.+pub fn annotate(mut reply: Value) -> Value {+    if let (Some(w), true) = (warning(), reply.is_object()) {+        let line = "DisplayLink detected: Fusion Electronics may crash with an unrecoverable D3D12 error. Unplug the DisplayLink adapters and restart Fusion before a tour or an Electronics import.";+        reply["_hint"] = json!(format!("{line} {}", reply["_hint"].as_str().unwrap_or("")).trim().to_string());+        reply["displayWarning"] = w;+    }+    reply+}++#[cfg(test)]+mod tests {+    use super::*;+    fn utf16(s: &str) -> Vec<u8> { let mut b = vec![0xFF, 0xFE]; for u in s.encode_utf16() { b.extend_from_slice(&u.to_le_bytes()); } b }++    #[test]+    fn displaylink_box_is_flagged() {+        // the lines from the box in page issue #121+        let xml = r#"<?xml version="1.0" encoding="UTF-16"?><OptionGroups><GraphicsOptionGroup>+<driverOptionId UserName="Graphics driver" Value="VirtualDeviceDx11"/>+<IndirectDisplayAdapterDetected UserName="IndirectDisplayAdapterDetected" Value="1"/></GraphicsOptionGroup></OptionGroups>"#;+        let w = parse(&utf16(xml)).expect("flagged");+        assert_eq!(w["graphicsDriver"], "VirtualDeviceDx11");+        assert!(parse(xml.as_bytes()).is_some(), "UTF-8 too");+    }++    #[test]+    fn ordinary_box_is_not() {+        assert!(parse(&utf16(r#"<OptionGroups><DisplayOptionGroup UserName="Display"></DisplayOptionGroup></OptionGroups>"#)).is_none());+        assert!(parse(&utf16(r#"<IndirectDisplayAdapterDetected UserName="x" Value="0"/>"#)).is_none());+    }+}
rust/bridge/src/main.rs+4−1
@@ -42,6 +42,7 @@ mod electronics; mod routing; mod sheet; mod bodies;+mod display; mod fusionpath; mod tour; mod palette;@@ -470,7 +471,7 @@ fn main() {                             "signin_2fa" => signin::signin_2fa(&ctx, &parsed["args"]), "aps_signin" => aps_signin::signin(&ctx, &parsed["args"]),                             "electronics_query" => electronics::query(&ctx, &parsed["args"], VERSION, &pass), "view" => tour::view(&pass, &parsed["args"]), "narrate" => tour::narrate(&parsed["args"]), "interactive" => tour::interactive(&pass, parsed["args"]["probeMainThread"].as_bool().unwrap_or(true)), "demo_progress" => if tour::owns(parsed["args"]["demoId"].as_str().unwrap_or("")) || parsed["args"]["demoId"].as_str().map(|s| s.is_empty()).unwrap_or(true) { tour::progress(&parsed["args"]) } else { demo::demo_progress(&ctx.state_dir, &parsed["args"]) },                             "demo_control" => if tour::owns(parsed["args"]["demoId"].as_str().unwrap_or("")) || parsed["args"]["demoId"].as_str().map(|s| s.is_empty()).unwrap_or(true) { tour::control(&parsed["args"]) } else { demo::demo_control(&parsed["args"]) },-                            "demo" if parsed["args"]["mode"].as_str() == Some("tour") && parsed["args"]["legacy"].as_bool() != Some(true) => tour::start(&ctx, &parsed["args"], tour::Env { version: VERSION }),+                            "demo" if parsed["args"]["mode"].as_str() == Some("tour") && parsed["args"]["legacy"].as_bool() != Some(true) => display::annotate(tour::start(&ctx, &parsed["args"], tour::Env { version: VERSION })),                             "demo" => if parsed["args"]["mode"].as_str() == Some("tour") { let (sc1, sc2) = (sidecar.clone(), sidecar.clone()); let readiness: Arc<dyn Fn() -> Value + Send + Sync> = Arc::new(move || owned_readiness(&*host(), &sc1, &Caller::default())); let aps_state: Arc<dyn Fn() -> (bool, bool) + Send + Sync> = Arc::new(move || { let st = native_aps_status(&sc2, &Caller::default()); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }); demo::demo_tour(&ctx, &parsed["args"], demo::TourEnv { readiness, aps_state, version: VERSION }, &pass) }                                 else { let readiness = || owned_readiness(&**h, &sidecar, &caller); let aps_state = || native_aps_status(&sidecar, &caller)["data"].clone(); let aps_search = |a: &Value| native_aps_search(a); let start = || launch::start(&ctx, &json!({})); let aps_pair = || { let st = native_aps_status(&sidecar, &caller); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }; let aps_signin = |p: &str| aps_signin::signin(&ctx, &json!({"profile": p})); let signin_fn = |a: &Value| signin::signin(&ctx, a, &signin::Env { readiness: &readiness, start: &start, aps_state: &aps_pair, aps_signin: &aps_signin }); demo::demo(&ctx, &parsed["args"], &demo::DemoEnv { readiness: &readiness, signin: &signin_fn, aps_state: &aps_state, aps_search: &aps_search }, &pass) }, "web_signin" => signin::web_signin(&ctx, &parsed["args"]),                             "library_manager" => managed::library_manager(&ctx, &parsed["args"], &pass), "managed_library_create" => managed::create(&ctx, &parsed["args"], &pass), "managed_library_link" => managed::link(&ctx, &parsed["args"], &pass), "managed_library_pull" => managed::pull(&ctx, &parsed["args"], &pass), "managed_library_push" => managed::push(&ctx, &parsed["args"], &pass), "managed_library_unlink" => managed::unlink(&ctx, &parsed["args"], &pass),@@ -1372,6 +1373,8 @@ fn owned_readiness(h: &dyn HostBoth, sidecar: &Arc<Mutex<Option<Sidecar>>>, call     let mut r = out.result;     if seat_resolved { r["seatDialogAutoResolved"] = json!(true); }     r["readinessOwner"] = json!("exe");+    // a DisplayLink box crashes Fusion Electronics: say so on every readiness poll (page issue #121)+    if let Some(w) = display::warning() { r["displayWarning"] = w; }     r } 
rust/bridge/src/orchestrations.rs+29−4
@@ -1232,9 +1232,9 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V             v["stage"] = json!(if r["success"].as_bool() == Some(true) { "done" } else { "failed" }); v["finishedAt"] = json!(now_f()); v["result"] = r;             eagle_run_write(&sd, &rid, &v);         });-        return json!({"success": true, "started": true, "runId": run_id, "statusVerb": "fusion_eagle_import_status", "partsExpected": expected,+        return crate::display::annotate(json!({"success": true, "started": true, "runId": run_id, "statusVerb": "fusion_eagle_import_status", "partsExpected": expected,             "dialogsDriven": "Fusion's own Open dialogs (sch, then brd), driven in the background by accessible name; Autodesk offers no API for a sch+brd import",-            "_hint": format!("Import running in the background (it outlives the relay window). Poll fusion_eagle_import_status {{runId:\"{run_id}\"}} until stage is done or failed; fusion_get_app_state keeps answering meanwhile. The record reports documentName, partsExpected ({expected} elements in the .brd) and partsPlaced from the live board; success needs parts on the board.")});+            "_hint": format!("Import running in the background (it outlives the relay window). Poll fusion_eagle_import_status {{runId:\"{run_id}\"}} until stage is done or failed; fusion_get_app_state keeps answering meanwhile. The record reports documentName, partsExpected ({expected} elements in the .brd) and partsPlaced from the live board; success needs parts on the board.")}));     }     let sch = args["schPath"].as_str().or(args["filePath"].as_str()).unwrap_or("").replace('\\', "/");     if sch.is_empty() || !sch.to_lowercase().ends_with(".sch") { return json!({"success": false, "error": "schPath (a .sch file on the Windows host) is required.", "_hint": "Author the EAGLE .sch + paired .brd (same basename, same folder), stage both to Windows (send_files), then call with {schPath, brdPath}."}); }@@ -1253,6 +1253,25 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V             "_hint": format!("Don't drive the Open dialogs. If the parts are placed in the .brd (elements + embedded library), call `fusion_open_board {{filePath:\"{brd}\"}}` - it instantiates them via newDesignFromLocal with NO dialogs and NO foreground (the background way). For 3D bodies, embed <packages3d> + per-element package3d_urn in the .brd (see this verb's docstring), then fusion_show_3d_board. Only if you truly have a bare .sch that Fusion must import: fire 'Commands.Start ImportSCHAndBRDCmd' via fusion_execute_text_command and drive the two Open dialogs (foreground; last resort).")});     };     use fusion_core::ab::AbClient;+    // Fusion's own crash dialogs (the D3D12 device loss on DisplayLink boxes, the router's+    // "unexpected condition"): an import interrupted by one never finishes, so each wait below+    // checks for them and stops at once, cancelling its Open dialog, instead of running its clock out+    // (page issue #121: the import sat 2.5 min at "started" behind a d3d12 dialog)+    let crash_dialog = || -> Option<Value> {+        crate::lifecycle::dialog_inventory(ctx).into_iter().find(|d| {+            let cat = d["category"].as_str().unwrap_or("");+            let words = format!("{} {}", d["title"].as_str().unwrap_or(""), d["text"].as_str().unwrap_or("")).to_lowercase();+            cat == "fusion_gpu_device_lost" || cat == "fusion_unexpected_condition" || words.contains("d3d12") || words.contains("unexpected condition")+        })+    };+    let crashed = |dlg: Value, open_dialog: Option<i64>, stage: &str, picked: &[String]| -> Value {+        if let Some(h) = open_dialog { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 10); }+        crate::fusion_prefs::action_ledger(&ctx.state_dir, "eagle_import_crash_dialog", json!({"stage": stage, "dialog": dlg}));+        crate::display::annotate(json!({"success": false, "errorCode": if dlg["category"] == "fusion_gpu_device_lost" { "fusion_graphics_error" } else { "blocked_by_dialog" },+            "stage": stage, "imported": picked, "dialogs": [dlg.clone()], "openDialogCancelled": open_dialog.is_some(),+            "error": format!("Fusion raised '{}' during the import ({stage}); the import cannot finish, so the bridge stopped and cancelled its Open dialog.", dlg["title"].as_str().unwrap_or("a crash dialog")),+            "_hint": format!("{} Save the open documents, restart Fusion (fusion_restart_for_addin {{save:true}}), then run the import again.", dlg["resolution"].as_str().unwrap_or(""))}))+    };     let find_open_dialog = || -> Option<i64> { ab.call("desktop_list_windows", json!({}), &ctx.caller, 10).ok().and_then(|v| v["data"]["windows"].as_array().or_else(|| v["windows"].as_array()).cloned()).unwrap_or_default().iter().find(|w| w["title"].as_str().unwrap_or("").trim().to_lowercase() == "open").and_then(|w| w["hwnd"].as_i64()) };     let (sch_base, brd_base) = (sch.rsplit('/').next().unwrap_or("").to_string(), brd.rsplit('/').next().unwrap_or("").to_string());     let mut stale_cancelled = false;@@ -1278,7 +1297,10 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V         // the queued import runs once Fusion's main thread is free (a new design still settling can         // hold it for half a minute), so wait up to 90 s for the first dialog, 30 s for the second         // never the dialog just answered: it can linger a moment while the board's dialog replaces it-        for _ in 0..(if want == "sch" { 60 } else { 20 }) { std::thread::sleep(Duration::from_millis(1500)); dlg = find_open_dialog().filter(|h| Some(*h) != answered); if dlg.is_some() { break; } }+        for i in 0..(if want == "sch" { 60 } else { 20 }) {+            std::thread::sleep(Duration::from_millis(1500)); dlg = find_open_dialog().filter(|h| Some(*h) != answered); if dlg.is_some() { break; }+            if i % 4 == 3 { if let Some(c) = crash_dialog() { return crashed(c, None, &format!("waiting for the {want} Open dialog"), &picked); } }+        }         if dlg.is_some() { restore_minimized(&|t: &str| t.trim().eq_ignore_ascii_case("open")); std::thread::sleep(Duration::from_millis(800)); }         let Some(h) = dlg else {             if want == "brd" && !picked.is_empty() { break; }@@ -1286,6 +1308,7 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V         };         // the FULL path into "File name:": the dialog opens on whatever folder Fusion used last, so         // clicking the bare name only worked when that happened to be the file's folder (2026-10-05)+        if let Some(c) = crash_dialog() { return crashed(c, Some(h), &format!("before typing the {want} path"), &picked); }         let full = (if want == "sch" { &sch } else { &brd }).replace('/', "\\");         // role Edit: the bare name also matches the combo box, which has no ValuePattern. A value         // set by UIA is not committed by the Open button, so submit (Enter in the field; ab puts@@ -1296,6 +1319,7 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V         // click missed and the dialog stays. Never leave it on the user's screen.         let mut gone = false;         for _ in 0..8 { std::thread::sleep(Duration::from_secs(1)); if find_open_dialog().map(|x| x != h).unwrap_or(true) { gone = true; break; } }+        if !gone { if let Some(c) = crash_dialog() { return crashed(c, Some(h), &format!("submitting the {want} path"), &picked); } }         if !gone {             let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 10);             crate::fusion_prefs::action_ledger(&ctx.state_dir, "eagle_import_dialog_cancelled", json!({"wanted": base, "kind": want}));@@ -1308,8 +1332,9 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V     }     let mut ready = false;     let mut doc_name = String::new();-    for _ in 0..40 {+    for i in 0..40 {         std::thread::sleep(Duration::from_secs(3));+        if i % 3 == 2 { if let Some(c) = crash_dialog() { return crashed(c, None, "waiting for the imported design", &picked); } }         let st = pass("get_app_state", json!({}));         let d = &st["data"];         if d["isElectronics"].as_bool() == Some(true) && ["pcb editor", "schematic editor", "board layout", "3d pcb"].contains(&d["activeWorkspace"].as_str().unwrap_or("").to_lowercase().as_str()) { ready = true; doc_name = d["activeDocument"].as_str().unwrap_or("").to_string(); break; }