--- master/BRIDGE_VERSION +++ release/BRIDGE_VERSION @@ -1 +1 @@ -0.9.317+0.9.340 --- master/package.json +++ release/package.json @@ -1,6 +1,6 @@ { "slug": "kicad-bridge", - "version": "1.0.127", + "version": "1.0.126", "type": "app", "description": "Skills for your container so your AI knows how to drive the KiCad bridge. The bridge runtime itself is the release zip; Adom Bridge loads that.", "tags": [ @@ -115,4 +115,4 @@ "adom-desktop-kicad-bridge container approval is the first of two gates; the desktop gate is the second." ] } -} +} --- master/bridge.json +++ release/bridge.json @@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA", - "version": "0.9.317", + "version": "0.9.340", "description": "Reverse bridge for KiCad \u2014 board/schematic introspection, lint via kicad-cli, plugin install, multi-instance probe, in-process DRC.", "homepage": "https://wiki.adom.inc/adom/adom-bridge", "author": "Adom Inc.", @@ -74,7 +74,16 @@ "kicad_show_2d_board", "kicad_show_3d_board", "kicad_show_library", - "kicad_show_project" + "kicad_show_project", + "kicad_routing_state", + "kicad_route_net", + "kicad_remove_route", + "kicad_routing_validate", + "kicad_board_pads", + "kicad_add_track", + "kicad_add_via", + "kicad_route", + "kicad_ipc_api" ], "statusVerb": "kicad_status", "timeouts": { --- master/page.json +++ release/page.json @@ -4,7 +4,7 @@ "slug": "kicad-bridge", "title": "KiCad - the KiCad Bridge", "brief": "Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.", - "version": "1.0.127", + "version": "1.0.111", "tags": [ "kicad", "pcb", @@ -53,7 +53,7 @@ "install_hint": "", "version_cmd": "" }, - "readme": "# Adom Desktop — KiCad Bridge\n\nA **reverse bridge** that lets Adom Desktop (AD) — and the AI driving it — control the user's own\nKiCad on Windows: open and drive every editor, install symbol/footprint libraries, place parts, run\nDRC/ERC, export manufacturing files, and screenshot any window back to the AI.\n\nKiCad is the user's **host app**. The bridge *detects* an existing KiCad and, if none is present,\n*installs* one for them — it never asks the user to download or click through anything by hand.\n\n- **Page / docs:** https://wiki.adom.inc/adom/adom-desktop-kicad-bridge\n- **Bridge SDK:** https://wiki.adom.inc/adom/adom-desktop-bridges\n- **Verb namespace:** `kicad_*`  ·  **Status verb:** `kicad_bridge_status`  ·  **Health:** `GET /status`\n\n> Every screenshot in this README was captured on **ADOMBASELINE**, a stock Hyper-V VM with **no\n> GPU**, driven end-to-end through the bridge — install → libraries → editors → 2D → 3D. If it\n> renders there, it renders on a real laptop.\n\n---\n\n## Demo — narrated live editor tour (real screen recording)\n\n[![KiCad bridge live demo](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/kicad-bridge-demo-poster.png)](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/kicad-bridge-demo.mp4)\n\n**▶ [Watch the demo](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/kicad-bridge-demo.mp4)** (48s, 🔊 narrated) — the bridge driving real KiCad on a Windows VM: **select** a footprint and **zoom** the copper in the PCB editor, **rotate** the assembled board in 3D, then tour the schematic, symbol, and footprint editors. Genuine screen recording of the live windows (zoom / select / 3D rotation), narrated section by section — not animated stills.\n\n\n---\n\n## The demo verb — `kicad_demo`\n\nOne verb that shows the whole bridge off, built for the AI that demos Adom Desktop\nduring Hydrogen Desktop's install. Six beats, in the order a hardware person thinks:\n\n| Beat | Shows |\n|---|---|\n| 1 | the **schematic symbol** in the user's own Symbol Editor |\n| 2 | the **footprint** for that same part |\n| 3 | that part in **3D** |\n| 4 | a **schematic** using it (U1 + two 10k resistors) |\n| 5 | the **2D board layout** |\n| 6 | the finished **board in 3D** |\n\n```bash\nadom-desktop kicad_demo '{}' # start: prepares, warms KiCad, opens beat 1\nadom-desktop kicad_demo '{\"step\":\"footprint\"}' # ...then follow data.nextCall each time\nadom-desktop kicad_demo '{\"all\":true}' # bulk (non-interactive; slower than one request budget)\nadom-desktop kicad_close '{\"force\":true}' # tear down\n```\n\n**It narrates itself.** Every beat returns `say` (a line to speak), `pointOut`\n(what's actually on screen), `window` (match it in `kicad_screenshot_all`) and\n`nextCall`. One beat per call is the default precisely so the AI can talk, screenshot,\nthen move on.\n\n**It ships nothing.** The two demo parts, the schematic and the board are all\ngenerated in code, and the 3D bodies come from KiCad's *own* bundled\n`3dmodels/*.3dshapes` — so the runtime zip stays small and the 3D views are real.\nParts install into the user's real `Adom` library; the project lands in\n`Documents/adom-kicad-demo`.\n\n**No KiCad? It offers to install it.** `kicad_demo` returns an offer instead of an\nerror; `kicad_demo '{\"installKiCad\": true}'` silently installs the official build\n(per-user, no UAC) and then runs the tour. Watching the AI install your EDA tool is\nitself a good demo beat.\n\n**It never takes your screen.** Every window opens in the background and all input is\nwindow-targeted (UIA / `PostMessage`), so you can keep working while it runs.\n\n---\n\n## How it fits together\n\nThe bridge is a `spawn.kind: python` process AD launches on the user's machine (`entrypoint:\nserver.py`, `port: 0` — AD picks the port and passes `ADOM_BIND_HOST`). It speaks HTTP\n(`POST /command`, `GET /status`) and reaches KiCad through **four control surfaces**, picking the\nlightest one that can do the job.\n\n![Control surfaces](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/control-surfaces.svg)\n\n| # | Surface | Used for | Where |\n|---|---------|----------|-------|\n| 1 | **kicad-cli** (subprocess) | headless DRC/ERC, gerber/pdf/svg/step/bom export, format upgrade | `handlers/export.py`, `run_drc`, `run_erc`, `lint_*` |\n| 2 | **KiCad IPC (kipy)** | board/schematic introspection, confirmed footprint placement (KiCad 9+) | `handlers/place_footprint.py` |\n| 3 | **Embedded Python** | JSON-RPC *inside* each KiCad process, dispatched on its wx UI thread | `plugin_payload/adom_bridge.py`, `handlers/bridge_client.py` |\n| 4 | **Win32 / UIA / SendKeys** | open editors, screenshots, clicks/keys, dialog handling, window management | `handlers/kicad_ui.py`, `handlers/close_windows.py` |\n\n---\n\n## The window tour\n\nEverything below was opened and captured on the GPU-less VM. Sample data is the **RP2040 breakout**\ngenerated by `tour-pack-rp2040/` (an `AdomRP2040` symbol + `QFN-56_AdomRP2040` footprint + a board).\n\n### Schematic editor — `kicad_open_schematic`\n\nThe RP2040 breakout: the MCU (U1), USB-C, a 12 MHz crystal, decoupling, and mounting pins.\n\n![Schematic editor](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/schematic-editor.png)\n\n### Symbol editor — `kicad_open_symbol_editor`\n\nOpens straight to a symbol (`kicad_install_symbol` puts it in a user library first).\n\n![Symbol editor](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/symbol-editor.png)\n\n### Footprint editor — `kicad_open_footprint_editor`\n\n`{\"footprintName\":\"QFN-56_AdomRP2040\",\"library\":\"AdomRP2040\"}` loads the part directly — 56 pins +\nthermal pad, 7×7 mm, 0.4 mm pitch, courtyard and silkscreen.\n\n![Footprint editor](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/footprint-editor.png)\n\n### PCB editor (2D) — `kicad_open_board`\n\nThe routed breakout — copper, silkscreen, the QFN-56 land pattern, mounting holes.\n\n![PCB editor 2D](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/pcb-editor-2d.png)\n\n### 3D viewer — `kicad_open_3d_viewer {\"editor\":\"pcb\"}`\n\nThe full board in 3D — board body, the USB-C connector's 3D model, the QFN chip body, SMD parts,\nplated through-holes. **Rendered on the CPU via the software-OpenGL fallback (below); reload 1.7 s.**\n\n![3D viewer](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/3d-viewer.png)\n\n---\n\n## Install & upgrade — zero manual steps\n\nThe bridge never tells the user to go download KiCad. `kicad_upgrade` fetches the official installer\nand runs it silently, picking the scope automatically:\n\n- **Elevated AD** → `/allusers /S` (system-wide, `%ProgramFiles%\\KiCad`).\n- **Non-elevated AD** → `/currentuser /S` (`%LocalAppData%\\Programs\\KiCad`, **no UAC prompt**).\n\n`kicad_readiness` reports whether KiCad is installed and ready without side effects; the AI routes on\nit before offering to install. Hard-won install details (all handled for you):\n\n- KiCad 10's NsisMultiUser installer **requires** a scope flag — bare `/S` errors `rc=666660`.\n- AD's portable Python has **no CA bundle**; the bridge ships `certs/cacert.pem` and uses it for TLS.\n- Downloads are size-checked against `Content-Length` (a truncated installer otherwise fails at NSIS).\n- `%APPDATA%/kicad//` lib tables don't exist until first launch — the bridge **bootstraps** them\n so `install_library`/`install_footprint` work on a never-opened KiCad.\n- `kicad_upgrade {\"diagnoseOnly\":true}` reports token-elevation type + `EnableLUA` without installing.\n\n---\n\n## Dialogs & error handling — the bridge clears the pointless ones\n\nKiCad throws modal dialogs that stall automation. The bridge **scans every window owned by a running\nKiCad process** (by PID — reliable, unlike title matching), auto-expires the benign ones, screenshots\nwhat it dismissed, and returns a hint so the AI can decide what (if anything) to tell the user.\n\nAuto-expired benign dialogs include:\n\n- **\"Could not use OpenGL / falling back to software rendering\"** (GPU-less hosts).\n- **\"Welcome to KiCad — starting for the first time\"** first-run wizard (dismissing accepts defaults).\n- **\"This file was created by an older version of KiCad\"** conversion notice.\n\n![First-run wizard, auto-dismissed](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/dialog-firstrun-wizard.png)\n\nVerbs:\n\n- `kicad_window_info` — lists windows and **self-heals** (auto-expires benign dialogs) by default.\n- `kicad_dismiss_dialogs {\"all\":true}` — clear everything blocking; `{\"screenshot\":true}` returns\n images of each; `{\"forceSoftwareCanvas\":true}` persists Cairo canvas; `{\"debug\":true}` dumps the\n raw PID-based scan.\n- `kicad_screenshot_all` — one call returns **every** open KiCad window, so the AI can spot an error\n dialog it didn't expect and read the message.\n\n---\n\n## Software-OpenGL fallback (last resort)\n\n`kicad_enable_software_opengl` deploys Mesa's `llvmpipe` (a CPU OpenGL rasterizer) into KiCad's `bin`\nso a box with **no usable GPU** — Hyper-V, RDP, headless CI — can still render the editors and the 3D\nviewer. This is how every 3D shot above exists.\n\n> ⚠️ It renders on the CPU and is **slow**. It is a worst-case fallback only — a real GPU (or GPU-P /\n> DDA passthrough) is vastly better. The bridge keeps it so a GPU-less box isn't a dead end; it is\n> never suggested proactively.\n\n---\n\n## Verb reference\n\nAll 41 verbs (prefix `kicad_`). Call `kicad_describe` for the live catalog with per-verb hints,\nrelated verbs, and pitfalls.\n\n**Windows & UI** — `open_board`, `open_schematic`, `open_symbol_editor`, `open_footprint_editor`,\n`open_3d_viewer`, `open_editors`, `close_symbol_editor`, `close_footprint_editor`, `close_3d_viewer`,\n`close`, `window_info`, `dismiss_dialogs`, `screenshot_all`, `send_key`, `click`, `fix_keyboard`\n\n**Libraries & parts** — `install_library`, `install_symbol`, `install_footprint`, `install_plugin`,\n`place_footprint`, `adom_library_status`\n\n**Checks & export** — `run_drc`, `run_erc`, `lint_board`, `lint_schematic`, `lint_library`,\n`format_upgrade`, `export_gerber`, `export_pdf`, `export_svg`, `export_step`, `export_bom_csv`\n\n**Detect, install & meta** — `list_versions`, `readiness`, `describe`, `enable_software_opengl`,\n`check_for_updates`, `upgrade`, `bridge_status`, `bridge_call`\n\n---\n\n## Running / dependencies\n\nAD provisions everything; there is nothing to install by hand.\n\n- **Python** ≥ 3.11 (AD provisions it) — stdlib only, plus the bundled `certs/cacert.pem`.\n- **KiCad** ≥ 7.0 (host app; `kicad_upgrade` installs it if absent).\n- **OS:** Windows (macOS/Linux detection stubs exist; the GUI surfaces are Windows-first).\n\nThe bridge binds `ADOM_BIND_HOST` on an AD-assigned port — never `0.0.0.0`. Auto-updates via\n`updateManifestUrl` in `bridge.json`.\n\n---\n\n*Developer & publish notes live in `dev-skills/` and `publish-skills/` (source-only, never shipped to\na user install).*\n", + "readme": "# Adom Desktop \u2014 KiCad Bridge\n\nA **reverse bridge** that lets Adom Desktop (AD) \u2014 and the AI driving it \u2014 control the user's own\nKiCad on Windows: open and drive every editor, install symbol/footprint libraries, place parts, run\nDRC/ERC, export manufacturing files, and screenshot any window back to the AI.\n\nKiCad is the user's **host app**. The bridge *detects* an existing KiCad and, if none is present,\n*installs* one for them \u2014 it never asks the user to download or click through anything by hand.\n\n- **Page / docs:** https://wiki.adom.inc/adom/adom-desktop-kicad-bridge\n- **Bridge SDK:** https://wiki.adom.inc/adom/adom-desktop-bridges\n- **Verb namespace:** `kicad_*`  \u00b7  **Status verb:** `kicad_bridge_status`  \u00b7  **Health:** `GET /status`\n\n> Every screenshot in this README was captured on **ADOMBASELINE**, a stock Hyper-V VM with **no\n> GPU**, driven end-to-end through the bridge \u2014 install \u2192 libraries \u2192 editors \u2192 2D \u2192 3D. If it\n> renders there, it renders on a real laptop.\n\n---\n\n## Demo \u2014 narrated live editor tour (real screen recording)\n\n[![KiCad bridge live demo](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/kicad-bridge-demo-poster.png)](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/kicad-bridge-demo.mp4)\n\n**\u25b6 [Watch the demo](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/kicad-bridge-demo.mp4)** (48s, \ud83d\udd0a narrated) \u2014 the bridge driving real KiCad on a Windows VM: **select** a footprint and **zoom** the copper in the PCB editor, **rotate** the assembled board in 3D, then tour the schematic, symbol, and footprint editors. Genuine screen recording of the live windows (zoom / select / 3D rotation), narrated section by section \u2014 not animated stills.\n\n\n---\n\n## The demo verb \u2014 `kicad_demo`\n\nOne verb that shows the whole bridge off, built for the AI that demos Adom Desktop\nduring Hydrogen Desktop's install. Six beats, in the order a hardware person thinks:\n\n| Beat | Shows |\n|---|---|\n| 1 | the **schematic symbol** in the user's own Symbol Editor |\n| 2 | the **footprint** for that same part |\n| 3 | that part in **3D** |\n| 4 | a **schematic** using it (U1 + two 10k resistors) |\n| 5 | the **2D board layout** |\n| 6 | the finished **board in 3D** |\n\n```bash\nadom-desktop kicad_demo '{}' # start: prepares, warms KiCad, opens beat 1\nadom-desktop kicad_demo '{\"step\":\"footprint\"}' # ...then follow data.nextCall each time\nadom-desktop kicad_demo '{\"all\":true}' # bulk (non-interactive; slower than one request budget)\nadom-desktop kicad_close '{\"force\":true}' # tear down\n```\n\n**It narrates itself.** Every beat returns `say` (a line to speak), `pointOut`\n(what's actually on screen), `window` (match it in `kicad_screenshot_all`) and\n`nextCall`. One beat per call is the default precisely so the AI can talk, screenshot,\nthen move on.\n\n**It ships nothing.** The two demo parts, the schematic and the board are all\ngenerated in code, and the 3D bodies come from KiCad's *own* bundled\n`3dmodels/*.3dshapes` \u2014 so the runtime zip stays small and the 3D views are real.\nParts install into the user's real `Adom` library; the project lands in\n`Documents/adom-kicad-demo`.\n\n**No KiCad? It offers to install it.** `kicad_demo` returns an offer instead of an\nerror; `kicad_demo '{\"installKiCad\": true}'` silently installs the official build\n(per-user, no UAC) and then runs the tour. Watching the AI install your EDA tool is\nitself a good demo beat.\n\n**It never takes your screen.** Every window opens in the background and all input is\nwindow-targeted (UIA / `PostMessage`), so you can keep working while it runs.\n\n---\n\n## How it fits together\n\nThe bridge is a `spawn.kind: python` process AD launches on the user's machine (`entrypoint:\nserver.py`, `port: 0` \u2014 AD picks the port and passes `ADOM_BIND_HOST`). It speaks HTTP\n(`POST /command`, `GET /status`) and reaches KiCad through **four control surfaces**, picking the\nlightest one that can do the job.\n\n![Control surfaces](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/control-surfaces.svg)\n\n| # | Surface | Used for | Where |\n|---|---------|----------|-------|\n| 1 | **kicad-cli** (subprocess) | headless DRC/ERC, gerber/pdf/svg/step/bom export, format upgrade | `handlers/export.py`, `run_drc`, `run_erc`, `lint_*` |\n| 2 | **KiCad IPC (kipy)** | board/schematic introspection, confirmed footprint placement (KiCad 9+) | `handlers/place_footprint.py` |\n| 3 | **Embedded Python** | JSON-RPC *inside* each KiCad process, dispatched on its wx UI thread | `plugin_payload/adom_bridge.py`, `handlers/bridge_client.py` |\n| 4 | **Win32 / UIA / SendKeys** | open editors, screenshots, clicks/keys, dialog handling, window management | `handlers/kicad_ui.py`, `handlers/close_windows.py` |\n\n---\n\n## The window tour\n\nEverything below was opened and captured on the GPU-less VM. Sample data is the **RP2040 breakout**\ngenerated by `tour-pack-rp2040/` (an `AdomRP2040` symbol + `QFN-56_AdomRP2040` footprint + a board).\n\n### Schematic editor \u2014 `kicad_open_schematic`\n\nThe RP2040 breakout: the MCU (U1), USB-C, a 12 MHz crystal, decoupling, and mounting pins.\n\n![Schematic editor](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/schematic-editor.png)\n\n### Symbol editor \u2014 `kicad_open_symbol_editor`\n\nOpens straight to a symbol (`kicad_install_symbol` puts it in a user library first).\n\n![Symbol editor](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/symbol-editor.png)\n\n### Footprint editor \u2014 `kicad_open_footprint_editor`\n\n`{\"footprintName\":\"QFN-56_AdomRP2040\",\"library\":\"AdomRP2040\"}` loads the part directly \u2014 56 pins +\nthermal pad, 7\u00d77 mm, 0.4 mm pitch, courtyard and silkscreen.\n\n![Footprint editor](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/footprint-editor.png)\n\n### PCB editor (2D) \u2014 `kicad_open_board`\n\nThe routed breakout \u2014 copper, silkscreen, the QFN-56 land pattern, mounting holes.\n\n![PCB editor 2D](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/pcb-editor-2d.png)\n\n### 3D viewer \u2014 `kicad_open_3d_viewer {\"editor\":\"pcb\"}`\n\nThe full board in 3D \u2014 board body, the USB-C connector's 3D model, the QFN chip body, SMD parts,\nplated through-holes. **Rendered on the CPU via the software-OpenGL fallback (below); reload 1.7 s.**\n\n![3D viewer](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/3d-viewer.png)\n\n---\n\n## Install & upgrade \u2014 zero manual steps\n\nThe bridge never tells the user to go download KiCad. `kicad_upgrade` fetches the official installer\nand runs it silently, picking the scope automatically:\n\n- **Elevated AD** \u2192 `/allusers /S` (system-wide, `%ProgramFiles%\\KiCad`).\n- **Non-elevated AD** \u2192 `/currentuser /S` (`%LocalAppData%\\Programs\\KiCad`, **no UAC prompt**).\n\n`kicad_readiness` reports whether KiCad is installed and ready without side effects; the AI routes on\nit before offering to install. Hard-won install details (all handled for you):\n\n- KiCad 10's NsisMultiUser installer **requires** a scope flag \u2014 bare `/S` errors `rc=666660`.\n- AD's portable Python has **no CA bundle**; the bridge ships `certs/cacert.pem` and uses it for TLS.\n- Downloads are size-checked against `Content-Length` (a truncated installer otherwise fails at NSIS).\n- `%APPDATA%/kicad//` lib tables don't exist until first launch \u2014 the bridge **bootstraps** them\n so `install_library`/`install_footprint` work on a never-opened KiCad.\n- `kicad_upgrade {\"diagnoseOnly\":true}` reports token-elevation type + `EnableLUA` without installing.\n\n---\n\n## Dialogs & error handling \u2014 the bridge clears the pointless ones\n\nKiCad throws modal dialogs that stall automation. The bridge **scans every window owned by a running\nKiCad process** (by PID \u2014 reliable, unlike title matching), auto-expires the benign ones, screenshots\nwhat it dismissed, and returns a hint so the AI can decide what (if anything) to tell the user.\n\nAuto-expired benign dialogs include:\n\n- **\"Could not use OpenGL / falling back to software rendering\"** (GPU-less hosts).\n- **\"Welcome to KiCad \u2014 starting for the first time\"** first-run wizard (dismissing accepts defaults).\n- **\"This file was created by an older version of KiCad\"** conversion notice.\n\n![First-run wizard, auto-dismissed](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/dialog-firstrun-wizard.png)\n\nVerbs:\n\n- `kicad_window_info` \u2014 lists windows and **self-heals** (auto-expires benign dialogs) by default.\n- `kicad_dismiss_dialogs {\"all\":true}` \u2014 clear everything blocking; `{\"screenshot\":true}` returns\n images of each; `{\"forceSoftwareCanvas\":true}` persists Cairo canvas; `{\"debug\":true}` dumps the\n raw PID-based scan.\n- `kicad_screenshot_all` \u2014 one call returns **every** open KiCad window, so the AI can spot an error\n dialog it didn't expect and read the message.\n\n---\n\n## Software-OpenGL fallback (last resort)\n\n`kicad_enable_software_opengl` deploys Mesa's `llvmpipe` (a CPU OpenGL rasterizer) into KiCad's `bin`\nso a box with **no usable GPU** \u2014 Hyper-V, RDP, headless CI \u2014 can still render the editors and the 3D\nviewer. This is how every 3D shot above exists.\n\n> \u26a0\ufe0f It renders on the CPU and is **slow**. It is a worst-case fallback only \u2014 a real GPU (or GPU-P /\n> DDA passthrough) is vastly better. The bridge keeps it so a GPU-less box isn't a dead end; it is\n> never suggested proactively.\n\n---\n\n## Verb reference\n\nAll 41 verbs (prefix `kicad_`). Call `kicad_describe` for the live catalog with per-verb hints,\nrelated verbs, and pitfalls.\n\n**Windows & UI** \u2014 `open_board`, `open_schematic`, `open_symbol_editor`, `open_footprint_editor`,\n`open_3d_viewer`, `open_editors`, `close_symbol_editor`, `close_footprint_editor`, `close_3d_viewer`,\n`close`, `window_info`, `dismiss_dialogs`, `screenshot_all`, `send_key`, `click`, `fix_keyboard`\n\n**Libraries & parts** \u2014 `install_library`, `install_symbol`, `install_footprint`, `install_plugin`,\n`place_footprint`, `adom_library_status`\n\n**Checks & export** \u2014 `run_drc`, `run_erc`, `lint_board`, `lint_schematic`, `lint_library`,\n`format_upgrade`, `export_gerber`, `export_pdf`, `export_svg`, `export_step`, `export_bom_csv`\n\n**Detect, install & meta** \u2014 `list_versions`, `readiness`, `describe`, `enable_software_opengl`,\n`check_for_updates`, `upgrade`, `bridge_status`, `bridge_call`\n\n---\n\n## Running / dependencies\n\nAD provisions everything; there is nothing to install by hand.\n\n- **Python** \u2265 3.11 (AD provisions it) \u2014 stdlib only, plus the bundled `certs/cacert.pem`.\n- **KiCad** \u2265 7.0 (host app; `kicad_upgrade` installs it if absent).\n- **OS:** Windows (macOS/Linux detection stubs exist; the GUI surfaces are Windows-first).\n\nThe bridge binds `ADOM_BIND_HOST` on an AD-assigned port \u2014 never `0.0.0.0`. Auto-updates via\n`updateManifestUrl` in `bridge.json`.\n\n---\n\n*Developer & publish notes live in `dev-skills/` and `publish-skills/` (source-only, never shipped to\na user install).*\n", "author": { "name": "John Lauer", "email": "john@adom.inc" @@ -90,8 +90,6 @@ "skills/kicad-tour/SKILL.md", "skills/kicad-tour/tour_runner.py", "skills/kicad-web-control/SKILL.md", - "skills/kicad-uia/catalog.json", - "skills/kicad-uia/SKILL.md", "skills/kicad-bridge-dev/SKILL.md", "skills/kicad-bridge-publish/SKILL.md", "skills/kicad-bridge-hero/SKILL.md", @@ -129,30 +127,5 @@ "binary_name": "adom-bridge-kicad", "authors": null, "author_handle": "john", - "authors_json": null, - "agent_permissions": { - "allow_bash": [ - "adom-desktop-kicad-bridge" - ], - "allow": [ - "adom-desktop-kicad-bridge status, readiness, describe, diagnostics, window_info, state and open_editors are read-only queries against KiCad and the bridge; they change nothing.", - "adom-desktop-kicad-bridge list_versions, list_footprints, list_symbols, list_design_rules and adom_library_status read library tables and files; they change nothing.", - "adom-desktop-kicad-bridge plugin_diagnose, verb_times, progress, errors, log_tail, get_settings and check_for_updates read bridge logs and settings; they change nothing.", - "adom-desktop-kicad-bridge screenshot_all captures KiCad windows to images; it changes nothing in KiCad.", - "adom-desktop-kicad-bridge extract_netlist, trace_net, find_connections and analyze_connections read the open design; they change nothing.", - "adom-desktop-kicad-bridge lint_board, lint_schematic, lint_library, run_drc and run_erc run KiCad's own checks and produce reports; they change nothing." - ], - "soft_deny": [ - "adom-desktop-kicad-bridge install_library, install_library_bundle, install_symbol, install_footprint, pcm_install and pcm_uninstall write to the user's KiCad libraries.", - "adom-desktop-kicad-bridge adom_library_heal, set_design_rules, format_upgrade and set_settings rewrite library files, rules or settings.", - "adom-desktop-kicad-bridge launch, close, close_symbol_editor, close_footprint_editor, close_3d_viewer and dismiss_dialogs start or stop KiCad windows.", - "adom-desktop-kicad-bridge uninstall, install_plugin, upgrade and enable_software_opengl reconfigure the user's KiCad or the bridge.", - "adom-desktop-kicad-bridge export_gerber, export_gerbers, export_pdf, export_svg, export_step, export_molecule, export_bom_csv and export_part write files.", - "adom-desktop-kicad-bridge make_part_project and place_footprint edit or create designs; demo, send_key and click drive KiCad's windows and inject input." - ], - "environment": [ - "adom-desktop-kicad-bridge is the KiCad Bridge; every kicad verb also passes Adom Bridge's human-approval gate on the user's desktop (caller identity, per-call reason, Activity Log).", - "adom-desktop-kicad-bridge container approval is the first of two gates; the desktop gate is the second." - ] - } -}+ "authors_json": null +} --- master/handlers/open_footprint_editor.py +++ release/handlers/open_footprint_editor.py @@ -524,7 +524,7 @@ fixed the Symbol Editor (0.9.86). WM_SETTEXT into the LIB_TREE filter, Down through the filter box to select the match, Enter posted to the tree candidates + PostMessage dblclick fallback. Zero focus, no plugin needed.""" - edit = _find_child_by_class_and_parent(fp_hwnd, "Edit", "searchCtrl") + edit = _ensure_tree_search(fp_hwnd) if not edit: return False WM_SETTEXT = 0x000C @@ -583,6 +583,61 @@ return False + +_TREE_TOGGLED: dict = {} # fp editor hwnd -> monotonic time we toggled its Library Tree pane on + + +def _ensure_tree_search(fp_hwnd: int): + """The Library Tree's search control, showing the pane through the frame's + own View menu when the user has it hidden (arav-rog 2026-09-05: every + background load typed into a control that did not exist). Toggle at most + once per editor per minute, then WAIT for the control instead of toggling + again (a second toggle hid it right back).""" + def _visible(h) -> bool: + # wx HIDES a closed pane: the control still exists, so "found" is not + # enough (arav-rog 2026-09-05: the loader typed into a hidden filter box + # for three runs). Visible means the control and its ancestors are shown. + try: + return bool(h) and bool(user32.IsWindowVisible(h)) + except Exception: # pylint: disable=broad-except + return bool(h) + edit = _find_child_by_class_and_parent(fp_hwnd, "Edit", "searchCtrl") + if _visible(edit): + return edit + import time as _t + now = _t.monotonic() + if now - _TREE_TOGGLED.get(int(fp_hwnd), 0) > 60 and _show_library_tree_pane(): + _TREE_TOGGLED[int(fp_hwnd)] = now + for _ in range(16): + edit = _find_child_by_class_and_parent(fp_hwnd, "Edit", "searchCtrl") + if _visible(edit): + return edit + _t.sleep(0.25) + return edit if _visible(edit) else None + + +def _show_library_tree_pane() -> bool: + """Tick View > Panels > Library Tree on the Footprint Editor frame via the + pcbnew plugin (menu id resolved live; the FP editor is not frame 0).""" + try: + from handlers.bridge_client import _resolve_plugin, call as _rpc + meta, err = _resolve_plugin("pcbnew") + if err or not meta: + return False + rpc = _rpc(meta["port"], "get_menu_ids", {}, timeout=8.0) + frames = ((rpc.get("result") or {}).get("frames")) or [] + for idx, fr in enumerate(frames): + if "footprint editor" not in str(fr.get("frameTitle") or "").lower(): + continue + for m in fr.get("menuItems") or []: + if str(m.get("label") or "").strip().lower() == "library tree": + r = _rpc(meta["port"], "wm_command", {"frame_index": idx, "menu_id": int(m["id"])}, timeout=8.0) + return bool((r.get("result") or {}).get("posted")) + return False + except Exception: # pylint: disable=broad-except + return False + + def _navigate_to_footprint(fp_hwnd: int, footprint_name: str, load_after: bool = True) -> dict: """Call the PowerShell script to navigate to a specific footprint. @@ -593,7 +648,7 @@ if not _NAVIGATE_SCRIPT.exists(): return {"status": "error", "error": f"Script not found: {_NAVIGATE_SCRIPT}"} - search_edit = _find_child_by_class_and_parent(fp_hwnd, "Edit", "searchCtrl") + search_edit = _ensure_tree_search(fp_hwnd) if not search_edit: return {"status": "error", "error": "Search edit control not found in Footprint Editor"} @@ -1035,6 +1090,13 @@ _on_disk = bool(_ls.get("existsInLibrary")) except Exception: _ls = {} + # arav-rog 2026-09-05: with no pcbnew running (plugin_not_running on every + # tier) the editor never OPENED, yet this block read "on disk but not + # loaded" as a stale cache and restarted KiCad, twice, 128 s each. A cache + # verdict needs an open editor that failed to load the part; without one + # there is no evidence, so fall through to the plain failure and its hint. + if not fp_hwnd: + _on_disk = False if _on_disk and not args.get("_autoRestarted") and not args.get("_treeRefreshed"): # First resort: reload the tree in place (#51). Cheap, no restart, # and it keeps every window and the user's other work untouched. --- master/handlers/demo.py +++ release/handlers/demo.py @@ -327,6 +327,81 @@ return _re.sub(r'\n\s*\(model "(?:[A-Za-z]:/|\.\./)[^"]*"(?:(?!\n\t\t\(model)[\s\S])*?\n\t\t\)', "", pcb) +_MODEL_ALIASES = { # footprint id -> KiCad standard body when the name does not match a KiCad model + "MOSFET_CDT:TDSON-8-1_BSC016N06NS": "Package_TO_SOT_SMD.3dshapes/TDSON-8-1", +} + + +def _attach_standard_models(pcb: str, kicad_info: dict) -> tuple[str, dict]: + """John, 2026-09-05: "why don't you have all the chips loaded in that final 3d + board? there's no mosfets or microcontroller". The wiki board's author left + many footprints (the LQFP-48 MCU, the six TDSON MOSFETs, the SOT-89 regulator, + the pin headers) with NO (model ...) at all. For each footprint without a + model, attach KiCad's own body when one exists on this box for the same + Lib:Name (or an alias), so the 3D beat shows chips instead of bare pads. + Only the tour's fetched copy is touched, never the wiki page.""" + import re as _re, os as _os + base = (kicad_info or {}).get("base_dir") or "" + root = _os.path.join(base, "share", "kicad", "3dmodels") if base else "" + report = {"attached": 0, "skipped": 0, "missingModels": [], "root": root, + "aliasProbe": {k: _os.path.exists(_os.path.join(root, *(v + ".step").split("/"))) for k, v in _MODEL_ALIASES.items()} if root else {}} + if not root or not _os.path.isdir(root): + report["why"] = f"no 3dmodels dir under {base!r}" + return pcb, report + out = []; pos = 0 + for m in _re.finditer(r'\(footprint "([^"]+)"', pcb): + start = m.start() + block = _sexpr_block(pcb, start) + end = start + len(block) + fp_id = m.group(1) + # A body KiCad cannot load on THIS box is dead: the author's own disk + # (C:/..., ../, a private ${VAR}) or a ${KICAD10_3DMODEL_DIR} file that is + # not installed here. Drop it (balanced block) so a standard one can take + # over. (arav-rog 2026-09-05: the MOSFETs carried a second, missing model.) + if "MOSFET" in fp_id and len(report.setdefault("debug", [])) < 8: + report["debug"].append({"id": fp_id, "modelsBefore": _re.findall(r'\(model "([^"]+)"', block)}) + pos_m = 0 + while True: + dm = _re.search(r'\(model "([^"]+)"', block[pos_m:]) + if not dm: + break + at = pos_m + dm.start() + path = dm.group(1) + dead = bool(_re.match(r'(?:[A-Za-z]:[/\\]|\.\./|\$\{ADOM_)', path)) + if not dead and path.startswith("${KICAD10_3DMODEL_DIR}/"): + dead = not _os.path.exists(_os.path.join(root, *path[len("${KICAD10_3DMODEL_DIR}/"):].split("/"))) + if dead: + mb = _sexpr_block(block, at) + cut0 = at + while cut0 > 0 and block[cut0 - 1] in " \t\r\n": + cut0 -= 1 + block = block[:cut0] + block[at + len(mb):] + report["deadDropped"] = report.get("deadDropped", 0) + 1 + pos_m = cut0 + else: + pos_m = at + len(dm.group(0)) + if "(model " not in block and ":" in fp_id: + lib, name = fp_id.split(":", 1) + cand = _MODEL_ALIASES.get(fp_id) or f"{lib}.3dshapes/{name}" + rel = None + for ext in (".step", ".wrl"): + if _os.path.exists(_os.path.join(root, *(cand + ext).split("/"))): + rel = cand + ext; break + if "MOSFET" in fp_id: + report["debug"].append({"id": fp_id, "cand": cand, "rel": rel}) + if rel: + model = ('\n\t\t(model "${KICAD10_3DMODEL_DIR}/%s"\n\t\t\t(offset (xyz 0 0 0))\n\t\t\t(scale (xyz 1 1 1))\n\t\t\t(rotate (xyz 0 0 0))\n\t\t)' % rel) + block = block[:-1].rstrip() + model + "\n\t)" + report["attached"] += 1 + else: + report["skipped"] += 1 + if len(report["missingModels"]) < 12 and fp_id not in report["missingModels"]: + report["missingModels"].append(fp_id) + out.append(pcb[pos:start]); out.append(block); pos = end + out.append(pcb[pos:]) + return "".join(out), report + + def _extract_symbol(sch_text: str, lib_id: str) -> str: """One embedded library symbol (lib_symbols block) as a standalone .kicad_sym.""" i = sch_text.find("(lib_symbols") @@ -389,6 +464,47 @@ sch_text = Path(files["schematic"]).read_text(encoding="utf-8", errors="replace") pcb_text = Path(files["board"]).read_text(encoding="utf-8", errors="replace") try: + patched, rep = _attach_standard_models(pcb_text, kicad_info) + if rep.get("attached") and patched != pcb_text: + Path(files["board"]).write_text(patched, encoding="utf-8") + pcb_text = patched + # pcbnew keeps the OLD copy in memory; close the tour's board and its + # viewers so _warm_kicad reopens the patched file (arav-rog 2026-09-05: + # bodies were attached on disk and the 3D beat still showed bare pads). + try: + tt = _tour_titles({"boardName": DEMO_BOARD["name"]}) + for h in _viewers_3d(): + _close_hwnd(h) + bh = _hwnd_for_title(tt["board"]) + if bh: + _close_hwnd(bh) + rep["boardReloaded"] = True + except Exception as e: # pylint: disable=broad-except + rep["reloadError"] = f"{type(e).__name__}: {e}" + out["models3dAttached"] = rep + print(f"[demo] 3D bodies: {rep}", file=_sys.stderr) + # The open PCB editor holds whatever copy it loaded; if the file on disk + # is newer than the running pcbnew (patched by this or an earlier tour), + # close the tour's board and viewers so _warm_kicad reopens the fresh + # file (arav-rog 2026-09-05: bodies attached on disk, viewer still bare). + try: + import os as _os + started = _kicad_started_at() + bf = files.get("board") + if bf and started and _os.path.getmtime(bf) > started and not rep.get("boardReloaded"): + tt = _tour_titles({"boardName": DEMO_BOARD["name"]}) + for h in _viewers_3d(): + _close_hwnd(h) + bh = _hwnd_for_title(tt["board"]) + if bh: + _close_hwnd(bh) + rep["boardReloaded"] = "file newer than the running KiCad" + except Exception as e: # pylint: disable=broad-except + rep["reloadError"] = f"{type(e).__name__}: {e}" + except Exception as e: # pylint: disable=broad-except + out["warnings"].append(f"3D bodies: {e}") + out["focus"] = _focus_fractions(sch_text, pcb_text) # where the MCU sits, for motion that shows it + try: sym = _extract_symbol(sch_text, DEMO_BOARD["chipLibId"]) r = handle_install_symbol(kicad_info, {"fileName": f"{IC_SYM}.kicad_sym", "symbolName": IC_SYM, "fileContent": _b64(sym), "quietInstall": True}) @@ -402,6 +518,8 @@ "fileContent": _b64(fp), "quietInstall": True}) (out["installed"] if r.get("success") else out["warnings"]).append( IC_FP if r.get("success") else f"{IC_FP}: {r.get('error')}") + out["footprintWasNew"] = bool(r.get("success")) and not r.get("existedBefore") + out["footprintPath"] = r.get("installedPath") except Exception as e: # pylint: disable=broad-except out["warnings"].append(f"{IC_FP}: {e}") lqfp = _model_path(kicad_info, "Package_QFP.3dshapes", "LQFP-48_7x7mm_P0.5mm") @@ -511,7 +629,25 @@ f"registered in your '{DEMO_LIB}' library, not a scratch file"], "raw": r} if step == "footprint": - r = handle_open_footprint_editor(kicad_info, {"libraryName": DEMO_LIB, "footprintName": IC_FP}) + # arav-rog 2026-09-05: KiCad was cold when the tour started, the + # footprint beat ran before the project manager's plugin was alive and + # every open tier failed. Wait for the plugin (bounded) and retry. + # The Footprint Editor is hosted by pcbnew, and its background open needs + # the reverse-bridge plugin alive INSIDE pcbnew (it arms ~5 s after the + # PCB editor appears). Make sure the board is open, wait for the plugin, + # then try twice. + if not _hwnd_for_title(_tour_titles(prep)["board"]): + _warm_kicad(kicad_info, prep) + _wait_plugin_alive("pcbnew", 45) + r = {} + for _attempt in range(2): + r = handle_open_footprint_editor(kicad_info, {"libraryName": DEMO_LIB, "footprintName": IC_FP}) + _fh = _hwnd_for_title(_tour_titles(prep)["footprint"]) + if r.get("success") and _fh: + break + if _EXIT["now"]: + break + __import__("time").sleep(min(45, (r.get("retryAfterMs") or 4000) / 1000.0) if r.get("errorCode") == "kicad_restarted_retry" else 4) _fh = _hwnd_for_title(_tour_titles(prep)["footprint"]) return {"ok": bool(r.get("success")) and bool(_fh), "window": _tour_titles(prep)["footprint"], "hwnd": _fh, "title": "2/6 · Its footprint", @@ -638,10 +774,10 @@ # KiCad remembers a frame's size when it closes, so every bound we touch is # saved first and restored at the end of the tour (or its stop, or a crash on # the next tour start via the leftovers file). -_LAYOUT_SLOTS = { # (x-anchor, y-anchor): 0 = top/left edge, 1 = bottom/right edge - "symbol": (0.0, 0.0), "footprint": (0.15, 0.0), "footprint3d": (1.0, 0.0), - "schematic": (0.0, 1.0), "board": (0.15, 1.0), "board3d": (1.0, 1.0), -} +# John, 2026-09-05: "make all of them be 95% covering the entire display and center it", then "go 98% sizing". +# (The earlier 80% corner slots are gone; every editor and viewer window lands here.) +_LAYOUT_FRACTION = 0.98 +_LAYOUT_SLOTS = {k: (0.5, 0.5) for k in ("symbol", "footprint", "footprint3d", "schematic", "board", "board3d")} _SAVED_BOUNDS: dict = {} @@ -703,7 +839,7 @@ return {"placed": False, "why": "no monitor bounds or no hwnd"} ax, ay = _LAYOUT_SLOTS.get(step, (0.5, 0.5)) W, H = int(mon.get("width") or 0), int(mon.get("height") or 0) - w, h = int(W * 0.8), int(H * 0.8) + w, h = int(W * _LAYOUT_FRACTION), int(H * _LAYOUT_FRACTION) x = int(mon.get("x", 0) + ax * (W - w)); y = int(mon.get("y", 0) + ay * (H - h)) _save_bounds(hwnd, title) try: @@ -788,9 +924,10 @@ for attempt in range(tries): v["attempts"] = attempt + 1 rows = [r for r in (kicad_windows.find(all_windows=True, fresh=True) or []) if r.get("z") is not None] - top = sorted(rows, key=lambda r: r["z"])[0] if rows else {} + fh = _front_hwnd(rows) + top = next((r for r in rows if int(r.get("hwnd") or 0) == fh), {}) v["frontTitle"] = top.get("title") - v["front"] = int(top.get("hwnd") or 0) == int(hwnd) + v["front"] = fh == int(hwnd) mine = next((r for r in rows if int(r.get("hwnd") or 0) == int(hwnd)), None) live_title = (mine or {}).get("title") or "" v["liveTitle"] = live_title @@ -861,6 +998,7 @@ win_focus.allow_activation(target) except Exception: # pylint: disable=broad-except pass + _caption_note(f"Bringing the {window_title} to the front") raised = False try: r = ad_client.call("desktop_bring_to_front", @@ -878,13 +1016,157 @@ import time as _t for _ in range(4): # up to ~1 s for the z-order to settle _t.sleep(0.25) - rows = [x for x in (kicad_windows.find(all_windows=True, fresh=True) or []) if x.get("z") is not None] - if rows and int(sorted(rows, key=lambda x: x["z"])[0].get("hwnd") or 0) == target: + rows = kicad_windows.find(all_windows=True, fresh=True) or [] + if _front_hwnd(rows) == target: + _caption_note(f"Brought the {window_title} to the front") return True + _caption_note(f"The {window_title} stayed behind another window; leaving your desktop alone", 5000) return False except Exception: # pylint: disable=broad-except return False + + + +def _focus_fractions(sch_text: str, pcb_text: str) -> dict: + """Where the featured part (DEMO_BOARD chipRef) sits, as fractions of the + drawn content: the sheet for the schematic, the Edge.Cuts box for the board. + Motion aims its zoom there instead of the canvas centre (John, 2026-09-05: + "you still don't end up showing important stuff"). Measured for esc-g431: + schematic (0.20, 0.54) on A3, board (0.23, 0.66).""" + import re as _re + out = {} + try: + m = _re.search(r'\(symbol\s*\(lib_id "%s"\)\s*\(at ([-\d.]+) ([-\d.]+)' % _re.escape(DEMO_BOARD["chipLibId"]), sch_text) + paper = (_re.search(r'\(paper "([^"]+)"', sch_text) or [None, "A4"])[1] + sizes = {"A4": (297, 210), "A3": (420, 297), "A2": (594, 420), "A1": (841, 594), "A0": (1189, 841), + "A": (279.4, 215.9), "B": (431.8, 279.4), "C": (558.8, 431.8), "D": (863.6, 558.8), "E": (1117.6, 863.6)} + pw, ph = sizes.get(paper, (420, 297)) + if _re.search(r'\(paper "[^"]+" portrait\)', sch_text): + pw, ph = ph, pw + if m: + out["schematic"] = (round(float(m.group(1)) / pw, 3), round(float(m.group(2)) / ph, 3)) + except Exception: # pylint: disable=broad-except + pass + try: + i = pcb_text.find('(property "Reference" "%s"' % DEMO_BOARD["chipRef"]) + j = pcb_text.rfind("(footprint ", 0, i) + m = _re.search(r'\(at ([-\d.]+) ([-\d.]+)', pcb_text[j:i]) if i > 0 and j >= 0 else None + xs, ys = [], [] + for mm in _re.finditer(r'\((?:gr_line|gr_arc|gr_rect|gr_circle)[\s\S]*?\(layer "Edge\.Cuts"\)', pcb_text): + for c in _re.finditer(r'\((?:start|end|mid) ([-\d.]+) ([-\d.]+)\)', mm.group(0)): + xs.append(float(c.group(1))); ys.append(float(c.group(2))) + if m and xs and ys and max(xs) > min(xs) and max(ys) > min(ys): + fx = (float(m.group(1)) - min(xs)) / (max(xs) - min(xs)); fy = (float(m.group(2)) - min(ys)) / (max(ys) - min(ys)) + out["board"] = (round(min(max(fx, 0.0), 1.0), 3), round(min(max(fy, 0.0), 1.0), 3)) + except Exception: # pylint: disable=broad-except + pass + return out + + +def _restart_for_new_footprint(kicad_info: dict, prep: dict) -> dict: + """KiCad caches footprint libraries per PROCESS (#47). On a first-run box the + tour's footprint is brand new, and a running KiCad cannot see it until it + restarts; the opener did that restart MID-BEAT on arav-rog (2026-09-05), + which closed every window the tour had opened and cost six minutes. Do it + here, under the 'preparing' caption, before any window is on screen.""" + # KiCad caches footprint libraries per process: it needs a restart when the + # file is NEW, or when it was (re)written after this KiCad started (arav-rog + # 2026-09-05: earlier tours rewrote the file; the beat then failed with + # "cache predates the install" on every run). Compare file mtime with the + # process start time instead of trusting a flag. + needs = bool(prep.get("footprintWasNew")) + why = "footprint is new" + try: + import os as _os + fp = prep.get("footprintPath") + started = _kicad_started_at() + if fp and started and _os.path.exists(fp) and _os.path.getmtime(fp) > started: + needs, why = True, "footprint file is newer than the running KiCad" + except Exception: # pylint: disable=broad-except + pass + if not needs: + return {"restarted": False, "reason": "footprint already known to KiCad"} + try: + from handlers.kicad_ui import _find_all_kicad_windows + if not (_find_all_kicad_windows() or []): + return {"restarted": False, "reason": "KiCad not running; a fresh launch sees the new footprint"} + from handlers.open_footprint_editor import _restart_kicad_for_cache + _caption("KiCad tour 1/7: restarting KiCad so it sees the new footprint", duration_ms=40000) + r = _restart_kicad_for_cache(kicad_info) or {} + if r.get("restarted"): + import time as _t + t0 = _t.monotonic() + while _t.monotonic() - t0 < 60 and not (_find_all_kicad_windows() or []): + _t.sleep(1.0) + return r + except Exception as e: # pylint: disable=broad-except + return {"restarted": False, "reason": f"{type(e).__name__}: {e}"} + + +def _kicad_started_at() -> float | None: + """Earliest process start time (epoch seconds) among the live KiCad windows, + via GetProcessTimes. None off Windows or when nothing is running.""" + try: + import ctypes, ctypes.wintypes as wt + from handlers import kicad_windows + pids = {int(r.get("pid") or 0) for r in (kicad_windows.find(fresh=True) or []) if r.get("pid")} + if not pids: + return None + k32 = ctypes.windll.kernel32 + best = None + for pid in pids: + h = k32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION + if not h: + continue + c, e, kt, ut = wt.FILETIME(), wt.FILETIME(), wt.FILETIME(), wt.FILETIME() + ok = k32.GetProcessTimes(h, ctypes.byref(c), ctypes.byref(e), ctypes.byref(kt), ctypes.byref(ut)) + k32.CloseHandle(h) + if ok: + t = ((c.dwHighDateTime << 32) | c.dwLowDateTime) / 1e7 - 11644473600.0 + best = t if best is None else min(best, t) + return best + except Exception: # pylint: disable=broad-except + return None + + +def _wait_plugin_alive(exe: str, seconds: float) -> bool: + """Bounded wait for the reverse-bridge plugin inside `exe` (kicad or pcbnew) + to be resolvable and alive. Returns True when it is.""" + import time as _t + t0 = _t.monotonic() + while _t.monotonic() - t0 < seconds: + try: + from handlers.bridge_client import _resolve_plugin + info, err = _resolve_plugin(exe) + if info and not err: + return True + except Exception: # pylint: disable=broad-except + pass + if _EXIT["now"]: + return False + _t.sleep(1.5) + return False + + +_FRONT_IGNORE_TITLES = ("popuphost", "adom bridge recorder", "adom demo panel") +_FRONT_IGNORE_IMAGES = ("explorer.exe", "shellexperiencehost.exe", "searchhost.exe", "startmenuexperiencehost.exe", + "textinputhost.exe", "applicationframehost.exe") + + +def _front_hwnd(rows: list) -> int: + """The hwnd a user would call 'in front': lowest z among windows that are + not minimized, not Windows shell popups, not our own overlays.""" + cands = [] + for r in rows or []: + if r.get("z") is None or r.get("minimized"): + continue + t = str(r.get("title") or "").lower(); im = str(r.get("image") or "").lower() + if any(k in t for k in _FRONT_IGNORE_TITLES) or im in _FRONT_IGNORE_IMAGES: + continue + cands.append(r) + top = sorted(cands, key=lambda r: r["z"])[0] if cands else {} + return int(top.get("hwnd") or 0) def _hwnd_for_title(window_title: str) -> int | None: @@ -1022,6 +1304,28 @@ } _BEAT_CAPTION_MS = {"prepare": 35000, "schematic": 25000, "board": 25000, "board3d": 40000, "symbol": 30000, "footprint": 30000, "footprint3d": 35000} +# John, 2026-09-05: every beat is two parts. Part A loads the window and the +# document (its own short line, so the viewer is never stuck watching a load in +# silence); part B starts only when the window is verified on screen, with the +# motion and the line that explains what is being shown. +_BEAT_CAPTIONS_B = { + "symbol": "Zooming in on the STM32 pins, then back out to the whole symbol", + "footprint": "48 pads on 0.5 mm pitch: zooming in, then back out", + "footprint3d": "The LQFP body on its pads, tilted to isometric and orbited", + "schematic": "Zooming in on the microcontroller block, then back out to the sheet", + "board": "Zooming in on the MCU and its decoupling, then back out", + "board3d": "Isometric orbit around the MOSFETs and the MCU", +} + + +def _wait_narration(max_s: float) -> None: + """Let the current line finish (bounded), honouring exit and pending presses.""" + t0 = _time.monotonic() + while _time.monotonic() - t0 < max_s: + pr = _NARR.get("proc") + if not pr or pr.poll() is not None or _EXIT["now"] or _pending_interrupt(): + return + _time.sleep(0.2) # ── Narration: PRE-RENDERED mp3s, never on-the-fly TTS (John, 2026-08-24) ─── @@ -1034,6 +1338,7 @@ def _play_narration(step: str) -> None: + _NARR["startedAt"] = _time.time() _NARR["step"] = step if _NARR.get("muted"): return @@ -1417,7 +1722,7 @@ # MEDIUM size explicitly. r = ad_client.call("desktop_caption", {"id": _CAPTION_ID, "text": text, "duration": duration_ms, - "size": "medium", "position": "bottom", + "size": "medium", "position": "bottom-left", # the demo panel owns bottom-right (John, 2026-09-05) "reason": "Narrate the KiCad tour the user started, one line per beat"}, timeout=6) or {} ok = bool(r.get("success")) and not r.get("error") @@ -1433,11 +1738,30 @@ indicating you got my signal').""" try: from handlers import ad_client - r = ad_client.call("desktop_caption", {"id": "kicad-demo-ack", "text": text, "duration": 3500, - "size": "medium", "position": "top", + r = ad_client.call("desktop_caption", {"id": "kicad-demo-note", "text": text, "duration": 3500, + "size": "small", "position": "top", "reason": "Acknowledge a press on the KiCad tour remote the instant it arrives"}, timeout=6) or {} with _JOB_LOCK: _JOB.setdefault("captions", []).append({"text": text[:60], "ack": True, + "ok": bool(r.get("success")) and not r.get("error"), + "error": (r.get("error") or None)}) + except Exception: # pylint: disable=broad-except + pass + + +def _caption_note(text: str, duration_ms: int = 4000) -> None: + """Small caption at the TOP for what the tour is doing to the desktop right + now, above all "bringing the X window to the front" (John, 2026-09-05: "put + ab captions (small) at the top indicating each time you're foregrounding a + kicad window"). Shares the id with the press acks: same strip, replaced in + place, never stacked.""" + try: + from handlers import ad_client + r = ad_client.call("desktop_caption", {"id": "kicad-demo-note", "text": text, "duration": duration_ms, + "size": "small", "position": "top", + "reason": "Tell the user which KiCad window the tour is bringing forward"}, timeout=6) or {} + with _JOB_LOCK: + _JOB.setdefault("captions", []).append({"text": text[:60], "note": True, "ok": bool(r.get("success")) and not r.get("error"), "error": (r.get("error") or None)}) except Exception: # pylint: disable=broad-except @@ -1523,7 +1847,7 @@ _drain_stale_presses() _restore_bounds("leftovers") # a crashed earlier tour never leaves windows moved _close_stale_tour_windows() - _center_main_window() + # John, 2026-09-05: leave KiCad's main window exactly as KiCad opens it; only the editor/viewer windows get the 80% slots. _panel_show(0, "playing") _caption(_BEAT_CAPTIONS["prepare"], duration_ms=_BEAT_CAPTION_MS["prepare"]) _play_narration("prepare") @@ -1531,6 +1855,7 @@ _job_update(step="prepare", stepLabel="Preparing the sample project", percent=int(100 * _BEAT_EST["prepare"] * 0.5 / total_est), elapsedSec=round(_t.monotonic() - t0)) + prep["cacheRestart"] = _restart_for_new_footprint(kicad_info, prep) prep["warm"] = _warm_kicad(kicad_info, prep) done_weight += _BEAT_EST["prepare"] shot = _beat_shot(kicad_info, "Preparing the sample project", "KiCad") @@ -1575,6 +1900,7 @@ # ("next" simply proceeds: we are already between beats, so moving # on IS skipping the wait; a next during a beat applies here.) s_ = STEPS[i] + _beat_t0 = _time.time() # box epoch: lets a recording be synced to the beats afterwards _job_update(step=s_, stepLabel=f"Opening {s_.replace('_', ' ')}", beatIndex=i + 1, beatCount=len(STEPS), percent=min(95, int(100 * done_weight / total_est)), @@ -1582,8 +1908,11 @@ _panel_show(i + 1, "playing") if s_ in _BEAT_CAPTIONS: _caption(_BEAT_CAPTIONS[s_], duration_ms=_BEAT_CAPTION_MS.get(s_, 30000)) - _play_narration(s_) + _play_narration(f"{s_}-a" if (_AUDIO_DIR / f"{s_}-a.mp3").exists() else s_) # part A: loading r = _run_step(kicad_info, s_, prep) + r["startedAt"] = _beat_t0 + r["partAAt"] = _NARR.get("startedAt") + r["narrationAt"] = r["partAAt"] if not r.get("ok"): # 10.0.5 regression (found live 2026-08-16): a modal dialog at # editor-open stalled the first beats. STAGED calls get the @@ -1626,10 +1955,23 @@ else: # Motion for the recording (John): only on a verified window, and it # stops at the next gesture on STOP or a switch away. + # Part B: the window is verified on screen; let part A finish, + # then the explaining line and the motion together. + _wait_narration(12.0) + r["partBAt"] = _time.time() + if s_ in _BEAT_CAPTIONS_B: + _caption(_BEAT_CAPTIONS_B[s_], duration_ms=25000) + if (_AUDIO_DIR / f"{s_}-b.mp3").exists(): + _play_narration(f"{s_}-b") try: from handlers import demo_motion kind = "3d" if s_ in ("footprint3d", "board3d") else "2d" - r["motion"] = demo_motion.choreograph(target, kind, abort=_pending_interrupt) + _ku = __import__("handlers.kicad_ui", fromlist=["_canvas_probe", "_canvas_content"]) + r["motion"] = demo_motion.choreograph(target, kind, abort=_pending_interrupt, + check=lambda: (_ku._canvas_probe(target) or {}).get("rendered", True), + measure=lambda: _ku._canvas_content(target), + focus=((prep.get("focus") or {}).get(s_)), + zoom=((1, 0) if s_ in ("symbol", "footprint") else (2, 1))) except Exception as e: # pylint: disable=broad-except r["motion"] = {"error": f"{type(e).__name__}: {e}"} if _EXIT["now"]: @@ -1656,6 +1998,7 @@ _CTRL["queue"].insert(0, cmd); _CTRL["cmd"] = cmd break _t.sleep(0.5) + r["partBEndAt"] = _time.time() if exited: break i += 1 @@ -1669,7 +2012,8 @@ ok_ct = len([r for r in results if r.get("ok")]) _stop_narration() _panel_hide() - _job_update(windowsRestored=_restore_bounds("end")) + _job_update(windowsRestored=_restore_bounds("end"), + prepSummary={k: prep.get(k) for k in ("installed", "warnings", "footprintWasNew", "cacheRestart", "models3dAttached", "focus")}) try: from handlers import ad_client ad_client.call("notify_dismiss", {"id": _CTL_TOAST_ID}, timeout=4) @@ -1697,6 +2041,8 @@ "motion": r.get("motion"), "hwnd": r.get("hwnd"), "window": r.get("window"), "verified": r.get("verified"), "placed": r.get("placed"), + "startedAt": r.get("startedAt"), "narrationAt": r.get("narrationAt"), + "partAAt": r.get("partAAt"), "partBAt": r.get("partBAt"), "partBEndAt": r.get("partBEndAt"), "failReason": (None if r.get("ok") else { "handlerSuccess": bool((r.get("raw") or {}).get("success")), "error": str((r.get("raw") or {}).get("error") or "")[:200], @@ -1764,7 +2110,7 @@ out = {"success": True, **{k: snap.get(k) for k in ("done", "step", "stepLabel", "beatIndex", "beatCount", "percent", "elapsedSec", "estimatedSec", "error", - "panel", "muted", "muteSource", "lastPress", "presses", "paused", "stopping", "verified", "captions", "windowsRestored")}} + "panel", "muted", "muteSource", "lastPress", "presses", "paused", "stopping", "verified", "captions", "windowsRestored", "prepSummary")}} # 0.9.199 (wiki issue #39, John): `active` used to be a BOOLEAN here and # an ARRAY in kicad_progress - the same key, two types, from one bridge. # A renderer written as `if (resp.active)` then treats a finished job as @@ -1909,6 +2255,7 @@ # Warm eeschema only when starting the tour (Symbol Editor is hosted inside it, # and a cold WM_COMMAND misses its 10 s budget). Later beats reuse the warm app. if run_all or steps[0] == STEPS[0]: + prep["cacheRestart"] = _restart_for_new_footprint(kicad_info, prep) prep["warm"] = _warm_kicad(kicad_info, prep) import time --- master/handlers/kicad_ui.py +++ release/handlers/kicad_ui.py @@ -573,6 +573,84 @@ return None +def _canvas_content(hwnd: int) -> dict | None: + """Where the DRAWN content sits on the GL canvas, in SCREEN pixels. + + Same GDI grab as _canvas_probe. Background = the most common sampled colour + on the canvas; content = samples that differ from it by more than a small + tolerance. Returns {canvas:(l,t,r,b), bbox:(l,t,r,b)|None, coverage:0..1, + touches:[edges]} so a demo can aim its zoom at the part it is talking about + and notice when a pan or a rotation pushed the content off the view + (John, 2026-09-05: "don't be dumb about your zooming or panning").""" + try: + rect = _get_window_rect(hwnd) + width, height = rect["width"], rect["height"] + if width <= 0 or height <= 0: + return None + hdc_screen = user32.GetDC(hwnd) + hdc_mem = gdi32.CreateCompatibleDC(hdc_screen) + hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height) + old_bmp = gdi32.SelectObject(hdc_mem, hbmp) + user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT) + class _BIH(ctypes.Structure): + _fields_ = [("biSize", ctypes.wintypes.DWORD), ("biWidth", ctypes.wintypes.LONG), + ("biHeight", ctypes.wintypes.LONG), ("biPlanes", ctypes.wintypes.WORD), + ("biBitCount", ctypes.wintypes.WORD), ("biCompression", ctypes.wintypes.DWORD), + ("biSizeImage", ctypes.wintypes.DWORD), ("biXPelsPerMeter", ctypes.wintypes.LONG), + ("biYPelsPerMeter", ctypes.wintypes.LONG), ("biClrUsed", ctypes.wintypes.DWORD), + ("biClrImportant", ctypes.wintypes.DWORD)] + bmi = _BIH(); bmi.biSize = ctypes.sizeof(_BIH) + bmi.biWidth, bmi.biHeight, bmi.biPlanes = width, -height, 1 # top-down rows + bmi.biBitCount, bmi.biCompression = 32, BI_RGB + pixel_buf = ctypes.create_string_buffer(width * height * 4) + gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS) + buf = pixel_buf.raw + gdi32.SelectObject(hdc_mem, old_bmp); gdi32.DeleteObject(hbmp); gdi32.DeleteDC(hdc_mem) + user32.ReleaseDC(hwnd, hdc_screen) + gl = _gl_canvas_rect(hwnd) + if not gl: + return None + wl, wt = rect.get("x", rect.get("left", 0)), rect.get("y", rect.get("top", 0)) + l, t, r, b = gl # relative to the window box + ins_x, ins_y = int((r - l) * 0.03), int((b - t) * 0.03) + x0, x1, y0, y1 = max(0, l + ins_x), min(width, r - ins_x), max(0, t + ins_y), min(height, b - ins_y) + if x1 <= x0 or y1 <= y0: + return None + n = 140 + sx, sy = max(1, (x1 - x0) // n), max(1, (y1 - y0) // n) + samples = [] + for y in range(y0, y1, sy): + row = y * width * 4 + for x in range(x0, x1, sx): + o = row + x * 4 + samples.append((x, y, buf[o], buf[o + 1], buf[o + 2])) + if not samples: + return None + from collections import Counter + q = Counter(((bb >> 4, g >> 4, rr >> 4) for _, _, bb, g, rr in samples)) + bg = q.most_common(1)[0][0] + def is_bg(bb, g, rr): + return abs((bb >> 4) - bg[0]) <= 1 and abs((g >> 4) - bg[1]) <= 1 and abs((rr >> 4) - bg[2]) <= 1 + # grid dots are sparse single samples: ignore samples with no content neighbour + cont = [(x, y) for x, y, bb, g, rr in samples if not is_bg(bb, g, rr)] + cov = len(cont) / len(samples) + if not cont: + return {"canvas": (wl + l, wt + t, wl + r, wt + b), "bbox": None, "coverage": 0.0, "touches": []} + xs = sorted(x for x, _ in cont); ys = sorted(y for _, y in cont) + # trim 2% outliers (stray grid/axis pixels) before taking the box + k = max(0, int(len(xs) * 0.02)) + bx0, bx1, by0, by1 = xs[k], xs[-1 - k], ys[k], ys[-1 - k] + touches = [] + if bx0 <= x0 + 2 * sx: touches.append("left") + if bx1 >= x1 - 2 * sx: touches.append("right") + if by0 <= y0 + 2 * sy: touches.append("top") + if by1 >= y1 - 2 * sy: touches.append("bottom") + return {"canvas": (wl + l, wt + t, wl + r, wt + b), + "bbox": (wl + bx0, wt + by0, wl + bx1, wt + by1), "coverage": round(cov, 3), "touches": touches} + except Exception: # pylint: disable=broad-except + return None + + def _screenshot_hwnd(hwnd: int, label: str = "", max_dim: int = 1568) -> dict: """Capture a window by HWND. AD core first, local PrintWindow as fallback. --- master/handlers/kicad_windows.py +++ release/handlers/kicad_windows.py @@ -53,7 +53,11 @@ r = ad_client.call("desktop_list_windows", {}, timeout=6) or {} except Exception: # pylint: disable=broad-except return None - out = r.get("output") if isinstance(r, dict) else None + # The direct API hands the verb payload back UNWRAPPED (top-level + # `windows`; measured on arav-rog 2026-09-05, where this returned None and + # the tour fell back to a local list with no z-order, so no beat could + # verify or foreground). Older shapes wrap it in output / data. Accept all. + out = r.get("output") if isinstance(r, dict) and "output" in r else r if isinstance(out, str): try: import json as _j @@ -62,6 +66,8 @@ return None data = (out or {}).get("data") if isinstance(out, dict) else None wins = (data or {}).get("windows") if isinstance(data, dict) else None + if not isinstance(wins, list) and isinstance(out, dict): + wins = out.get("windows") if not isinstance(wins, list): return None rows = [] @@ -78,6 +84,9 @@ "width": rect.get("width", 0), "height": rect.get("height", 0)}, "z": w.get("z"), "kind": w.get("kind"), + # carried through so 'in front' can skip minimized windows and shell popups + "minimized": bool(w.get("minimized")), "maximized": bool(w.get("maximized")), + "image": w.get("image") or "", "pid": w.get("pid"), }) return rows --- master/handlers/install_footprint.py +++ release/handlers/install_footprint.py @@ -179,7 +179,17 @@ # The .kicad_mod file must be named to match the footprint name inside it dest_file = pretty_dir / f"{footprint_name}.kicad_mod" - dest_file.write_text(footprint_text, encoding="utf-8") + existed_before = dest_file.exists() # a NEW footprint needs a KiCad restart to be seen (process-wide cache, #47) + # Identical content: leave the file alone. Rewriting it bumps the mtime past + # KiCad's cache load and the opener then reports "cache predates the install" + # on every tour after the first (arav-rog 2026-09-05, footprint beat failed). + unchanged = False + try: + unchanged = existed_before and dest_file.read_text(encoding="utf-8") == footprint_text + except Exception: # pylint: disable=broad-except + unchanged = False + if not unchanged: + dest_file.write_text(footprint_text, encoding="utf-8") # --- Step 3: Register in fp-lib-table --- @@ -246,6 +256,8 @@ "success": True, "output": " | ".join(output_parts), "installedPath": str(dest_file), + "existedBefore": existed_before, + "unchanged": unchanged, "footprintName": footprint_name, "libraryName": ADOM_FP_LIB_NAME, "editorOpened": editor_opened,