← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.10.33\ No newline at end of file+1.10.34\ No newline at end of file
README.md+1−1
@@ -559,7 +559,7 @@ _160 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_preflight_lbr` - PRE-ANALYZE a .lbr: will Fusion open it? - `fusion_prove_result` - POLL a background run started by fusion_create_library or the detached fusion_build_library_3d. - `fusion_show_3d_package` - Open the 3D CHIP view (Package3DEnvironment) for a library package - and the RELIABLE way to get a 3D model into a library: pass stepPath and it is attached...-- `fusion_show_attributes` - Show a component's METADATA: returns the attributes of a deviceset (MPN, MANUFACTURER, MOUSER_PN, DIGIKEY_PN, LCSC_PN, DATASHEET, ADOM_WIKI, per variant) read...+- `fusion_show_attributes` - Show a component's METADATA, as data and on screen. - `fusion_show_footprint` - Open a FOOTPRINT (package) in Fusion's library editor. - `fusion_show_library` - Eight-surfaces contract: the LIBRARY surface. - `fusion_show_symbol` - Open a schematic SYMBOL in Fusion's library editor - first-class, no hand-built EDIT strings.
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.10.33",+  "version": "1.10.34",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",
contracts/describe.json+4−3
@@ -2780,18 +2780,19 @@       "input": {         "capture": "optional bool (default true)",         "deviceset": "required str",+        "dialog": "optional bool (default true): open, read, capture and close Fusion's Variant Attributes table",         "libraryPath": "optional str (.lbr on the Fusion machine; opened first and read for the attributes; omit when the library is already the active document)"       },       "longRunning": false,       "name": "fusion_show_attributes",       "output": {         "attributes": "obj (the component's metadata)",-        "screenshot": "str|null",+        "screenshot": "str|null (device editor)",         "success": "bool",-        "variantAttributesDialog": "obj (recipe)"+        "variantAttributesDialog": "{opened, closed, title, text, screenshot}"       },       "statusVerb": "fusion_get_app_state",-      "summary": "Show a component's METADATA: returns the attributes of a deviceset (MPN, MANUFACTURER, MOUSER_PN, DIGIKEY_PN, LCSC_PN, DATASHEET, ADOM_WIKI, per variant) read from the library file with their full values, and opens the DEVICE editor for it (EDIT <name>.dev, no dialog: symbol, the Packages table with the Mapped check, footprint and 3D previews in one frame) with a capture. Fusion's own Variant Attributes table sits behind a button in an opaque Qt panel; the reply carries the positioned-click recipe for it and notes that a main-window recording does not include that owned dialog.",+      "summary": "Show a component's METADATA, as data and on screen. Returns the attributes of a deviceset (MPN, MANUFACTURER, MOUSER_PN, DIGIKEY_PN, LCSC_PN, DATASHEET, ADOM_WIKI, per variant) read from the library file with full values, in BOTH dialects: EAGLE <technology><attribute> and Fusion's layered <att_values>/<att_set> (1.10.34; Fusion-authored libraries used to read as empty). Opens the DEVICE editor (EDIT <name>.dev: symbol, Packages table with the Mapped check, footprint and 3D previews) with a capture, then opens Fusion's own Variant Attributes table with EAGLE's ATTRIBUTE command (no positioned clicks), reads it through UI Automation, captures the dialog window and Cancels it. Nothing is left on screen. dialog:false skips the table.",       "timeoutSeconds": 120     },     {
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.10.33"+version = "1.10.34" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.10.33"+version = "1.10.34" edition = "2021"  [dependencies]
describe.py+3−3
@@ -251,9 +251,9 @@ _T = [     ("fusion_show_3d_package", "Open the 3D CHIP view (Package3DEnvironment) for a library package - and the RELIABLE way to get a 3D model into a library: pass stepPath and it is attached AFTER the library opens (embedded <packages3d> in hand-built .lbr files is the Fusion quirk that fails; attach-after-open works). stepPath with no library/packageXml auto-generates a minimal 2-pad carrier footprint. Without stepPath it just enters the 3D view of the named package.", {"capture": "optional bool - ONE call = ONE consent: the bridge settles the surface (waits for the add-in main thread + a paint beat) and returns proof inline as shot:{title,image,thumbnailDataUrl,maxWidth} (base64 frame + a ready-to-use data: URL for BROWSER callers who cannot pull a file; set captureMaxWidth to shrink it - 320 keeps a /command body small, default 640. The bridge encodes PNG; the relay may transcode to WebP, so sniff rather than assume). Use this instead of polling fusion_state after a show - page-side settle polling costs one human consent per poll (issue #30).", "name": "str", "libraryPath": "optional str (.lbr)", "packageXml": "optional str (bare <package> fragment)", "stepPath": "optional str (WINDOWS path .step to attach)", "foreground": "optional bool - DEFAULT IS BACKGROUND (no focus steal; response carries windowEtiquette:background). true REQUIRES foregroundReason and shows it to the user as a 3s ab caption before any focus change - only when the USER asked to watch", "foregroundReason": "required WITH foreground:true - a short user-facing sentence shown as the 3s caption (e.g. 'You asked to watch the 3D board build')"}, {"success": "bool", "view": "str", "screenshot": "str path", "wrappedLibrary": "str"}, 300, None, True, {"name": "MYCHIP", "stepPath": "C:/Temp/chip.step"}),     ("fusion_demo_progress", "Poll a running fusion_demo tour: {percent, elapsedSec, estimatedSec, step, stepLabel, done, failed, shots, shotCount, stepShot}. Poll ~1-2.5s and render a REAL progress bar. LIVE EVIDENCE (issue #30): each frame MAY carry stepShot {step, title, image} - a ~640px base64 capture the BRIDGE took at that step's completion, one per step, riding the first frame after it completes (render these during the run; the page should not guess-snap). shots[] streams too: mid-run it carries paths for every completed step; pass imagesAs:base64 + sinceShot:<count you have> to receive only NEW shots fully encoded. done:true = the full gallery (base64 by default). Finished tours are DISK-SNAPSHOTTED, so the final frame stays pollable even across a bridge respawn.", {"demoId": "required str (from fusion_demo mode:tour; omitted = the single running tour)", "imagesAs": "optional str paths|base64|both", "sinceShot": "optional int - shots you already received; response windows to the new ones"}, {"percent": "int", "step": "str", "stepLabel": "str", "done": "bool", "shots": "list", "shotCount": "int", "stepShot": "dict|absent"}, 15, None, False, {"demoId": "tour-abc123", "imagesAs": "base64", "sinceShot": 3}), -    ("fusion_show_attributes", "Show a component's METADATA: returns the attributes of a deviceset (MPN, MANUFACTURER, MOUSER_PN, DIGIKEY_PN, LCSC_PN, DATASHEET, ADOM_WIKI, per variant) read from the library file with their full values, and opens the DEVICE editor for it (EDIT <name>.dev, no dialog: symbol, the Packages table with the Mapped check, footprint and 3D previews in one frame) with a capture. Fusion's own Variant Attributes table sits behind a button in an opaque Qt panel; the reply carries the positioned-click recipe for it and notes that a main-window recording does not include that owned dialog.",-     {"deviceset": "required str", "libraryPath": "optional str (.lbr on the Fusion machine; opened first and read for the attributes; omit when the library is already the active document)", "capture": "optional bool (default true)"},-     {"success": "bool", "attributes": "obj (the component's metadata)", "screenshot": "str|null", "variantAttributesDialog": "obj (recipe)"}, 120, _STATUS, False, {"libraryPath": "C:/Users/me/Downloads/Adom_ToF.lbr", "deviceset": "VL53L8CX"}),+    ("fusion_show_attributes", "Show a component's METADATA, as data and on screen. Returns the attributes of a deviceset (MPN, MANUFACTURER, MOUSER_PN, DIGIKEY_PN, LCSC_PN, DATASHEET, ADOM_WIKI, per variant) read from the library file with full values, in BOTH dialects: EAGLE <technology><attribute> and Fusion's layered <att_values>/<att_set> (1.10.34; Fusion-authored libraries used to read as empty). Opens the DEVICE editor (EDIT <name>.dev: symbol, Packages table with the Mapped check, footprint and 3D previews) with a capture, then opens Fusion's own Variant Attributes table with EAGLE's ATTRIBUTE command (no positioned clicks), reads it through UI Automation, captures the dialog window and Cancels it. Nothing is left on screen. dialog:false skips the table.",+     {"deviceset": "required str", "libraryPath": "optional str (.lbr on the Fusion machine; opened first and read for the attributes; omit when the library is already the active document)", "capture": "optional bool (default true)", "dialog": "optional bool (default true): open, read, capture and close Fusion's Variant Attributes table"},+     {"success": "bool", "attributes": "obj (the component's metadata)", "screenshot": "str|null (device editor)", "variantAttributesDialog": "{opened, closed, title, text, screenshot}"}, 120, _STATUS, False, {"libraryPath": "C:/Users/me/Downloads/Adom_ToF.lbr", "deviceset": "VL53L8CX"}),     ("fusion_make_showcase_project", "Put a library's parts on a NEW schematic and board to SHOW THEM OFF: every deviceset in the .lbr (or the ones in devicesets[]) placed in a row on a fresh sheet and a fresh board, no nets, with the library's bound 3D urns carried across so the 3D PCB shows the real bodies. Generated as EAGLE source on the box and opened dialog-free (Document.newDesignFromLocal), so nothing touches the user's screen. This is THE verb for 'make a board with these new parts on it'; fusion_make_test_project is the one-part circuit variant. After it: fusion_show_2d_board / fusion_show_3d_board {capture:true}, fusion_schematic {action:\"show\"}, fusion_save_to_cloud. The name of each part is written on the top silkscreen under its footprint (silkLabels:false turns it off); fusion_make_test_project does the same.",      {"silkLabels": "optional bool (default true): the part name on layer 21 under each footprint", "libraryPath": "required str (.lbr on the Fusion machine; a component page's .lbr or a merged bundle library)", "devicesets": "optional [str] (default: every deviceset in the library)", "name": "optional str document name (default <library>-showcase)"},      {"success": "bool", "documentName": "str", "parts": "[{ref, deviceset, package, symbol}]", "partsPlaced": "int (read back from the board)", "schPath": "str", "brdPath": "str"}, 300, _STATUS, True, {"libraryPath": "C:/Users/me/Downloads/Adom_ToF.lbr", "devicesets": ["VL53L5CX", "VL53L7CX", "VL53L8CX"]}),
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.10.33"+DASH_VERSION="1.10.34" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.10.33",+  "version": "1.10.34",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+10−26
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.10.33",+  "version": "1.10.34",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -11,6 +11,10 @@     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -76,6 +80,9 @@     "add a 3d model to a component",     "make a fusion 360 library with 3d",     "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page",     "aps not configured",     "share aps client id across my org",     "set up fusion cloud search for my company",@@ -108,28 +115,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "agent_permissions": {-    "allow_bash": [],-    "allow": [-      "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",-      "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",-      "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",-      "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."-    ],-    "soft_deny": [-      "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",-      "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",-      "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",-      "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."-    ],-    "environment": [-      "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",-      "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."-    ]-  },-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }-}\ No newline at end of file+  ]+}
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"]  # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.10.33"+version = "1.10.34" edition = "2021" license = "MIT" 
rust/bridge/src/orchestrations.rs+45−6
@@ -1009,12 +1009,51 @@ pub fn show_attributes(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Va     std::thread::sleep(Duration::from_millis(1200));     let _ = pass("execute_text_command", json!({"command": "Electron.run WINDOW FIT;"}));     let shot = if args["capture"].as_bool() != Some(false) { capture_labeled(ctx, &format!("attributes_{ds}")) } else { None };-    json!({"success": true, "deviceset": ds, "attributes": attributes, "deviceEditor": true, "screenshot": shot,-        "onScreen": "the DEVICE editor for the deviceset: symbol, the Packages table with the Mapped check, footprint and 3D package previews (Fusion's ganged view)",-        "variantAttributesDialog": {"how": "In the Packages table the variant row has a ... button at its right edge; click it, then Attribute Sets. Both live in an opaque Qt panel with no UIA children, so use positioned clicks: desktop_screenshot_window {hwnd}, then desktop_click {hwnd, space:image, shotId, x, y}. The modal is titled Variant Attributes and appears in fusion_state.dialogs[] with its own hwnd; close it with desktop_ui_click {hwnd, name:Cancel}.",-            "recording": "desktop_record_window_start on Fusion's main window does NOT include that owned dialog: record the dialog's own hwnd (from fusion_state.dialogs[]) for that shot.",-            "columns": "the DEFAULT column truncates URLs and part numbers at its default width; drag its edge wider before capturing, or rely on attributes in this reply for the full values"},-        "_hint": "attributes carries the component's metadata read from the library file (the full values); screenshot shows it on screen in the device editor. For Fusion's own Attribute Sets table follow variantAttributesDialog.how."})+    // Fusion's own "Variant Attributes" table. EAGLE's ATTRIBUTE command, run in the device+    // editor, opens it directly: no "..." button, no positioned clicks (found 2026-09-20; the+    // first cut of this verb only described a click recipe). The command blocks Fusion's main+    // thread until the dialog closes, so it is fired from a side thread while this one reads the+    // dialog through UI Automation (full values, the on-screen column truncates them), captures+    // the dialog's own window, and then Cancels it. The bridge never leaves it on screen.+    let mut dialog = json!({"opened": false});+    if args["dialog"].as_bool() != Some(false) {+        let fire = std::thread::spawn(|| { let _ = crate::addin::addin_http("electron_run", &json!({"command": "ATTRIBUTE", "confirm": "no"}), 45); });+        let mut found: Option<Value> = None;+        for _ in 0..24 {+            std::thread::sleep(Duration::from_millis(500));+            if let Some(d) = crate::lifecycle::dialog_inventory(ctx).into_iter().find(|d| d["title"].as_str().map(|t| t.contains("Variant Attributes") || t.contains("Attributes")).unwrap_or(false)) { found = Some(d); break; }+        }+        if let Some(d) = found {+            let hwnd = d["hwnd"].as_i64().unwrap_or(0);+            let mut dshot = Value::Null;+            if let Some(ab) = fusion_core::ab::Loopback::from_env() {+                use fusion_core::ab::AbClient; use base64::Engine;+                if args["capture"].as_bool() != Some(false) {+                    if let Ok(r) = ab.call("desktop_screenshot_window", json!({"hwnd": hwnd}), &ctx.caller, 20) {+                        let img = r["image"].as_str().or(r["data"]["image"].as_str()).unwrap_or("");+                        if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(img) {+                            let dir = std::path::Path::new("C:/tmp/conduit-screenshots"); let _ = std::fs::create_dir_all(dir);+                            let dst = dir.join(format!("fusion-variant-attributes_{}-{}.png", ds.replace(|c: char| !c.is_ascii_alphanumeric(), "_"), (now_f() * 1000.0) as u64));+                            if std::fs::write(&dst, bytes).is_ok() { dshot = json!(dst.to_string_lossy().replace('\\', "/")); }+                        }+                    }+                }+                let _ = ab.call("desktop_ui_click", json!({"hwnd": hwnd, "name": "Cancel", "role": "button"}), &ctx.caller, 12);+                std::thread::sleep(Duration::from_millis(700));+                // Cancel did not land: WM_CLOSE is the same answer, and nothing may be left open+                if crate::lifecycle::dialog_inventory(ctx).iter().any(|x| x["hwnd"].as_i64() == Some(hwnd)) { let _ = ab.call("desktop_close_window", json!({"hwnd": hwnd}), &ctx.caller, 10); }+            }+            dialog = json!({"opened": true, "closed": true, "title": d["title"], "hwnd": hwnd, "text": d["text"], "screenshot": dshot,+                "note": "text is the whole table read through UI Automation (full values). The screenshot shows Fusion's own dialog, where the DEFAULT column truncates long URLs."});+        } else {+            dialog = json!({"opened": false, "note": "ATTRIBUTE did not raise the Variant Attributes dialog within 12 s (a deviceset with no device, or the device editor did not take focus). attributes in this reply is still complete."});+        }+        let _ = fire.join();+    }+    json!({"success": true, "deviceset": ds, "attributes": attributes, "deviceEditor": true, "screenshot": shot, "variantAttributesDialog": dialog,+        "onScreen": "the DEVICE editor for the deviceset (symbol, Packages table with the Mapped check, footprint and 3D previews), then Fusion's own Variant Attributes table, opened and closed by the bridge",+        "recording": "To FILM the table: fusion_show_attributes {dialog:false}, start desktop_record_window_start on Fusion, run fusion_electron_run ATTRIBUTE yourself from a second call, record the dialog's own hwnd (fusion_state.dialogs[]), then desktop_ui_click Cancel. A recording of the main window does not include an owned dialog.",+        "_hint": "attributes = the component's metadata from the library file, both EAGLE <attribute> and Fusion <att_value> dialects, full values. variantAttributesDialog.text = the same table as Fusion shows it, read by UI Automation; .screenshot = the dialog itself. The dialog is already closed. Pass dialog:false to skip it."}) }  /// Where an EAGLE import run keeps its state (`fusion_eagle_import_status {runId}`).
rust/lbr/src/lib.rs+55−7
@@ -167,14 +167,40 @@ pub fn library_part_metadata(xml: &str) -> Result<LibraryMetadata, LbrError> {         let mut variants = Vec::new();         for dev in ds.descendants().filter(|n| el(*n, "device")) {             let dev_name = dev.attribute("name").unwrap_or("").to_string();-            let techs: Vec<Option<roxmltree::Node>> = {-                let t: Vec<_> = dev.descendants().filter(|n| el(*n, "technology")).map(Some).collect();-                if t.is_empty() { vec![None] } else { t }+            // Fusion-authored libraries do not use EAGLE's <technology><attribute>. They layer+            // <att_values> on the deviceset, again on the device, and then per named <att_set>+            // (the "DEFAULT" column of Fusion's Variant Attributes dialog is the set named "").+            // A later non-empty value wins. Both dialects are read, so a converted library that+            // carries both still reports one truthful set per variant.+            let layer = |parent: roxmltree::Node, into: &mut BTreeMap<String, String>| {+                for holder in parent.children().filter(|c| el(*c, "att_values")) {+                    for a in holder.children().filter(|c| el(*c, "att_value")) {+                        let n = a.attribute("name").unwrap_or("").to_uppercase();+                        let v = a.attribute("value").unwrap_or("").trim().to_string();+                        if !n.is_empty() && !v.is_empty() { into.insert(n, v); }+                    }+                }+            };+            let mut dev_base = BTreeMap::new();+            layer(ds, &mut dev_base);+            layer(dev, &mut dev_base);+            let att_sets: Vec<roxmltree::Node> = dev.children().filter(|c| el(*c, "att_sets")).flat_map(|h| h.children().filter(|c| el(*c, "att_set")).collect::<Vec<_>>()).collect();+            let set_values = |set: roxmltree::Node, into: &mut BTreeMap<String, String>| {+                for a in set.children().filter(|c| el(*c, "att_value")) {+                    let n = a.attribute("name").unwrap_or("").to_uppercase();+                    let v = a.attribute("value").unwrap_or("").trim().to_string();+                    if !n.is_empty() && !v.is_empty() { into.insert(n, v); }+                }             };-            for tech in techs {-                let tname = tech.and_then(|t| t.attribute("name")).unwrap_or("").to_string();-                let mut attrs = BTreeMap::new();-                if let Some(t) = tech {+            let tech_nodes: Vec<roxmltree::Node> = dev.descendants().filter(|n| el(*n, "technology")).collect();+            // one variant per technology; with none, one per attribute set; with neither, one bare variant+            let names: Vec<String> = if !tech_nodes.is_empty() { tech_nodes.iter().map(|t| t.attribute("name").unwrap_or("").to_string()).collect() }+                else if !att_sets.is_empty() { att_sets.iter().map(|t| t.attribute("name").unwrap_or("").to_string()).collect() }+                else { vec![String::new()] };+            for tname in names {+                let mut attrs = dev_base.clone();+                if let Some(set) = att_sets.iter().find(|t| t.attribute("name").unwrap_or("") == tname) { set_values(*set, &mut attrs); }+                if let Some(t) = tech_nodes.iter().find(|t| t.attribute("name").unwrap_or("") == tname) {                     for a in t.descendants().filter(|n| el(*n, "attribute")) {                         let n = a.attribute("name").unwrap_or("").to_uppercase();                         let v = a.attribute("value").unwrap_or("").trim().to_string();@@ -900,3 +926,25 @@ mod heatsink_tests {         assert!(w.iter().any(|x| x["code"] == "heatsink_vias" && x["level"] == "info"), "same-net joins are reported as info: {codes:?}");     } }++#[cfg(test)]+mod fusion_att_tests {+    use super::*;+    #[test]+    fn fusion_att_values_are_read_in_layers() {+        let xml = r#"<?xml version="1.0"?><eagle><drawing><library><devicesets>+<deviceset name="VL53L5CX" prefix="U"><description>ToF</description>+<att_values><att_value name="MANUFACTURER" value="STMicroelectronics"/><att_value name="DATASHEET" value="https://x/ds.pdf"/></att_values>+<devices><device name="" package="VL53L5CX">+<att_values><att_value name="MPN" value=""/><att_value name="MOUSER_PN" value=""/></att_values>+<att_sets><att_set name=""><att_value name="MPN" value="VL53L5CXV0GC/1"/><att_value name="MOUSER_PN" value="511-VL53L5CXV0GC/1"/></att_set></att_sets>+</device></devices></deviceset></devicesets></library></drawing></eagle>"#;+        let md = library_part_metadata(xml).unwrap();+        let v = &md.devicesets[0].variants[0];+        assert_eq!(v.attributes.get("MPN").map(String::as_str), Some("VL53L5CXV0GC/1"));+        assert_eq!(v.attributes.get("MANUFACTURER").map(String::as_str), Some("STMicroelectronics"));+        assert_eq!(v.attributes.get("MOUSER_PN").map(String::as_str), Some("511-VL53L5CXV0GC/1"));+        assert!(v.missing.is_empty(), "{:?}", v.missing);+        assert_eq!(md.totals.with_mpn, 1);+    }+}
tools/check-naming.sh

  
tools/check-portable.sh

  
tools/promote.sh

  
tools/release.sh