app
KiCad - the KiCad Bridge
Public Made by Adomby adom
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.
← Commit history
kicad_autoroute job mode: start with job:true, poll with job:<id>; routing prompt draft for Astra
4 files changed
+506−25
docs/esc-routing-astra-prompt.mdadded+25@@ -0,0 +1,25 @@+# Prompt for Codex Astra: route the placed ESC G431, on camera, and report the numbers++Paste everything below the line into Codex (GPT-6 Astra) in Hydrogen. Astra routes the placed ESC G431 board through the KiCad Bridge's IPC routing verbs, one net per commit, while the desktop records, and files a wiki issue with the video and the numbers so the take goes on the routing chart next to Freerouting's and Claude Fable 5.1's.++---++You are GPT-6 Astra in Codex inside Hydrogen. John wants a screen recording of you routing a real board: the Adom ESC G431 (STM32G431, DRV8300 gate driver, six MOSFET 3-phase bridge, TPSM365 buck, INA181 current sense, 64 x 74 mm, four copper layers), already placed, with no copper on it except two full-board planes: GND on In1.Cu and +3V3 on In2.Cu. You plan every trace yourself and land it through the bridge as native undo steps. Do all of it yourself; do not ask John to do any step you can do.++Read first:+- `adom-wiki pkg install adom/kicad-bridge`, then the kicad-bridge SKILL.md sections "Live routing" and "Placement", and the kicad-autorouting skill. The routing verbs: `kicad_routing_state {filePath}` (revision, pads with net and layer, existing copper, netsRemaining), `kicad_route_net {filePath, expectedRevision, net, points|paths, width, dryRun?}` (one native undo step per call; `points` is a list of pad refs like "U5.12" and waypoints `[x, y]` or `{"x":..,"y":..,"layer":"B.Cu"}`, a layer change inside the list means a via there; `paths` is a list of such lists for a branched net; a clearance hit answers `drc_rejected` and nothing lands; a stale revision answers `stale_board`), `kicad_remove_route {filePath, itemIds}`, `kicad_routing_validate {filePath}` (KiCad DRC on the live board: errors, unconnected, violations). The demo notes: https://wiki.adom.inc/adom/kicad-bridge/files/docs/esc-demo.md++The fixture (public): `curl -sLo esc-g431-fable-placed-planes.kicad_pcb https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb`. Send it to the desktop with `adom-bridge --ai-thread "<your thread name>" --target arav-rog send_files {"filePaths":["<container path>"],"dest":"C:/Users/arav/Downloads/adom-gate/esc-demo/astra-routing","reason":"..."}`. Work on THAT copy only. It is Claude Fable 5.1's placement, so the three engines route the same board.++Rules that are not optional:+1. Desktop arav-rog, Windows user arav. Confirm `kicad_status` says kicad-bridge 1.0.3 or newer and KiCad 10.0.1 or newer, and `kicad_ipc_api {}` says enabled. Pass `--ai-thread` on every call. If KiCad is running, `kicad_close {}` first and stop if it reports unsaved work.+2. Never save the board, never touch any other board, never kill KiCad, do not install or run Freerouting (this take is the AI engine only; Freerouting gets its own take).+3. Get the board on camera the way the placement take did: `kicad_open_board {"filePath": B, "foreground": true, "foregroundReason": "John asked for a screen recording of Astra routing the ESC on the test box"}`, maximize with `desktop_ui_window {"hwnd": H, "action": "maximize"}`, wait until the status bar no longer says "Loading Footprint Libraries", `kicad_send_key {"hwnd": H, "key": "ctrl+home"}`, and about 25 s after opening `desktop_bring_to_front {"hwnd": H, "reason": "..."}` once. Confirm with `desktop_screenshot_screen {}` that the board is visible and the canvas is not white. (On a user's own box you would instead switch KiCad to its fallback canvas and record the window in the background; that recipe is in the placement prompt on the page.)+4. Read `kicad_routing_state` and plan the routing yourself. Rules of the board: track 0.25 mm and clearance 0.2 mm by default (read the file's setup and netclass blocks and use them), via 0.8 mm with a 0.4 mm drill; signal traces on F.Cu and B.Cu with vias between them; the three phase nets (/DRV_SHA, /DRV_SHB, /DRV_SHC), +VBAT and the MOSFET drain and source connections want 1.0 mm tracks where the space allows and at least 0.5 mm; GND and +3V3 pads are NOT routed as traces: each such SMD pad gets a short stub and a via to its plane (In1.Cu for GND, In2.Cu for +3V3), thru-hole pads already reach the planes. Keep the current sense path (shunt to INA) short and paired. Route the short nets first. A `drc_rejected` means your plan crossed something: change the plan, do not force it. Write your own router or plan by hand; do not copy Claude's plan (it is on the page).+5. Record before the first commit: `desktop_record_start {"monitor": 0, "audio": false, "fps": 30, "reason": "Astra routing the ESC G431", "maxDurationMs": 1800000}`. Then land the plan with `kicad_route_net`, one net per call, passing the previous reply's revision as `expectedRevision`, about a second between calls so a viewer can follow. Do not speed anything up.+6. Finish with `kicad_routing_validate`: it must report the errors and unconnected count truthfully; the goal is 0 errors and 0 unconnected (the 13 inherited library errors on this board, six malformed MOSFET courtyards and seven U2 pad clearances, are known and do not count). If something stays unconnected, say which nets and why. Then `desktop_record_stop`, `pull_file` the MP4, check frames at the start, middle and end, transcode to 1920 wide H.264 (`ffmpeg -i in.mp4 -vf scale=1920:-2 -c:v libx264 -crf 22 -pix_fmt yuv420p -movflags +faststart -an out.mp4`), make a poster, push both to the adom/codex page under docs/videos/ (`adom-wiki repo push adom/codex --files ...`), and confirm the MP4 URL returns 200.+7. The numbers, all of them, in the issue, in the shape of https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/demo/placement/fable/fable-stats.json: (a) planning time, first `kicad_routing_state` to first `kicad_route_net`, wall clock; (b) routing time, first commit to last, wall clock, and the number of commits, nets routed, segments, vias and total copper length in mm; (c) the validate result: errors, unconnected, violation types; (d) your session's token usage for this task (input, cached input, output) and the model name; (e) the dollar cost at OpenAI's published per-token price for the model (state the price and where it is published) and that cost as a percentage of a $200 per month plan; (f) sub-agents or worker threads spawned (zero is fine); (g) the bridge calls with timestamps; (h) what you rejected or retried, including any take you discarded.+8. File the issue: `adom-wiki issue create adom/kicad-bridge --category show-and-tell --title "Astra routing video: ESC G431 through kicad_route_net" --body "<video URL, poster URL, the numbers in 7, the exact commands>"`. No em dashes.+9. Close KiCad with `kicad_close {"discardChanges": true}` and tell John the issue URL and the video URL.++The kicad-bridge maintainer thread watches that issue tracker and will cut your take into the routing video and put your numbers on the chart next to Freerouting's and Claude Fable 5.1's.
docs/rust-port-plan.md+1@@ -24,6 +24,7 @@ Written 2026-09-11 from a full audit of the 0.9.350 source, the Adom Bridge (ab) | 2026-09-12 | Freerouting, on the user's terms. `kicad_freerouting {status|install|uninstall}` and the `freerouting` engine of `kicad_autoroute` shipped in 1.0.1. The bridge writes the Specctra DSN itself, runs Freerouting's own self-contained bundle headless (its Java runtime lives inside that folder; nothing installed on the PC, no PATH, no UAC: the MSI is extracted with an administrative extract into a fresh subfolder of the bridge cache), reads the SES back and lands the copper as one native undo step per net through the IPC API, or into the file with a backup, then a DRC. Install runs as a background job (ab's request budget is shorter than an 88 MB download on a slow link) and status reports its progress. Verified on ConfRoomROG: install 6 s, six-net fixture routed in 2 passes and 3 s, file apply DRC clean, live apply six undo steps in 4.6 s at zero errors and zero unconnected, uninstall freed 147 MB. Two Windows facts learned the hard way: msiexec property values with spaces need the quotes inside the token or msiexec waits forever on its usage box, and the administrative image must go to an empty folder that does not hold the package or msiexec returns 1603. The `kicad-autorouting` skill carries the choice: the AI engine is the recommendation, Freerouting is offered every time and installed only when the user says yes. | | 2026-09-12 | Placement verbs, the same way the routing verbs work. `kicad_placement_state` (every footprint with its courtyard box in board coordinates, side, pose, pads and nets, the outline, per-net footprint clusters and an MST ratsnest estimate), `kicad_move_footprint` (one footprint or a batch through `update_items`, ONE native undo step named "Adom: place U1", after the revision check, a same-side courtyard collision check that names the offending refs, and the outline check; `dryRun` reports without writing; a side change flips through the layer field, which is how KiCad's `FOOTPRINT::Deserialize` flips) and `kicad_placement_validate` (all-pairs courtyard overlaps, outside and parked footprints, the ratsnest total, KiCad DRC on the live snapshot filtered to the placement violation types with the routing verbs' truncation flags). Live when the PCB editor has the board on the IPC API; the reads and a dryRun answer from the file otherwise. Core geometry in `kicad-core/src/placement.rs` (courtyard extraction added to the PCB parser), IPC glue beside the routing operations in `ipc.rs`, the verb group in `verbs_placement.rs`. The demo fixture is the public Adom ESC G431 board: `tools/make_placement_fixture.py` (standard-library Python, a byte-preserving s-expression edit) writes an unplaced copy (149 footprints parked in a grid to the right of the outline, 434 segments, 384 vias, 57 zone fills and 66 teardrop zones removed, the 49 user zones kept) and an unrouted copy, under `demo/placement/`; both pass service-kicad DRC (KiCad 10.0.2: zero courtyard overlaps in the parked copy, 271 unconnected items as expected, the source board's own six malformed courtyards and seven intra-footprint pad clearances inherited) and the bridge's parser (integration test). 241 tests. Runner: placement_state and placement_validate in phase 2, move_footprint dryRun in phase 2.6. Live verification on a desktop is pending (John runs it). | | 2026-09-12 | Live test of `kicad_move_footprint` on KiCad 10.0.3 found the anchor moving without the body: pads and courtyard stayed in the parking lot while `verified` said true. Root cause in KiCad's `api_handler_pcb.cpp` and `footprint.cpp` (10.0 branch): `UpdateItems` does not edit a footprint in place, it builds a new one from our FootprintInstance proto, removes the old one and adds the new one, and `FOOTPRINT::Deserialize` takes every child (pads, fields, shapes, texts, zones, dimensions) at the absolute board coordinates and angles the proto carries. Fix in 1.0.2's tree: `build_footprint_updates` now applies the move's rigid-body transform to every child client-side (p' = N + R(delta)(p - O), angles + delta, KiCad's y-down RotatePoint), the four mandatory fields through the typed proto and every `definition.items` payload on the protobuf wire format by field number (`placement::transform_child`; the crate keeps the proto types private), covering Pad, BoardText, Field, BoardTextBox, BoardGraphicShape (segment, rectangle, arc, circle, polygon with arc nodes, bezier; an off-cardinal rectangle becomes a polygon as KiCad's own rotate does), Zone (outline and fills), Dimension (all five styles, with KiCad's orthogonal axis rule), ReferenceImage and Barcode; Group and Footprint3DModel pass through; anything else refuses the move with `unsupported_child_item`. Side changes are refused with `side_change_not_supported` until a mirror lands. The post-commit check now compares the read-back courtyard box centre (or the first pad) with the expected transformed value within 0.01 mm and reports `children did not move` with the numbers instead of trusting the anchor. 210 core tests. Live re-test on a desktop pending (John runs it). |+| 2026-09-12 | `kicad_autoroute` as a background job. A real board takes minutes with Freerouting (a 59-net four-layer ESC: 40 passes over several minutes), the ab relay gives up around 130 s, and the reply of a timed-out synchronous call was lost even though the bridge finished the work. Now `kicad_autoroute {"engine":"freerouting","job":true}` validates exactly as the synchronous call does (not installed, bad apply, board open, no ipc feature), starts the same work (DSN, Freerouting, live or file apply, DRC) on its own thread and answers at once with `{status:"ok", job:"<id>", engine, started}`; `kicad_autoroute {"job":"<id>"}` (no engine) answers `running` with the live `stepLabel` and `percent` (mirrored from the progress registry, so `kicad_status.operations` shows the same frame), `done` with the complete routing reply merged in (routed, applied, drc, freerouting, dsn, revision, undoSteps, itemIds) or `failed` with the error reply; finished jobs stay pollable for an hour, then `job_not_found`. One job per board at a time (`job_busy` names the running id; the synchronous path refuses too). The job start and the poll run without GUI_LOCK (dispatch exempts them the way it exempts `kicad_demo`); the job thread takes GUI_LOCK itself around the editor sections (live snapshot, commit, live DRC) and holds nothing while Freerouting runs, so window verbs and polls are never queued behind a routing pass. The synchronous call is unchanged for a direct caller (timeout 900 s); the verb's hint and pitfalls now say to use `job:true` over the relay. Six unit tests on the job table (insert and poll, done and failed replies, expiry, busy per board, job mode parsing, unknown id). Live run on a desktop pending (John runs it). | ## The goal in one sentence
rust/crates/kicad-bridge/src/verbs.rs+7−1@@ -30,8 +30,14 @@ pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Cal // kicad_demo is the one Window verb that takes the lock ITSELF: its background job holds // GUI_LOCK per beat and releases it between beats, and its start, progress and control // calls must answer while a beat is in flight (the dashboard polls it every 2-3 s).+ // kicad_autoroute with `job` is the Ipc verb that does the same: job:true only validates+ // the arguments and spawns, the poll only reads the job table, and the job thread takes+ // GUI_LOCK itself around the sections that talk to the editor (live snapshot, commit,+ // live DRC), never while Freerouting runs, so polls and window verbs are not queued for+ // minutes behind a routing pass. Without `job` the verb runs synchronously under the lock.+ let takes_own_lock = command == "kicad_demo" || (command == "kicad_autoroute" && !matches!(crate::verbs_routing::job_mode(args), crate::verbs_routing::JobMode::Sync)); let _serial = match verb.mechanism {- catalog::Mechanism::Window | catalog::Mechanism::Ipc if command != "kicad_demo" => Some(GUI_LOCK.lock().unwrap_or_else(|e| e.into_inner())),+ catalog::Mechanism::Window | catalog::Mechanism::Ipc if !takes_own_lock => Some(GUI_LOCK.lock().unwrap_or_else(|e| e.into_inner())), _ => None, }; // The ab callback client forwards this request's caller identity (headers first, args.caller as fallback).
rust/crates/kicad-bridge/src/verbs_routing.rs+473−24@@ -7,7 +7,7 @@ //! `_VERB_CATALOG` plus the autoroute entry from docs/rust-port-plan.md. use std::path::PathBuf;-use std::time::Duration;+use std::time::{Duration, Instant}; use serde_json::{json, Value}; @@ -101,11 +101,11 @@ pub static VERBS: &[Verb] = &[ name: "kicad_autoroute", summary: "Route a board with the engine the USER picked: \"ai\" (recommended) returns the plan request you drive with kicad_route_net; \"freerouting\" runs Freerouting headless (installed on demand) and lands its copper live as one undo step per net, or into the closed file.", mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 900,- input: "{\"engine\": \"ai\" | \"freerouting\", \"filePath\": \"C:/.../x.kicad_pcb\", \"nets\"?: [\"GND\", ...], \"dryRun\"?: bool, \"passes\"?: 20, \"threads\"?: n, \"timeoutSec\"?: 600, \"apply\"?: \"file\" | \"ipc\", \"backup\"?: true}",+ input: "{\"engine\": \"ai\" | \"freerouting\", \"filePath\": \"C:/.../x.kicad_pcb\", \"nets\"?: [\"GND\", ...], \"dryRun\"?: bool, \"passes\"?: 20, \"threads\"?: n, \"timeoutSec\"?: 600, \"apply\"?: \"file\" | \"ipc\", \"backup\"?: true, \"job\"?: true | \"<id>\"}", example: "kicad_autoroute {\"engine\":\"ai\",\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",- hint: "ASK THE USER WHICH ENGINE THEY WANT BEFORE ROUTING and name both every time. ai (recommended): any capable model can drive kicad_route_net (GPT-6 Astra in Codex has routed a 94-footprint board to zero unconnected items this way, Claude Fable 5.1 can, newer models will); the routing verbs give it pads, nets, zones, a live revision, one native undo step per trace and DRC per step, so it honours the schematic's intent and can explain every trace. freerouting: deterministic, installed on demand (88 MB download, bundled Java, no elevation), kicad_freerouting {\"action\":\"status\"} says whether it is present; not installed answers freerouting_not_installed with the install call, and you ask the user before installing. Report which engine produced the copper.",+ hint: "ASK THE USER WHICH ENGINE THEY WANT BEFORE ROUTING and name both every time. ai (recommended): any capable model can drive kicad_route_net (GPT-6 Astra in Codex has routed a 94-footprint board to zero unconnected items this way, Claude Fable 5.1 can, newer models will); the routing verbs give it pads, nets, zones, a live revision, one native undo step per trace and DRC per step, so it honours the schematic's intent and can explain every trace. freerouting: deterministic, installed on demand (88 MB download, bundled Java, no elevation), kicad_freerouting {\"action\":\"status\"} says whether it is present; not installed answers freerouting_not_installed with the install call, and you ask the user before installing. Report which engine produced the copper. Over the relay use job:true and poll; the relay times out around 130 s while the bridge keeps working, and the result of a timed-out synchronous call is not retrievable: kicad_autoroute {\"engine\":\"freerouting\",\"job\":true} answers at once with a job id, kicad_autoroute {\"job\":\"<id>\"} reports running (stepLabel, percent), done (the full routing reply) or failed, kept for an hour. A direct caller may still wait synchronously (timeout 900 s).", related: &["kicad_freerouting", "kicad_routing_state", "kicad_route_net", "kicad_routing_validate", "kicad_board_pads"],- pitfalls: &["Never pick the engine for the user; the hint makes you ask (same pattern as foregroundReason).", "engine ai mutates nothing by itself: the copper lands through kicad_route_net calls you make afterwards.", "engine freerouting never installs anything by itself: freerouting_not_installed means ask, then kicad_freerouting {\"action\":\"install\"}, then call again.", "engine freerouting applies live (apply ipc: the board open in the PCB editor with the IPC API on, one native undo step per net named Freerouting <net>) or to the closed file (apply file, .adom-bak beside it); auto picks ipc when the board is open, file otherwise.", "Oval, roundrect and custom pads reach the router as their bounding rect (conservative); dsn.approximatedPads lists them."],+ pitfalls: &["Never pick the engine for the user; the hint makes you ask (same pattern as foregroundReason).", "engine ai mutates nothing by itself: the copper lands through kicad_route_net calls you make afterwards.", "engine freerouting never installs anything by itself: freerouting_not_installed means ask, then kicad_freerouting {\"action\":\"install\"}, then call again.", "engine freerouting applies live (apply ipc: the board open in the PCB editor with the IPC API on, one native undo step per net named Freerouting <net>) or to the closed file (apply file, .adom-bak beside it); auto picks ipc when the board is open, file otherwise.", "Oval, roundrect and custom pads reach the router as their bounding rect (conservative); dsn.approximatedPads lists them.", "Over the relay use job:true and poll; the relay times out around 130 s while the bridge keeps working, and the result of a timed-out synchronous call is not retrievable. A real board takes minutes (a 59-net four-layer board: 40 passes), so job:true is the default choice over ab.", "One autoroute job per board at a time: job_busy names the running id, poll it to completion (or failure) before starting another. The poll takes no engine; a finished job answers for an hour, then job_not_found."], }, Verb { name: "kicad_freerouting",@@ -482,7 +482,28 @@ fn engines() -> Value { ]) } +/// How `job` was given: absent or false (synchronous), `true` (start a background job) or+/// a non-empty id (poll that job). Dispatch asks too: a job start and a poll never touch+/// the editor, so they run without GUI_LOCK.+pub enum JobMode {+ Sync,+ Start,+ Poll(String),+}++pub fn job_mode(args: &Value) -> JobMode {+ match args.get("job") {+ Some(Value::Bool(true)) => JobMode::Start,+ Some(Value::String(s)) if !s.trim().is_empty() => JobMode::Poll(s.trim().to_string()),+ _ => JobMode::Sync,+ }+}+ fn autoroute(state: &mut State, args: &Value) -> Value {+ let mode = job_mode(args);+ if let JobMode::Poll(id) = &mode {+ return autoroute_job_poll(id);+ } let engine = arg_str(args, "engine").map(|s| s.trim().to_ascii_lowercase()); let dry_run = args.get("dryRun") == Some(&json!(true)); match engine.as_deref() {@@ -493,7 +514,10 @@ fn autoroute(state: &mut State, args: &Value) -> Value { v } Some("ai") => autoroute_ai(state, args, dry_run),- Some("freerouting") => autoroute_freerouting(state, args, dry_run),+ Some("freerouting") => match mode {+ JobMode::Start => autoroute_job_start(args, dry_run),+ _ => autoroute_freerouting(state, args, dry_run),+ }, Some(other) => { let mut v = fail("unknown_engine", format!("unknown engine '{other}'"), format!("engine is \"ai\" (recommended) or \"freerouting\". {ASK_BOTH}")); v["engines"] = engines();@@ -590,19 +614,33 @@ fn autoroute_ai(state: &mut State, args: &Value, dry_run: bool) -> Value { // --------------------------------------------------------------------------- /// A live progress frame for the caller; ends when dropped (every early return included).+/// A frame that belongs to a background job mirrors every step into the job table too, so+/// the poll and `kicad_status.operations` show the same label and percent. struct Phase { phase: &'static str, caller: String,+ job: Option<String>, } impl Phase { fn begin(phase: &'static str, label: &str) -> Phase {- let caller = progress::caller_name();+ Phase::begin_as(phase, label, progress::caller_name(), None)+ }+ /// The caller is captured by whoever starts the frame: the ab caller identity is+ /// process-wide, so a job thread must not read it later (another request may have+ /// replaced it by then).+ fn begin_as(phase: &'static str, label: &str, caller: String, job: Option<String>) -> Phase { progress::begin_phase(phase, "warm", label, &caller);- Phase { phase, caller }+ if let Some(id) = &job {+ jobs().step(id, label, None);+ }+ Phase { phase, caller, job } } fn step(&self, label: &str, percent: Option<i64>) { progress::step_for(&self.caller, self.phase, label, percent, None);+ if let Some(id) = &self.job {+ jobs().step(id, label, percent);+ } } } @@ -701,15 +739,28 @@ fn norm_path(p: &std::path::Path) -> String { p.to_string_lossy().replace('\\', "/") } -/// Engine `freerouting`: board -> DSN -> Freerouting -> SES -> copper applied live (one-/// undo step per net) or into the closed file, then DRC. Not installed: the install call-/// and a hint that says ask the user; nothing is downloaded here.-fn autoroute_freerouting(state: &mut State, args: &Value, dry_run: bool) -> Value {+/// Everything `engine: freerouting` checks before any work. One function for the+/// synchronous call and the background job, so `job:true` refuses with exactly the errors+/// the synchronous call would (not installed, bad apply, board open, no ipc feature).+struct Prepared {+ exe: PathBuf,+ passes: u32,+ threads: Option<u32>,+ timeout_sec: u64,+ wanted: Option<Vec<String>>,+ loaded: pcb::Loaded,+ use_ipc: bool,+ live_args: Value,+ backup: bool,+ dry_run: bool,+}++fn freerouting_prepare(args: &Value, dry_run: bool) -> Result<Prepared, Value> { let status = freerouting::status(); let Some(exe) = status.exe.clone() else { let mut v = freerouting::not_installed(&status); v["engines"] = engines();- return v;+ return Err(v); }; let passes = args.get("passes").and_then(Value::as_u64).map(|p| p.clamp(1, 999) as u32).unwrap_or(freerouting::DEFAULT_PASSES); let threads = args.get("threads").and_then(Value::as_u64).map(|t| t.clamp(1, 64) as u32);@@ -718,13 +769,10 @@ fn autoroute_freerouting(state: &mut State, args: &Value, dry_run: bool) -> Valu let apply_arg = arg_str(args, "apply").map(|s| s.trim().to_ascii_lowercase()); if let Some(a) = &apply_arg { if a != "file" && a != "ipc" {- return fail("bad_apply", format!("apply must be \"file\" or \"ipc\", not '{a}'"), "apply file writes the closed board with a backup; apply ipc commits live into the open PCB editor. Omit it and the bridge picks ipc when the board is open, file otherwise.");+ return Err(fail("bad_apply", format!("apply must be \"file\" or \"ipc\", not '{a}'"), "apply file writes the closed board with a backup; apply ipc commits live into the open PCB editor. Omit it and the bridge picks ipc when the board is open, file otherwise.")); } }- let loaded = match pcb::load(args, true) {- Ok(l) => l,- Err(e) => return e,- };+ let loaded = pcb::load(args, true)?; let board_open = pcb::lock_file(&loaded.path).is_some(); let use_ipc = match apply_arg.as_deref() { Some("ipc") => true,@@ -732,16 +780,15 @@ fn autoroute_freerouting(state: &mut State, args: &Value, dry_run: bool) -> Valu _ => board_open, }; if !use_ipc && board_open {- return json!({+ return Err(json!({ "success": false, "errorCode": "board_open", "engine": "freerouting", "mutated": false, "error": "This board is open in KiCad; apply:\"file\" would conflict with the editor", "_hint": "Let the bridge apply live instead (omit apply, or apply:\"ipc\": the PCB editor with the IPC API on takes the copper as one undo step per net), or close the board for a file apply.",- });+ })); } if use_ipc && !ipc_available() {- return fail("ipc_unavailable", "This build has the ipc feature off; a live apply needs the KiCad IPC client compiled in", "Close the board and call again with apply:\"file\", or use a build with the ipc feature.");+ return Err(fail("ipc_unavailable", "This build has the ipc feature off; a live apply needs the KiCad IPC client compiled in", "Close the board and call again with apply:\"file\", or use a build with the ipc feature.")); }- let phase = Phase::begin("autoroute", "reading the board"); // The board the router sees: the editor's own serialisation when applying live (unsaved // edits included), the file otherwise. let mut live_args = json!({"filePath": loaded.path.to_string_lossy()});@@ -750,8 +797,50 @@ fn autoroute_freerouting(state: &mut State, args: &Value, dry_run: bool) -> Valu live_args[k] = v.clone(); } }+ Ok(Prepared { exe, passes, threads, timeout_sec, wanted, loaded, use_ipc, live_args, backup: backup_wanted(args), dry_run })+}++/// Engine `freerouting`, synchronous: board -> DSN -> Freerouting -> SES -> copper applied+/// live (one undo step per net) or into the closed file, then DRC. Not installed: the+/// install call and a hint that says ask the user; nothing is downloaded here. Dispatch+/// holds GUI_LOCK for the whole call (Mechanism::Ipc); a direct caller may wait the 900 s,+/// the ab relay gives up around 130 s and the reply is then lost, which is what `job:true`+/// is for.+fn autoroute_freerouting(state: &mut State, args: &Value, dry_run: bool) -> Value {+ let prep = match freerouting_prepare(args, dry_run) {+ Ok(p) => p,+ Err(e) => return e,+ };+ let key = job_board_key(&prep.loaded.path);+ {+ let mut t = jobs();+ t.expire(Instant::now());+ if let Some(busy) = t.busy_reply(&key, Instant::now()) {+ return busy;+ }+ }+ let phase = Phase::begin("autoroute", "reading the board");+ freerouting_execute(state, prep, &phase, false)+}++/// GUI_LOCK for the sections of a job thread that talk to the editor. The synchronous+/// call passes `own = false`: dispatch already holds the lock and std's Mutex is not+/// reentrant.+fn gui_guard(own: bool) -> Option<std::sync::MutexGuard<'static, ()>> {+ own.then(|| GUI_LOCK.lock().unwrap_or_else(|e| e.into_inner()))+}++/// The work itself, shared by the synchronous call and the job thread. A job thread takes+/// GUI_LOCK only around the editor sections (live snapshot, live commit, live DRC) and+/// holds nothing while Freerouting runs for minutes, so window verbs, status polls and the+/// job poll are never queued behind a routing pass.+fn freerouting_execute(state: &mut State, prep: Prepared, phase: &Phase, own_gui_lock: bool) -> Value {+ let Prepared { exe, passes, threads, timeout_sec, wanted, loaded, use_ipc, live_args, backup, dry_run } = prep; let (text, board, mut revision): (String, pcb::Board, Option<String>) = if use_ipc {- match live_snapshot(state, &live_args) {+ let gui = gui_guard(own_gui_lock);+ let snap = live_snapshot(state, &live_args);+ drop(gui);+ match snap { Ok((t, b, r)) => (t, b, Some(r)), Err(e) => return e, }@@ -863,10 +952,12 @@ fn autoroute_freerouting(state: &mut State, args: &Value, dry_run: bool) -> Valu // ---- apply let apply_result = if use_ipc { phase.step(&format!("committing {} net(s) live", copper.len()), Some(90));- commit_live(state, &live_args, &copper, revision.take().unwrap_or_default())+ let gui = gui_guard(own_gui_lock);+ let r = commit_live(state, &live_args, &copper, revision.take().unwrap_or_default());+ drop(gui);+ r } else { phase.step("writing the board file", Some(90));- let backup = backup_wanted(args); match freerouting::apply_to_file(&loaded, &copper, backup) { Ok((bak, s, v)) => Ok(json!({"applied": "file", "backup": bak, "segments": s, "vias": v, "reloaded": false, "source": "file"})), Err(e) => Err(e),@@ -897,7 +988,9 @@ fn autoroute_freerouting(state: &mut State, args: &Value, dry_run: bool) -> Valu phase.step("DRC", Some(95)); let kicad_cli = state.kicad_info().primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()); response["drc"] = if use_ipc {+ let gui = gui_guard(own_gui_lock); let v = live(state, &live_args, Live::Validate);+ drop(gui); if v["success"] == json!(true) { json!({"available": true, "source": "live-editor-snapshot", "errors": v["errors"], "warnings": v["warnings"], "unconnected": v["unconnected"], "clean": v["clean"], "summary": v["summary"], "revision": v["revision"]}) } else {@@ -916,6 +1009,228 @@ fn autoroute_freerouting(state: &mut State, args: &Value, dry_run: bool) -> Valu response } +// ---------------------------------------------------------------------------+// Background autoroute jobs+// ---------------------------------------------------------------------------+//+// A real board takes minutes (a 59-net four-layer ESC: 40 passes over several minutes);+// the ab relay gives up around 130 s and the reply of a timed-out synchronous call is+// gone even though the bridge finished the work. `job:true` validates like the+// synchronous call, then runs the same work on its own thread and parks the full reply+// here, keyed by id, for an hour after it finishes. The thread reports through `Phase`,+// so the live frame in the progress registry (`kicad_status.operations`) and this table+// carry the same label and percent.++/// How long a finished job stays pollable.+const JOB_KEEP: Duration = Duration::from_secs(3600);++struct AutorouteJob {+ id: String,+ /// Normalised, lower-cased board path: the one-job-per-board key.+ board_key: String,+ board_path: String,+ engine: String,+ caller: String,+ started: Instant,+ started_at: f64,+ phase: String,+ step_label: String,+ percent: Option<i64>,+ result: Option<Value>,+ finished: Option<Instant>,+}++struct JobTable {+ jobs: Vec<AutorouteJob>,+ seq: u64,+}++static AUTOROUTE_JOBS: std::sync::Mutex<JobTable> = std::sync::Mutex::new(JobTable { jobs: Vec::new(), seq: 0 });++fn jobs() -> std::sync::MutexGuard<'static, JobTable> {+ AUTOROUTE_JOBS.lock().unwrap_or_else(|e| e.into_inner())+}++fn job_board_key(p: &std::path::Path) -> String {+ norm_path(p).to_ascii_lowercase()+}++fn epoch_now() -> f64 {+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0)+}++impl JobTable {+ /// Drop finished jobs older than JOB_KEEP. A running job never expires.+ fn expire(&mut self, now: Instant) {+ self.jobs.retain(|j| match j.finished {+ Some(f) => now.saturating_duration_since(f) < JOB_KEEP,+ None => true,+ });+ }++ fn get(&self, id: &str) -> Option<&AutorouteJob> {+ self.jobs.iter().find(|j| j.id == id)+ }++ fn running_for(&self, board_key: &str) -> Option<&AutorouteJob> {+ self.jobs.iter().find(|j| j.board_key == board_key && j.result.is_none())+ }++ /// Register a new job, or Err(the running job's id) when the board already has one.+ fn start(&mut self, board_key: &str, board_path: &str, engine: &str, caller: &str, now: Instant) -> Result<String, String> {+ if let Some(j) = self.running_for(board_key) {+ return Err(j.id.clone());+ }+ self.seq += 1;+ let suffix: String = kicad_core::schematic::new_uuid().chars().take(8).collect();+ let id = format!("autoroute-{}-{suffix}", self.seq);+ self.jobs.push(AutorouteJob {+ id: id.clone(),+ board_key: board_key.to_string(),+ board_path: board_path.to_string(),+ engine: engine.to_string(),+ caller: caller.to_string(),+ started: now,+ started_at: epoch_now(),+ phase: "autoroute".into(),+ step_label: "starting".into(),+ percent: Some(0),+ result: None,+ finished: None,+ });+ Ok(id)+ }++ /// Mirror of `progress::step_for`: the label replaces, the percent never goes backwards.+ fn step(&mut self, id: &str, label: &str, percent: Option<i64>) {+ if let Some(j) = self.jobs.iter_mut().find(|j| j.id == id && j.result.is_none()) {+ j.step_label = label.to_string();+ if let Some(p) = percent {+ j.percent = Some(p.max(j.percent.unwrap_or(0)));+ }+ }+ }++ /// Park the verb's full reply (success or error) and start the one-hour clock.+ fn complete(&mut self, id: &str, result: Value, now: Instant) {+ if let Some(j) = self.jobs.iter_mut().find(|j| j.id == id) {+ let ok = result.get("success") != Some(&json!(false));+ j.step_label = if ok { "done".into() } else { result.get("error").and_then(Value::as_str).unwrap_or("failed").chars().take(80).collect() };+ j.percent = Some(100);+ j.result = Some(result);+ j.finished = Some(now);+ }+ }++ /// The poll reply: `running` with the live frame, or the parked reply with `status`+ /// done or failed merged in.+ fn snapshot(&self, id: &str, now: Instant) -> Option<Value> {+ let j = self.get(id)?;+ let elapsed = j.finished.unwrap_or(now).saturating_duration_since(j.started).as_secs_f64().round();+ Some(match &j.result {+ None => json!({+ "success": true, "status": "running", "job": j.id, "engine": j.engine, "boardPath": j.board_path,+ "phase": j.phase, "stepLabel": j.step_label, "percent": j.percent,+ "elapsedSec": elapsed, "startedAt": j.started_at, "caller": j.caller,+ "statusVerb": "kicad_autoroute", "statusArgs": {"job": j.id},+ "_hint": "Still routing: poll again in 10 to 20 s. stepLabel and percent are the same frame kicad_status.operations shows. Nothing to report to the user yet beyond the pass count.",+ }),+ Some(r) => {+ let ok = r.get("success") != Some(&json!(false));+ let mut v = r.clone();+ v["status"] = json!(if ok { "done" } else { "failed" });+ v["job"] = json!(j.id);+ v["engine"] = json!(j.engine);+ v["boardPath"] = json!(j.board_path);+ v["jobElapsedSec"] = json!(elapsed);+ v["startedAt"] = json!(j.started_at);+ v["finishedAgoSec"] = json!(j.finished.map(|f| now.saturating_duration_since(f).as_secs_f64().round()));+ v["keptForSec"] = json!(JOB_KEEP.as_secs());+ v+ }+ })+ }++ /// `job_busy` for a board that already has a running job, else None.+ fn busy_reply(&self, board_key: &str, now: Instant) -> Option<Value> {+ let j = self.running_for(board_key)?;+ let mut v = fail("job_busy", format!("an autoroute job is already running on this board: {}", j.id), format!("One autoroute job per board at a time. Poll kicad_autoroute {{\"job\":\"{}\"}} until its status is done or failed, then call again.", j.id));+ v["job"] = json!(j.id);+ v["status"] = json!("running");+ v["engine"] = json!(j.engine);+ v["boardPath"] = json!(j.board_path);+ v["stepLabel"] = json!(j.step_label);+ v["percent"] = json!(j.percent);+ v["elapsedSec"] = json!(now.saturating_duration_since(j.started).as_secs_f64().round());+ v["mutated"] = json!(false);+ Some(v)+ }++ fn ids(&self) -> Value {+ json!(self.jobs.iter().map(|j| json!({"job": j.id, "status": match &j.result { None => "running", Some(r) if r.get("success") != Some(&json!(false)) => "done", Some(_) => "failed" }, "boardPath": j.board_path})).collect::<Vec<_>>())+ }+}++/// `job:true`: validate exactly like the synchronous call, register the job, run the+/// work on its own thread, answer at once with the id to poll.+fn autoroute_job_start(args: &Value, dry_run: bool) -> Value {+ let prep = match freerouting_prepare(args, dry_run) {+ Ok(p) => p,+ Err(e) => return e,+ };+ let board_path = norm_path(&prep.loaded.path);+ let key = job_board_key(&prep.loaded.path);+ let apply = if prep.use_ipc { "ipc" } else { "file" };+ let caller = progress::caller_name();+ let id = {+ let mut t = jobs();+ let now = Instant::now();+ t.expire(now);+ match t.start(&key, &board_path, "freerouting", &caller, now) {+ Ok(id) => id,+ Err(_) => return t.busy_reply(&key, now).unwrap_or_else(|| fail("job_busy", "an autoroute job is already running on this board", "Poll it, then call again.")),+ }+ };+ let thread_id = id.clone();+ let spawned = std::thread::Builder::new().name(format!("kicad-{id}")).spawn(move || {+ let phase = Phase::begin_as("autoroute", "reading the board", caller, Some(thread_id.clone()));+ let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {+ let mut state = State::new();+ freerouting_execute(&mut state, prep, &phase, true)+ }))+ .unwrap_or_else(|_| fail("handler_panicked", "the autoroute job thread panicked", "Read kicad_log_tail for the panic; the board may be partly routed (each committed net is its own undo step)."));+ drop(phase);+ jobs().complete(&thread_id, out, Instant::now());+ });+ if let Err(e) = spawned {+ let err = fail("spawn_failed", format!("could not start the autoroute thread: {e}"), "Call again without job:true, or retry.");+ jobs().complete(&id, err.clone(), Instant::now());+ return err;+ }+ json!({+ "success": true, "status": "ok", "job": id, "engine": "freerouting", "started": true,+ "boardPath": board_path, "apply": apply, "dryRun": dry_run, "mutated": false, "phase": "autoroute",+ "statusVerb": "kicad_autoroute", "statusArgs": {"job": id},+ "_hint": format!("Routing runs in the background. Poll kicad_autoroute {{\"job\":\"{id}\"}} every 10 to 20 s: status running carries stepLabel and percent (kicad_status.operations shows the same frame); status done carries the full routing reply (routed, applied, drc, freerouting, dsn, revision, undoSteps, itemIds); status failed carries the error reply. The result is kept for an hour after the job finishes."),+ })+}++/// `job:"<id>"`: the job's state.+fn autoroute_job_poll(id: &str) -> Value {+ let mut t = jobs();+ let now = Instant::now();+ t.expire(now);+ match t.snapshot(id, now) {+ Some(v) => v,+ None => {+ let mut v = fail("job_not_found", format!("no autoroute job '{id}'"), "Job ids come from kicad_autoroute {\"engine\":\"freerouting\",\"job\":true}; a finished job is kept for an hour, then its result is gone. `jobs` lists what the bridge still holds.");+ v["job"] = json!(id);+ v["jobs"] = t.ids();+ v+ }+ }+}+ #[cfg(feature = "ipc")] fn live_snapshot(state: &mut State, live_args: &Value) -> Result<(String, pcb::Board, String), Value> { use kicad_core::{detect, ipc};@@ -977,3 +1292,137 @@ fn commit_live(state: &mut State, live_args: &Value, copper: &[dsn::NetCopper], fn commit_live(_state: &mut State, _live_args: &Value, _copper: &[dsn::NetCopper], _revision: String) -> Result<Value, Value> { Err(fail("ipc_unavailable", "ipc feature off in this build", "Close the board and apply to the file.")) }+++#[cfg(test)]+mod tests {+ use super::*;++ fn table() -> JobTable {+ JobTable { jobs: Vec::new(), seq: 0 }+ }++ #[test]+ fn job_insert_and_poll_running() {+ let mut t = table();+ let now = Instant::now();+ let id = t.start("c:/x/board.kicad_pcb", "C:/x/board.kicad_pcb", "freerouting", "thread-1", now).unwrap();+ assert!(id.starts_with("autoroute-1-"));+ let v = t.snapshot(&id, now).unwrap();+ assert_eq!(v["status"], json!("running"));+ assert_eq!(v["success"], json!(true));+ assert_eq!(v["job"], json!(id));+ assert_eq!(v["engine"], json!("freerouting"));+ assert_eq!(v["boardPath"], json!("C:/x/board.kicad_pcb"));+ assert_eq!(v["phase"], json!("autoroute"));+ assert_eq!(v["stepLabel"], json!("starting"));+ assert_eq!(v["percent"], json!(0));+ assert_eq!(v["caller"], json!("thread-1"));+ assert_eq!(v["statusArgs"], json!({"job": id}));+ // Steps mirror the registry contract: label replaces, percent never goes backwards.+ t.step(&id, "Freerouting pass 3 of up to 20", Some(18));+ t.step(&id, "Freerouting pass 3 of up to 20 (again)", Some(10));+ let v = t.snapshot(&id, now + Duration::from_secs(42)).unwrap();+ assert_eq!(v["stepLabel"], json!("Freerouting pass 3 of up to 20 (again)"));+ assert_eq!(v["percent"], json!(18));+ assert_eq!(v["elapsedSec"], json!(42.0));+ assert!(t.snapshot("autoroute-9-nope", now).is_none());+ }++ #[test]+ fn job_complete_done_and_failed() {+ let mut t = table();+ let t0 = Instant::now();+ let id = t.start("c:/x/a.kicad_pcb", "C:/x/a.kicad_pcb", "freerouting", "th", t0).unwrap();+ let reply = json!({"success": true, "engine": "freerouting", "mutated": true, "routed": {"netCount": 59}, "applied": "ipc", "drc": {"clean": true}, "freerouting": {"passes": 40}, "dsn": {"stats": {}}, "revision": "abc", "undoSteps": 59, "itemIds": 812, "_hint": "Freerouting routed 59 net(s)"});+ t.complete(&id, reply, t0 + Duration::from_secs(300));+ // A step after completion changes nothing.+ t.step(&id, "late", Some(1));+ let v = t.snapshot(&id, t0 + Duration::from_secs(360)).unwrap();+ assert_eq!(v["status"], json!("done"));+ assert_eq!(v["success"], json!(true));+ assert_eq!(v["job"], json!(id));+ for k in ["routed", "applied", "drc", "freerouting", "dsn", "revision", "undoSteps", "itemIds", "_hint", "mutated"] {+ assert!(v.get(k).is_some(), "done reply keeps {k}");+ }+ assert_eq!(v["undoSteps"], json!(59));+ assert_eq!(v["jobElapsedSec"], json!(300.0));+ assert_eq!(v["finishedAgoSec"], json!(60.0));+ assert_eq!(v["keptForSec"], json!(3600));+ assert_eq!(t.get(&id).unwrap().step_label, "done");+ assert_eq!(t.get(&id).unwrap().percent, Some(100));++ let id2 = t.start("c:/x/b.kicad_pcb", "C:/x/b.kicad_pcb", "freerouting", "th", t0).unwrap();+ t.complete(&id2, fail("freerouting_timeout", "Freerouting did not finish within 600 s", "Raise timeoutSec"), t0 + Duration::from_secs(600));+ let v = t.snapshot(&id2, t0 + Duration::from_secs(601)).unwrap();+ assert_eq!(v["status"], json!("failed"));+ assert_eq!(v["success"], json!(false));+ assert_eq!(v["errorCode"], json!("freerouting_timeout"));+ assert_eq!(v["job"], json!(id2));+ assert_eq!(t.get(&id2).unwrap().step_label, "Freerouting did not finish within 600 s");+ let ids = t.ids();+ assert_eq!(ids[0]["status"], json!("done"));+ assert_eq!(ids[1]["status"], json!("failed"));+ }++ #[test]+ fn job_expires_after_an_hour_running_never() {+ let mut t = table();+ let t0 = Instant::now();+ let done = t.start("c:/x/a.kicad_pcb", "C:/x/a.kicad_pcb", "freerouting", "th", t0).unwrap();+ let running = t.start("c:/x/b.kicad_pcb", "C:/x/b.kicad_pcb", "freerouting", "th", t0).unwrap();+ t.complete(&done, json!({"success": true}), t0 + Duration::from_secs(100));+ t.expire(t0 + Duration::from_secs(100) + JOB_KEEP - Duration::from_secs(1));+ assert!(t.get(&done).is_some(), "kept inside the hour");+ t.expire(t0 + Duration::from_secs(100) + JOB_KEEP);+ assert!(t.get(&done).is_none(), "gone after the hour");+ t.expire(t0 + Duration::from_secs(100_000));+ assert!(t.get(&running).is_some(), "a running job never expires");+ }++ #[test]+ fn job_busy_per_board() {+ let mut t = table();+ let t0 = Instant::now();+ let id = t.start("c:/x/a.kicad_pcb", "C:/x/a.kicad_pcb", "freerouting", "th", t0).unwrap();+ t.step(&id, "Freerouting pass 2 of up to 20", Some(13));+ assert_eq!(t.start("c:/x/a.kicad_pcb", "C:/x/a.kicad_pcb", "freerouting", "other", t0), Err(id.clone()));+ let busy = t.busy_reply("c:/x/a.kicad_pcb", t0 + Duration::from_secs(30)).unwrap();+ assert_eq!(busy["success"], json!(false));+ assert_eq!(busy["errorCode"], json!("job_busy"));+ assert_eq!(busy["job"], json!(id));+ assert_eq!(busy["status"], json!("running"));+ assert_eq!(busy["stepLabel"], json!("Freerouting pass 2 of up to 20"));+ assert_eq!(busy["percent"], json!(13));+ assert_eq!(busy["elapsedSec"], json!(30.0));+ assert!(busy["_hint"].as_str().unwrap().contains(&id));+ // Another board is free; the same board is free again once its job finished.+ assert!(t.start("c:/x/b.kicad_pcb", "C:/x/b.kicad_pcb", "freerouting", "th", t0).is_ok());+ assert!(t.busy_reply("c:/y/none.kicad_pcb", t0).is_none());+ t.complete(&id, json!({"success": true}), t0 + Duration::from_secs(60));+ assert!(t.busy_reply("c:/x/a.kicad_pcb", t0).is_none());+ let again = t.start("c:/x/a.kicad_pcb", "C:/x/a.kicad_pcb", "freerouting", "th", t0).unwrap();+ assert_ne!(again, id);+ assert!(again.starts_with("autoroute-3-"));+ }++ #[test]+ fn job_mode_from_args() {+ assert!(matches!(job_mode(&json!({})), JobMode::Sync));+ assert!(matches!(job_mode(&json!({"job": false})), JobMode::Sync));+ assert!(matches!(job_mode(&json!({"job": ""})), JobMode::Sync));+ assert!(matches!(job_mode(&json!({"job": true})), JobMode::Start));+ match job_mode(&json!({"job": " autoroute-1-abcd1234 "})) {+ JobMode::Poll(id) => assert_eq!(id, "autoroute-1-abcd1234"),+ _ => panic!("a non-empty string is a poll"),+ }+ }++ #[test]+ fn poll_unknown_id() {+ let v = autoroute_job_poll("autoroute-0-missing");+ assert_eq!(v["errorCode"], json!("job_not_found"));+ assert_eq!(v["job"], json!("autoroute-0-missing"));+ assert!(v["jobs"].is_array());+ }+}