app
AI Flow: JLCPCB
Public Made by Adomby adom
The whole JLCPCB order, from gerbers to assembled boards in hand: proven part swaps, design rules, rotation checks, quote and order.
← Commit history
Publish 0.1.0
23 files changed
+2279
Cargo.lock+546Cargo.toml+28LICENSE+9bin/adom-aiflow-jlcpcbbuild.sh+9docs/hero.pngdocs/img/jlc-preview-u1.pngflows/flow.json+83install.sh+18package.json+58schema/board-release.schema.json+25skills/adom-aiflow-jlcpcb/SKILL.md+61src/bridge.rs+82src/csvio.rs+65src/export.rs+177src/flow.rs+50src/kicad.rs+80src/ledger.rs+50src/main.rs+448src/out.rs+13src/rotation.rs+291src/swap.rs+180uninstall.sh+6Cargo.lockadded+546@@ -0,0 +1,546 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "adom-aiflow-jlcpcb"+version = "0.1.0"+dependencies = [+ "clap",+ "regex",+ "serde",+ "serde_json",+ "sha2",+ "zip",+]++[[package]]+name = "aho-corasick"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"+dependencies = [+ "memchr",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys",+]++[[package]]+name = "arbitrary"+version = "1.4.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"+dependencies = [+ "derive_arbitrary",+]++[[package]]+name = "block-buffer"+version = "0.10.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"+dependencies = [+ "generic-array",+]++[[package]]+name = "bumpalo"+version = "3.20.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"++[[package]]+name = "cfg-if"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"++[[package]]+name = "clap"+version = "4.6.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_derive"+version = "4.6.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn 3.0.6",+]++[[package]]+name = "clap_lex"+version = "1.1.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "cpufeatures"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"+dependencies = [+ "libc",+]++[[package]]+name = "crc32fast"+version = "1.5.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"+dependencies = [+ "cfg-if",+]++[[package]]+name = "crossbeam-utils"+version = "0.8.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"++[[package]]+name = "crypto-common"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"+dependencies = [+ "generic-array",+ "typenum",+]++[[package]]+name = "derive_arbitrary"+version = "1.4.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+]++[[package]]+name = "digest"+version = "0.10.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"+dependencies = [+ "block-buffer",+ "crypto-common",+]++[[package]]+name = "displaydoc"+version = "0.2.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.6",+]++[[package]]+name = "equivalent"+version = "1.0.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"++[[package]]+name = "flate2"+version = "1.1.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]+name = "generic-array"+version = "0.14.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"+dependencies = [+ "typenum",+ "version_check",+]++[[package]]+name = "hashbrown"+version = "0.17.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "indexmap"+version = "2.14.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"+dependencies = [+ "equivalent",+ "hashbrown",+]++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "libc"+version = "0.2.189"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"++[[package]]+name = "log"+version = "0.4.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"++[[package]]+name = "memchr"+version = "2.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"++[[package]]+name = "miniz_oxide"+version = "0.9.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "proc-macro2"+version = "1.0.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "regex"+version = "1.13.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"+dependencies = [+ "aho-corasick",+ "memchr",+ "regex-automata",+ "regex-syntax",+]++[[package]]+name = "regex-automata"+version = "0.4.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"+dependencies = [+ "aho-corasick",+ "memchr",+ "regex-syntax",+]++[[package]]+name = "regex-syntax"+version = "0.8.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"++[[package]]+name = "serde"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.6",+]++[[package]]+name = "serde_json"+version = "1.0.151"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"+dependencies = [+ "indexmap",+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "sha2"+version = "0.10.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"+dependencies = [+ "cfg-if",+ "cpufeatures",+ "digest",+]++[[package]]+name = "simd-adler32"+version = "0.3.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "syn"+version = "2.0.119"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "syn"+version = "3.0.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "thiserror"+version = "2.0.20"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"+dependencies = [+ "thiserror-impl",+]++[[package]]+name = "thiserror-impl"+version = "2.0.20"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.6",+]++[[package]]+name = "typenum"+version = "1.20.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"++[[package]]+name = "unicode-ident"+version = "1.0.26"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "zip"+version = "2.4.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"+dependencies = [+ "arbitrary",+ "crc32fast",+ "crossbeam-utils",+ "displaydoc",+ "flate2",+ "indexmap",+ "memchr",+ "thiserror",+ "zopfli",+]++[[package]]+name = "zmij"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"++[[package]]+name = "zopfli"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"+dependencies = [+ "bumpalo",+ "crc32fast",+ "log",+ "simd-adler32",+]
Cargo.tomladded+28@@ -0,0 +1,28 @@+# A flow app: one later-stage step of making a board (ordering, probing, ...) as its own Linux CLI.+# Copied from adom/adom-aiflow template/flow-app. This copy is yours: change anything, never depend on aiflow.+[package]+name = "adom-aiflow-jlcpcb"+version = "0.1.0"+edition = "2021"+license = "MIT"++[[bin]]+name = "adom-aiflow-jlcpcb"+path = "src/main.rs"++[dependencies]+serde = { version = "1", features = ["derive"] }+serde_json = { version = "1", features = ["preserve_order"] }+clap = { version = "4", features = ["derive"] }+regex = "1"+sha2 = "0.10"+zip = { version = "2", default-features = false, features = ["deflate"] }++[profile.release]+opt-level = 3+lto = true+codegen-units = 1+strip = true++# standalone: not a member of any surrounding workspace+[workspace]
LICENSEadded+9@@ -0,0 +1,9 @@+MIT License++Copyright (c) 2026 Adom Industries Inc.++Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
bin/adom-aiflow-jlcpcbadded⋯ 1 unchanged line ⋯
build.shadded+9@@ -0,0 +1,9 @@+#!/bin/bash+# Build the Linux binary from source and put it where the package ships it (bin/). Anyone can run this.+set -e+HERE="$(cd "$(dirname "$0")" && pwd)"; cd "$HERE"+NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' Cargo.toml | head -1)"+cargo test --release+cargo build --release+mkdir -p bin && cp "target/release/$NAME" "bin/$NAME"+echo "OK: bin/$NAME ($(bin/$NAME --version))"
docs/hero.pngadded⋯ 1 unchanged line ⋯
docs/img/jlc-preview-u1.pngadded⋯ 1 unchanged line ⋯
flows/flow.jsonadded+83@@ -0,0 +1,83 @@+{+ "name": "jlcpcb",+ "description": "From a finished board to boards in hand from JLCPCB (PCB + assembly), with the rotation corrections shared.",+ "steps": [+ {+ "name": "intake",+ "done": [+ "state.json"+ ],+ "do": "Run `init --release <board-release.json> --target <desktop>` (or `--board <B.kicad_pcb> --bom <csv>` when the board flow wrote no release). Read the BOM: every part placed by JLCPCB needs an LCSC number; parts fitted after assembly (contacts, machine pins) carry fit=post."+ },+ {+ "name": "sourcing",+ "done": [+ "sourcing.json"+ ],+ "do": "Make every placed part orderable at JLCPCB, as cheaply as is safe. Search in Hydrogen so the search is on screen: `adom-parts-search show \"<spec>\"` (add `--basic` for JLCPCB basic parts; each extended part type costs a setup fee). Order the exact MPN when JLCPCB has it. For an unstocked part, or an extended part with a basic equivalent, read BOTH datasheets, write swaps/<REF>.json with every number's source, and run `swap --ref <REF> --facts swaps/<REF>.json`, then `--apply` on a PASS. A deliberate exception goes in the facts' \"waive\" with the reason. Ask the human before swapping anything that changes the design's behaviour (dielectric, colour). Record each search with `clip start --name sourcing --hwnd <Hydrogen window>`. Close with `sourcing --why \"...\"`."+ },+ {+ "name": "rules",+ "done": [+ "rules.json"+ ],+ "do": "Run `rules`: KiCad DRC of a copy of the board with JLCPCB's design rules for its copper layer count, read from adom/pcb-design-rules (kicad/jlcpcb-2L|4L|6L.kicad_dru; the same page carries JLCPCB's Altium and Fusion rules). The design's own rules file is never changed. A failure goes back to the board flow (adom-aiflow); paying JLCPCB for a tighter process is the human's call. `export` refuses until this passes on the current board."+ },+ {+ "name": "export",+ "done": [+ "fab/*-fab-manifest.json"+ ],+ "do": "Run `export`: the gerber zip, JLCPCB BOM and CPL from the desktop's native kicad-cli, then the rotation check, which fits JLCPCB's own footprint for every LCSC number to the board's pads and corrects the CPL. A FAIL means the wrong footprint or LCSC number, or a mirrored part: fix the board or the BOM, never the CPL by hand. Parts JLCPCB has no footprint for are flagged; set a hand correction only from the preview (`--rotations rot.json`)."+ },+ {+ "name": "preview",+ "done": [+ "preview.json"+ ],+ "do": "Open cart.jlcpcb.com in a pup window (pup, never the OS mouse) and record it: `clip start --name upload --hwnd <pup window>`. Upload the gerber zip, choose PCB assembly, upload the BOM and CPL from jlcpcb-flow/fab/. In the placement preview, check pin 1 on every part the export flagged and save a screenshot of each with `preview --png <file> --refs <refs>`. If a part sits wrong, re-export with a correction; never fix it on JLCPCB's page."+ },+ {+ "name": "quote",+ "done": [+ "quote.json"+ ],+ "do": "Read the total JLCPCB shows before checkout and record it: `quote --total <amount> --currency USD`. Show the human the total, the board count and what drives the cost (extended parts, assembly setup)."+ },+ {+ "name": "approval",+ "done": [+ "approval.json"+ ],+ "do": "Wait for the human's explicit OK to pay this amount, in their own words. Record it with `approve --by <name> --amount <total> --words \"<what they said>\"`. Never write an approval the human did not give, and never pay without one."+ },+ {+ "name": "order",+ "done": [+ "order.json"+ ],+ "do": "Check the shipping address the human named, pay, and record the order number: `ordered --order <number>`. The checkout clip shows addresses and account details: keep it private, never on a public page."+ },+ {+ "name": "delivered",+ "done": [+ "delivered.json"+ ],+ "do": "When the boards arrive and have been tried, record them: `delivered --boards <N> --working <N>`. A failing board may be a part turned the wrong way: find out before anything is shared as proven."+ },+ {+ "name": "share",+ "done": [+ "shared.json"+ ],+ "do": "Share what this order proved: for each corrected or hand-set part with a wiki component page you own or were asked to update, `share --ref <REF> --page <owner/slug> --screenshot <its preview png>` (delivered boards are added automatically). On anyone else's page, open an issue with the record instead. Close with `share --done --why \"...\"`."+ },+ {+ "name": "report",+ "done": [+ "report.md"+ ],+ "do": "Run `report` and hand the next stage (probing, bring-up) what it needs: the order, the evidence, and anything that differed from the board release."+ }+ ]+}
install.shadded+18@@ -0,0 +1,18 @@+#!/bin/bash+# adom-wiki package install hook: the binary to ~/.local/bin, the skill to ~/.claude/skills (and ~/.codex/skills).+# Builds from source when no prebuilt binary ships or it does not run here.+set -e+HERE="$(cd "$(dirname "$0")" && pwd)"+NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' "$HERE/Cargo.toml" | head -1)"+if ! "$HERE/bin/$NAME" --version >/dev/null 2>&1; then+ command -v cargo >/dev/null || { echo "ERROR: no prebuilt binary for this machine and no cargo to build one (https://rustup.rs)"; exit 1; }+ (cd "$HERE" && cargo build --release && mkdir -p bin && cp "target/release/$NAME" "bin/$NAME")+fi+mkdir -p "$HOME/.local/bin"+install -m 755 "$HERE/bin/$NAME" "$HOME/.local/bin/.$NAME-new" && mv -f "$HOME/.local/bin/.$NAME-new" "$HOME/.local/bin/$NAME"+for skill in "$HERE"/skills/*; do+ [ -f "$skill/SKILL.md" ] || continue+ for consumer in .claude .codex; do mkdir -p "$HOME/$consumer/skills/$(basename "$skill")"; cp "$skill/SKILL.md" "$HOME/$consumer/skills/$(basename "$skill")/SKILL.md"; done+done+command -v adom-bridge >/dev/null 2>&1 || echo "Hint: desktop steps need adom-bridge (the Adom Bridge CLI)"+echo "OK: $NAME installed. Run '$NAME --help'."
package.jsonadded+58@@ -0,0 +1,58 @@+{+ "slug": "adom-aiflow-jlcpcb",+ "type": "app",+ "version": "0.1.0",+ "title": "AI Flow: JLCPCB",+ "description": "The JLCPCB ordering stage of AI Flow: from a finished board to assembled boards from JLCPCB. Part substitutes proven from both datasheets, the gerber zip, BOM and CPL from the desktop's own KiCad, every part's rotation checked against JLCPCB's own footprint and corrected, placement-preview evidence, the quote, the human's recorded OK before payment, and rotation corrections shared on component pages. Open source (MIT): build it with ./build.sh.",+ "category": "Developer Tools",+ "license": "MIT",+ "bin": {+ "adom-aiflow-jlcpcb": "bin/adom-aiflow-jlcpcb"+ },+ "tags": [+ "jlcpcb",+ "pcb assembly",+ "cpl",+ "rotation",+ "bom",+ "gerbers",+ "lcsc",+ "kicad",+ "ai flow"+ ],+ "dependencies": {},+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "bundled": true,+ "hero": {+ "path": "docs/hero.png"+ },+ "brief": "Order a finished board from JLCPCB with every part's pin 1 checked against JLCPCB's own footprint.",+ "discovery_pitch": "Use when a finished KiCad board goes to JLCPCB for assembly: it makes the gerbers, BOM and CPL, fixes part rotations before JLCPCB places them wrong, and keeps the order honest from quote to delivered boards.",+ "discovery_triggers": [+ "order my board from jlcpcb",+ "jlcpcb assembly",+ "jlc cpl rotation",+ "parts rotated wrong at jlcpcb",+ "export gerbers bom cpl",+ "jlcpcb basic parts",+ "swap to an lcsc part",+ "adom-aiflow-jlcpcb"+ ],+ "sample_prompts": [+ {+ "label": "Order this board",+ "prompt": "Order this board from JLCPCB with assembly, 5 boards"+ },+ {+ "label": "Check rotations",+ "prompt": "Check every part's rotation against JLCPCB before I upload the CPL"+ },+ {+ "label": "Cut the fees",+ "prompt": "Swap the extended parts on my BOM for JLCPCB basic parts where it is safe"+ }+ ]+}
schema/board-release.schema.jsonadded+25@@ -0,0 +1,25 @@+{+ "$schema": "https://json-schema.org/draft/2020-12/schema",+ "$id": "adom/board-release@1",+ "title": "Board release: the handoff from the board flow to every later stage",+ "description": "Written when a board is finished. Every later flow app (ordering, probing, ...) starts from this file and checks the sha256 of each file it names, so it never builds a board that changed after it was finished.",+ "type": "object",+ "required": ["schema", "board", "bom"],+ "properties": {+ "schema": {"const": "adom/board-release@1"},+ "name": {"type": "string", "description": "board name, e.g. buck-12v5v-molecule"},+ "page": {"type": ["string", "null"], "description": "the board's wiki page (owner/slug), if it has one"},+ "board": {"$ref": "#/$defs/file", "description": "the finished .kicad_pcb (or .brd)"},+ "schematic": {"$ref": "#/$defs/file"},+ "bom": {"$ref": "#/$defs/file", "description": "BOM csv: ref, mpn, value, plus vendor columns (lcsc, mouser...) and fit (post|dnp|hand for parts fitted after assembly)"},+ "spec": {"$ref": "#/$defs/file"},+ "sourcingProfile": {"type": "string", "description": "the profile the BOM was checked against (fab, jlcpcb, ...)"},+ "finished": {"type": "string", "format": "date-time"},+ "producedBy": {"type": "string", "description": "app and version that wrote this, e.g. adom-aiflow 0.1.52"},+ "checks": {"type": "object", "description": "what the board flow proved: drc, routed, analyses, with their results"},+ "notes": {"type": "array", "items": {"type": "string"}, "description": "anything a later stage must know"}+ },+ "$defs": {+ "file": {"type": "object", "required": ["path", "sha256"], "properties": {"path": {"type": "string"}, "sha256": {"type": "string"}}}+ }+}
skills/adom-aiflow-jlcpcb/SKILL.mdadded+61@@ -0,0 +1,61 @@+---+name: adom-aiflow-jlcpcb+description: >-+ The JLCPCB ordering stage of AI Flow: from a finished board (board-release.json, or a .kicad_pcb and BOM) to+ assembled boards from JLCPCB. Part substitutes proven from both datasheets (stocked or JLCPCB basic parts), the+ gerber zip, JLCPCB BOM and CPL from the desktop's own KiCad, every part's rotation checked against JLCPCB's own+ footprint and corrected in the CPL, placement-preview screenshots, the quote, the human's recorded OK before any+ payment, the order, delivered boards, and rotation corrections shared as jlcpcb-rotation.json on component pages.+ Trigger words: order from JLCPCB, JLCPCB assembly, JLC order, gerbers BOM CPL, CPL rotation, JLCPCB rotation,+ pin 1 wrong at JLCPCB, basic parts, extended part fee, LCSC substitute, adom-aiflow-jlcpcb.+---++# adom-aiflow-jlcpcb++Run `adom-aiflow-jlcpcb next` whenever you are unsure what to do: it names the current step and what you must do in+it. Every command answers `OK:` or `ERROR:` with `Hint:` lines, and every command goes in+`<project>/jlcpcb-flow/run.jsonl`. Pass `--ai-thread <you>` on every command that changes state.++```+adom-aiflow-jlcpcb init --release <board-release.json> --target <desktop> # or --board B.kicad_pcb --bom bom.csv+adom-aiflow-jlcpcb swap --ref C6 --facts swaps/C6.json [--apply] # a substitute, proven from both datasheets+adom-aiflow-jlcpcb sourcing --why "..." # every placed part has an LCSC number+adom-aiflow-jlcpcb rules # KiCad DRC with JLCPCB's rules (adom/pcb-design-rules)+adom-aiflow-jlcpcb export [--rotations rot.json] # gerbers, BOM, CPL, rotation check+adom-aiflow-jlcpcb preview --png <shot> --refs U1,L1 # JLCPCB's placement preview, pin 1 visible+adom-aiflow-jlcpcb quote --total 108.38+adom-aiflow-jlcpcb approve --by John --amount 108.38 --words "<what they said>" # only after the human said yes+adom-aiflow-jlcpcb ordered --order <number>+adom-aiflow-jlcpcb delivered --boards 5 --working 5+adom-aiflow-jlcpcb share --ref U1 --page <owner/slug> --screenshot <shot> # jlcpcb-rotation.json on the part's page+adom-aiflow-jlcpcb clip start --name <step> --hwnd <window> / clip stop # record every step for the video+adom-aiflow-jlcpcb report+```++## The rules that matter++- **JLCPCB's design rules live on the wiki, not here:** [adom/pcb-design-rules](https://wiki.adom.inc/adom/pcb-design-rules)+ has `kicad/jlcpcb-2L|4L|6L.kicad_dru` (plus board setup JSON) and the same limits for Altium (`altium/*.RUL`) and+ Fusion (`fusion/*.dru`, `*.edru`). `rules` picks the file by the board's copper layer count and runs KiCad DRC on a+ copy of the board with the board's own rules first and JLCPCB's limits last, so only JLCPCB's minimums are judged+ on top of the design's deliberate choices. `export` refuses until it passes. Design for JLCPCB from the start by+ loading the same file in the board flow.++- **Rotation is the classic JLCPCB failure.** KiCad's and JLCPCB's footprints often disagree on a package's zero+ rotation (SOT-23, TSOT, SOIC, QFN, electrolytics). `export` fits JLCPCB's own footprint to the board's pads for+ every LCSC number and corrects the CPL. A FAIL means the wrong footprint or LCSC number, or a mirrored part: fix+ the board or the BOM, never the CPL by hand. JLCPCB's preview is still the last word: screenshot every flagged part.+- **Read the part's page first.** A component page may carry `jlcpcb-rotation.json` (schema+ `adom/jlcpcb-rotation@1`): the correction for an LCSC number on a KiCad footprint, with evidence counts+ (pad fits, preview screenshots, boards delivered, users). Add to it after your order (`share`).+- **Order the exact part when JLCPCB has it.** Search with and without hyphens (EEE-FK1V470P is listed as+ EEEFK1V470P). Swap only for an unstocked part, or for a basic part that drops the extended-part fee, and ask the+ human before a swap that changes behaviour (dielectric, colour).+- **Search in Hydrogen, not on lcsc.com:** `adom-parts-search show "<spec>" --basic` puts the results in the Parts+ Search tab, on screen for the recording.+- **No money without the human's OK.** `approve` records their own words for one amount; `ordered` refuses without+ it. Never write an approval they did not give.+- **No moving mouse in the clips.** Drive JLCPCB's site with pup (`pup_eval`, `pup_input_dispatch`), which moves no+ OS cursor. The checkout clip shows addresses and account data: keep it private.+- **Share only where you may.** `share` pushes to pages you own or were asked to update; on anyone else's page,+ open an issue with the record.
src/bridge.rsadded+82@@ -0,0 +1,82 @@+#![allow(dead_code)] // template helpers: keep the ones your stage uses+//! Adom Bridge from a flow app: every desktop action (a browser through pup, a native app, a window+//! recording, files to and from the desktop) is one `adom-bridge` call. The app runs on Linux; the+//! desktop it drives is whatever `--target` names. Copied from aiflow-bridge; keep what you use.+use serde_json::{json, Value};+use std::path::{Path, PathBuf};+use std::process::Command;++#[derive(Clone)]+pub struct Bridge { pub target: String, pub ai_thread: String }++pub struct ClipStop { pub remote: Option<String>, pub local: Option<PathBuf>, pub pulled: bool, pub reply: Value }++impl Bridge {+ /// One verb with JSON args. Gated verbs need a per-call `reason` inside `args`.+ pub fn call(&self, verb: &str, args: &Value) -> Value {+ let out = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()]).output();+ match out {+ Ok(o) => {+ let text = String::from_utf8_lossy(&o.stdout).to_string();+ let v: Value = serde_json::from_str(&text).unwrap_or_else(|_| json!({"status": "parse_error", "raw": text.chars().take(400).collect::<String>()}));+ // some verbs wrap their answer as a JSON string in `output`+ match v.get("output").and_then(|o| o.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()) { Some(inner) => inner, None => v }+ }+ Err(e) => json!({"status": "error", "error": format!("adom-bridge not runnable: {e}")}),+ }+ }++ /// A field from a reply, at the top level or under `data` (verbs answer both ways).+ pub fn field<'a>(v: &'a Value, k: &str) -> Option<&'a Value> {+ v.get(k).or_else(|| v.get("data").and_then(|d| d.get(k)))+ }++ pub fn ok(v: &Value) -> bool {+ v.get("success").and_then(|s| s.as_bool()) == Some(true) || (v.get("status").and_then(|s| s.as_str()) == Some("ok") && v.get("success").is_none())+ }++ /// Record ONE window (Windows Graphics Capture: works in the background, nobody at the desk is+ /// disturbed). Capped at an hour whatever the AI forgets. Returns the recording id.+ pub fn record_window_start(&self, hwnd: i64, reason: &str) -> Result<String, Value> {+ let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason, "maxDurationMs": 3_600_000}));+ Self::field(&r, "recordingId").or_else(|| Self::field(&r, "id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)+ }++ /// Stop a recording and pull the file into `save_to`, checked: the local copy must exist and be+ /// non-empty. The desktop cleans its recordings folder, so pull now or lose the clip.+ pub fn record_stop(&self, id: &str, save_to: &Path) -> ClipStop {+ let r = self.call("desktop_record_stop", &json!({"recordingId": id}));+ let remote = Self::field(&r, "filePath").or_else(|| Self::field(&r, "path"))+ .and_then(|v| v.as_str()).map(|s| s.replace('\\', "/"));+ match remote {+ Some(p) => { let (pulled, local) = self.pull(&p, save_to, 3); ClipStop { remote: Some(p), local: Some(local), pulled, reply: r } }+ None => ClipStop { remote: None, local: None, pulled: false, reply: r },+ }+ }++ /// pull_file one desktop file into `save_to` until it lands non-empty (at most `attempts`).+ pub fn pull(&self, remote: &str, save_to: &Path, attempts: usize) -> (bool, PathBuf) {+ let local = save_to.join(remote.rsplit(['/', '\\']).next().unwrap_or(remote));+ for k in 0..attempts.max(1) {+ self.call("pull_file", &json!({"filePaths": [remote], "saveTo": save_to.display().to_string(), "reason": "flow app: bring a desktop file into the project"}));+ if std::fs::metadata(&local).map(|m| m.len() > 0).unwrap_or(false) { return (true, local); }+ if k + 1 < attempts { std::thread::sleep(std::time::Duration::from_secs(3)); }+ }+ (false, local)+ }++ /// A background screenshot of one window, copied to `dest`.+ pub fn screenshot_window(&self, hwnd: i64, dest: &Path) -> Option<PathBuf> {+ let r = self.call("desktop_screenshot_window", &json!({"hwnd": hwnd, "reason": "flow app: evidence screenshot"}));+ let d = r.get("data").cloned().unwrap_or(r.clone());+ let shots = d.get("screenshots").and_then(|s| s.as_array()).cloned().unwrap_or_else(|| vec![d.clone()]);+ let local = shots.iter().find_map(|s| s.get("localPath").or_else(|| s.get("savedTo")).and_then(|v| v.as_str()).map(str::to_string))?;+ std::fs::copy(&local, dest).ok()?;+ Some(dest.to_path_buf())+ }++ /// JavaScript in a pup (Puppeteer) browser window. No OS cursor moves, so recordings stay clean.+ pub fn pup_eval(&self, window: &str, expression: &str, reason: &str) -> Value {+ self.call("pup_eval", &json!({"window": window, "expression": expression, "reason": reason}))+ }+}
src/csvio.rsadded+65@@ -0,0 +1,65 @@+//! A small RFC 4180 CSV reader and writer (quoted fields, doubled quotes, commas and newlines in quotes).++pub fn parse(text: &str) -> Vec<Vec<String>> {+ let mut rows = Vec::new();+ let mut row = Vec::new();+ let mut field = String::new();+ let mut quoted = false;+ let mut chars = text.chars().peekable();+ let mut any = false;+ while let Some(c) = chars.next() {+ any = true;+ if quoted {+ if c == '"' {+ if chars.peek() == Some(&'"') { field.push('"'); chars.next(); } else { quoted = false; }+ } else { field.push(c); }+ continue;+ }+ match c {+ '"' => quoted = true,+ ',' => row.push(std::mem::take(&mut field)),+ '\r' => {}+ '\n' => { row.push(std::mem::take(&mut field)); rows.push(std::mem::take(&mut row)); any = false; }+ _ => field.push(c),+ }+ }+ if any { row.push(field); rows.push(row); }+ rows+}++/// Header and rows as maps, like Python's csv.DictReader (missing cells read as "").+pub struct Table { pub head: Vec<String>, pub rows: Vec<Vec<String>> }++impl Table {+ pub fn read(path: &std::path::Path) -> std::io::Result<Table> {+ let t = std::fs::read_to_string(path)?;+ let t = t.strip_prefix('\u{feff}').unwrap_or(&t);+ let mut all = parse(t);+ if all.is_empty() { return Ok(Table { head: vec![], rows: vec![] }); }+ let head = all.remove(0).into_iter().map(|h| h.trim().to_string()).collect();+ Ok(Table { head, rows: all.into_iter().filter(|r| !(r.len() == 1 && r[0].is_empty())).collect() })+ }+ pub fn col(&self, name: &str) -> Option<usize> { self.head.iter().position(|h| h == name) }+ pub fn get<'a>(&self, row: &'a [String], name: &str) -> &'a str {+ self.col(name).and_then(|i| row.get(i)).map(|s| s.as_str()).unwrap_or("")+ }+}++fn quote(f: &str) -> String {+ if f.contains([',', '"', '\n', '\r']) { format!("\"{}\"", f.replace('"', "\"\"")) } else { f.to_string() }+}++/// Rows joined with `eol` ("\r\n" matches Python's csv module default, which JLCPCB's files were made with).+pub fn write(rows: &[Vec<String>], eol: &str) -> String {+ rows.iter().map(|r| r.iter().map(|f| quote(f)).collect::<Vec<_>>().join(",") + eol).collect()+}++#[cfg(test)]+mod tests {+ #[test]+ fn quoted_fields_round_trip() {+ let rows = super::parse("a,\"b, c\",\"d \"\"e\"\"\"\r\n1,2,3\n");+ assert_eq!(rows, vec![vec!["a", "b, c", "d \"e\""], vec!["1", "2", "3"]]);+ assert_eq!(super::write(&rows, "\n"), "a,\"b, c\",\"d \"\"e\"\"\"\n1,2,3\n");+ }+}
src/export.rsadded+177@@ -0,0 +1,177 @@+//! The manufacturing package JLCPCB's upload page takes, made by the desktop's native kicad-cli (KiCad 10) through+//! Adom Bridge, because the shared KiCad service exports no paste layers and no placement file:+//! gerbers F/B.Cu, F/B.Paste, F/B.Silkscreen, F/B.Mask, Edge.Cuts (plus inner copper), Protel names+//! drill one Excellon file, mm, decimal, absolute origin+//! pos placement CSV, both sides, mm+//! Then, with JLCPCB's own column names:+//! <board>-gerbers.zip what "Add gerber file" takes+//! <board>-bom-jlcpcb.csv Comment, Designator, Footprint, LCSC Part # (one row per LCSC number)+//! <board>-cpl-jlcpcb.csv Designator, Mid X, Mid Y, Layer, Rotation+//! <board>-fab-manifest.json sha256 of every file, the parts left out and why, the rotations applied+//! Parts whose BOM row says fit=post (contacts, machine pins, hand-fitted parts) stay out of the BOM and CPL; every+//! other placed reference must have an LCSC number, or the export refuses.+use crate::bridge::Bridge;+use crate::csvio::{self, Table};+use crate::kicad::{g, ref_key};+use regex::Regex;+use serde_json::{json, Map, Value};+use std::collections::{BTreeMap, BTreeSet};+use std::path::{Path, PathBuf};++pub struct Package { pub manifest: PathBuf, pub cpl: PathBuf, pub base: String, pub summary: String }++fn out_text(r: &Value) -> String {+ format!("{}{}", Bridge::field(r, "stdout").and_then(|v| v.as_str()).unwrap_or(""), Bridge::field(r, "stderr").and_then(|v| v.as_str()).unwrap_or(""))+}++pub fn export(br: &Bridge, board: &Path, bom_path: &Path, out: &Path, rotations: &Map<String, Value>) -> Result<Package, String> {+ let board = board.canonicalize().map_err(|e| format!("{}: {e}", board.display()))?;+ let base = board.file_stem().and_then(|s| s.to_str()).unwrap_or("board").to_string();+ std::fs::create_dir_all(out).map_err(|e| e.to_string())?;+ let st = br.call("kicad_status", &json!({"reason": "find the native kicad-cli for the JLCPCB export"}));+ let cli = Bridge::field(&st, "install").and_then(|i| i["kicadCli"].as_str()).map(str::to_string)+ .or_else(|| std::env::var("KICAD_REMOTE_CLI").ok()).ok_or(format!("no KiCad on {} (set KICAD_REMOTE_CLI)", br.target))?;+ let sh = |cmd: &str, why: &str| br.call("shell_execute", &json!({"command": cmd, "timeoutSeconds": 240, "reason": why}));+ let tmpw = out_text(&sh("echo %TEMP%", "temp folder for the JLCPCB export")).trim().replace('\\', "/");+ if tmpw.is_empty() { return Err(format!("the desktop {} did not answer (is it connected?)", br.target)); }+ let dest = format!("{tmpw}/aiflow-jlcpcb-{}", crate::ledger::now().replace([':', '-'], ""));+ let dir = board.parent().unwrap();+ let mut send = vec![board.display().to_string()];+ for e in std::fs::read_dir(dir).map_err(|e| e.to_string())?.flatten() {+ let n = e.file_name().to_string_lossy().to_string();+ if n == format!("{base}.kicad_pro") || n.ends_with(".kicad_dru") { send.push(e.path().display().to_string()); }+ }+ br.call("send_files", &json!({"filePaths": send, "dest": dest, "reason": "JLCPCB export (native kicad-cli on the desktop)"}));+ let wb = format!("{dest}/{base}.kicad_pcb");+ let gdir = format!("{dest}/gerbers");+ let text = std::fs::read_to_string(&board).map_err(|e| e.to_string())?;+ let inner: BTreeSet<String> = Regex::new(r#"\(\d+ "(In\d+\.Cu)""#).unwrap().captures_iter(&text).map(|c| c[1].to_string()).collect();+ let mut layers = vec!["F.Cu".to_string(), "B.Cu".into()];+ layers.extend((1..=inner.len()).map(|i| format!("In{i}.Cu")));+ layers.extend(["F.Paste", "B.Paste", "F.Silkscreen", "B.Silkscreen", "F.Mask", "B.Mask", "Edge.Cuts"].map(String::from));+ let q = |p: &str| format!("\"{}\"", p.replace('/', "\\"));+ let cmds = [+ (format!("{} pcb export gerbers --output {} --layers {} {}", q(&cli), q(&gdir), layers.join(","), q(&wb)), "gerbers"),+ (format!("{} pcb export drill --output {} --format excellon --drill-origin absolute --excellon-zeros-format decimal --excellon-units mm {}", q(&cli), q(&gdir), q(&wb)), "drill"),+ (format!("{} pcb export pos --output {} --format csv --units mm --side both {}", q(&cli), q(&format!("{dest}/pos.csv")), q(&wb)), "placement"),+ (format!("powershell -NoProfile -Command \"Compress-Archive -Path '{}\\*' -DestinationPath '{}' -Force\"", gdir.replace('/', "\\"), format!("{dest}/gerbers.zip").replace('/', "\\")), "zip"),+ ];+ let mut log = Vec::new();+ for (c, what) in &cmds {+ let r = sh(c, &format!("JLCPCB export: {what}"));+ let o = out_text(&r);+ log.push(json!({"step": what, "exit": Bridge::field(&r, "exitCode"), "out": o.chars().rev().take(300).collect::<String>().chars().rev().collect::<String>()}));+ }+ let tmp = std::env::temp_dir().join(format!("aiflow-jlcpcb-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&tmp);+ std::fs::create_dir_all(&tmp).map_err(|e| e.to_string())?;+ let (gz_ok, gz) = br.pull(&format!("{dest}/gerbers.zip"), &tmp, 3);+ let (pos_ok, pos) = br.pull(&format!("{dest}/pos.csv"), &tmp, 3);+ sh(&format!("rmdir /s /q {}", q(&dest)), "clean up the JLCPCB export temp folder");+ if !(gz_ok && pos_ok) { return Err(format!("files did not come back: {}", Value::from(log.clone()))); }+ let names: Vec<String> = zip::ZipArchive::new(std::fs::File::open(&gz).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?.file_names().map(str::to_string).collect();+ let mut missing: Vec<String> = ["F_Cu", "B_Cu", "F_Mask", "B_Mask", "F_Silkscreen", "Edge_Cuts", "F_Paste"].iter().filter(|n| !names.iter().any(|x| x.contains(*n))).map(|s| s.to_string()).collect();+ if !names.iter().any(|x| x.to_lowercase().ends_with(".drl")) { missing.push("drill".into()); }+ if !missing.is_empty() { return Err(format!("the gerber zip lacks {}", missing.join(", "))); }+ let out_g = out.join(format!("{base}-gerbers.zip"));+ std::fs::copy(&gz, &out_g).map_err(|e| e.to_string())?;++ // BOM and CPL from the BOM rows and the placement file+ let bom = Table::read(bom_path).map_err(|e| format!("{}: {e}", bom_path.display()))?;+ let split = Regex::new(r"[ ,;]+").unwrap();+ let (mut fitpost, mut lcsc_of, mut comment_of, mut mpn_of) = (BTreeSet::new(), BTreeMap::new(), BTreeMap::new(), BTreeMap::new());+ for row in &bom.rows {+ let fit = bom.get(row, "fit").trim().to_lowercase();+ for rf in split.split(bom.get(row, "ref")).filter(|s| !s.is_empty()) {+ if crate::rotation::SKIP_FIT.contains(&fit.as_str()) { fitpost.insert(rf.to_string()); continue; }+ let l = bom.get(row, "lcsc").trim();+ lcsc_of.insert(rf.to_string(), if l.is_empty() { bom.get(row, "vendor_pn").trim().to_string() } else { l.to_string() });+ let v = bom.get(row, "value");+ comment_of.insert(rf.to_string(), if v.is_empty() { bom.get(row, "mpn").to_string() } else { v.to_string() });+ mpn_of.insert(rf.to_string(), bom.get(row, "mpn").to_string());+ }+ }+ let placed = Table::read(&pos).map_err(|e| e.to_string())?;+ let (mut cpl, mut fp_of, mut applied, mut nolcsc) = (Vec::new(), BTreeMap::new(), Map::new(), Vec::new());+ let num = |s: &str| s.trim().parse::<f64>().unwrap_or(0.0);+ for p in &placed.rows {+ let rf = placed.get(p, "Ref").to_string();+ if fitpost.contains(&rf) { continue; }+ if lcsc_of.get(&rf).map(|s| s.is_empty()).unwrap_or(true) { nolcsc.push(rf); continue; }+ let pkg = placed.get(p, "Package").to_string();+ let add = rotations.get(&rf).or_else(|| rotations.get(&pkg)).and_then(|v| v.as_f64()).unwrap_or(0.0);+ if add != 0.0 { applied.insert(rf.clone(), json!(add)); }+ cpl.push(vec![rf.clone(), format!("{:.4}mm", num(placed.get(p, "PosX"))), format!("{:.4}mm", num(placed.get(p, "PosY"))),+ if placed.get(p, "Side").to_lowercase().starts_with("top") { "Top".into() } else { "Bottom".into() },+ g((num(placed.get(p, "Rot")) + add).rem_euclid(360.0))]);+ fp_of.insert(rf, pkg);+ }+ let _ = std::fs::remove_dir_all(&tmp);+ if !nolcsc.is_empty() { nolcsc.sort_by_key(|r| ref_key(r)); return Err(format!("no LCSC number for {} (add it to the BOM, or fit=post for parts fitted after assembly)", nolcsc.join(", "))); }+ let mut refs: Vec<&String> = fp_of.keys().collect();+ refs.sort_by_key(|r| ref_key(r));+ let mut groups: Vec<(String, Vec<String>)> = Vec::new();+ for rf in refs {+ let l = &lcsc_of[rf];+ match groups.iter_mut().find(|(k, _)| k == l) { Some(gr) => gr.1.push(rf.clone()), None => groups.push((l.clone(), vec![rf.clone()])) }+ }+ let bom_out = out.join(format!("{base}-bom-jlcpcb.csv"));+ let cpl_out = out.join(format!("{base}-cpl-jlcpcb.csv"));+ let mut b = vec![vec!["Comment".to_string(), "Designator".into(), "Footprint".into(), "LCSC Part #".into()]];+ for (l, rs) in &groups { b.push(vec![format!("{} ({})", comment_of[&rs[0]], mpn_of[&rs[0]]), rs.join(","), fp_of[&rs[0]].clone(), l.clone()]); }+ std::fs::write(&bom_out, csvio::write(&b, "\r\n")).map_err(|e| e.to_string())?;+ let mut c = vec![vec!["Designator".to_string(), "Mid X".into(), "Mid Y".into(), "Layer".into(), "Rotation".into()]];+ c.extend(cpl.iter().cloned());+ std::fs::write(&cpl_out, csvio::write(&c, "\r\n")).map_err(|e| e.to_string())?;+ let sha = crate::sha256_file;+ let man = json!({"profile": "jlcpcb", "board": board.display().to_string(), "boardSha256": sha(&board), "bom": bom_path.canonicalize().unwrap_or(bom_path.to_path_buf()).display().to_string(),+ "made": crate::ledger::now(), "kicadCli": cli, "target": br.target, "gerbers": {"file": out_g.display().to_string(), "sha256": sha(&out_g), "files": names},+ "bomJlcpcb": {"file": bom_out.display().to_string(), "sha256": sha(&bom_out), "lines": groups.len()},+ "cplJlcpcb": {"file": cpl_out.display().to_string(), "sha256": sha(&cpl_out), "parts": cpl.len()},+ "leftOut": {"fitPost": fitpost.iter().collect::<Vec<_>>()}, "rotationsApplied": applied, "log": log});+ let man_path = out.join(format!("{base}-fab-manifest.json"));+ std::fs::write(&man_path, serde_json::to_string_pretty(&man).unwrap() + "\n").map_err(|e| e.to_string())?;+ let mut fp: Vec<&String> = fitpost.iter().collect();+ fp.sort_by_key(|r| ref_key(r));+ let summary = format!("{base}-gerbers.zip ({} gerber/drill files), {base}-bom-jlcpcb.csv ({} lines), {base}-cpl-jlcpcb.csv ({} parts); left out (fitted after assembly): {}",+ man["gerbers"]["files"].as_array().map(|a| a.len()).unwrap_or(0), groups.len(), cpl.len(), if fp.is_empty() { "none".into() } else { fp.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" ") });+ Ok(Package { manifest: man_path, cpl: cpl_out, base, summary })+}++/// The copper layer count of a board, from its layer table.+pub fn copper_layers(text: &str) -> usize {+ let re = Regex::new(r#"\(\d+ "((?:F|B|In\d+)\.Cu)""#).unwrap();+ re.captures_iter(text).map(|c| c[1].to_string()).collect::<BTreeSet<_>>().len()+}++/// DRC of the board under `dru` (JLCPCB's rules from adom/pcb-design-rules) on a temporary copy on the desktop, with+/// its native kicad-cli; the design's own rules file is never touched. Returns KiCad's JSON report.+pub fn drc_with_rules(br: &Bridge, board: &Path, dru: &str) -> Result<Value, String> {+ let board = board.canonicalize().map_err(|e| format!("{}: {e}", board.display()))?;+ let base = board.file_stem().and_then(|s| s.to_str()).unwrap_or("board").to_string();+ let st = br.call("kicad_status", &json!({"reason": "find the native kicad-cli for the JLCPCB rules check"}));+ let cli = Bridge::field(&st, "install").and_then(|i| i["kicadCli"].as_str()).map(str::to_string)+ .or_else(|| std::env::var("KICAD_REMOTE_CLI").ok()).ok_or(format!("no KiCad on {} (set KICAD_REMOTE_CLI)", br.target))?;+ let sh = |cmd: &str, why: &str| br.call("shell_execute", &json!({"command": cmd, "timeoutSeconds": 300, "reason": why}));+ let tmpw = out_text(&sh("echo %TEMP%", "temp folder for the JLCPCB rules check")).trim().replace('\\', "/");+ if tmpw.is_empty() { return Err(format!("the desktop {} did not answer (is it connected?)", br.target)); }+ let dest = format!("{tmpw}/aiflow-jlcpcb-drc-{}", crate::ledger::now().replace([':', '-'], ""));+ let local = std::env::temp_dir().join(format!("aiflow-jlcpcb-drc-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&local);+ std::fs::create_dir_all(&local).map_err(|e| e.to_string())?;+ std::fs::copy(&board, local.join(format!("{base}.kicad_pcb"))).map_err(|e| e.to_string())?;+ let pro = board.with_extension("kicad_pro");+ if pro.is_file() { std::fs::copy(&pro, local.join(format!("{base}.kicad_pro"))).map_err(|e| e.to_string())?; }+ std::fs::write(local.join(format!("{base}.kicad_dru")), dru).map_err(|e| e.to_string())?;+ let send: Vec<String> = std::fs::read_dir(&local).map_err(|e| e.to_string())?.flatten().map(|e| e.path().display().to_string()).collect();+ br.call("send_files", &json!({"filePaths": send, "dest": dest, "reason": "JLCPCB rules check (a copy of the board with JLCPCB's rules)"}));+ let q = |p: &str| format!("\"{}\"", p.replace('/', "\\"));+ let report = format!("{dest}/drc.json");+ let r = sh(&format!("{} pcb drc --format json --units mm --severity-all --output {} {}", q(&cli), q(&report), q(&format!("{dest}/{base}.kicad_pcb"))), "JLCPCB rules check: DRC");+ let (ok, got) = br.pull(&report, &local, 3);+ sh(&format!("rmdir /s /q {}", q(&dest)), "clean up the JLCPCB rules check temp folder");+ if !ok { let _ = std::fs::remove_dir_all(&local); return Err(format!("no DRC report came back: {}", out_text(&r).chars().take(400).collect::<String>())); }+ let v: Value = serde_json::from_str(&std::fs::read_to_string(&got).unwrap_or_default()).map_err(|e| format!("DRC report: {e}"))?;+ let _ = std::fs::remove_dir_all(&local);+ Ok(v)+}
src/flow.rsadded+50@@ -0,0 +1,50 @@+//! The flow file (flows/flow.json, compiled in): the ordered steps, the artifacts that prove each one+//! is done, and what the AI must do in it. `next` names the first step whose artifacts are missing.+//! The binary does the deterministic parts; the AI does the thinking the `do` text asks for.+use serde::Deserialize;+use std::path::Path;++#[derive(Deserialize, Clone)]+pub struct Step { pub name: String, pub done: Vec<String>, #[serde(rename = "do")] pub todo: String }++#[derive(Deserialize)]+#[allow(dead_code)]+pub struct Flow { pub name: String, pub description: String, pub steps: Vec<Step> }++pub fn load() -> Flow { serde_json::from_str(include_str!("../flows/flow.json")).expect("flows/flow.json is valid") }++/// One artifact pattern, relative to the flow folder; `*` matches within the last path segment.+pub fn exists(flow_dir: &Path, pat: &str) -> bool {+ let full = flow_dir.join(pat);+ let name = full.file_name().and_then(|n| n.to_str()).unwrap_or("");+ if !name.contains('*') { return full.exists(); }+ let (pre, post) = name.split_once('*').unwrap_or((name, ""));+ let dir = full.parent().unwrap_or(flow_dir);+ std::fs::read_dir(dir).map(|rd| rd.flatten().any(|e| {+ let n = e.file_name().to_string_lossy().to_string();+ n.starts_with(pre) && n.ends_with(post) && n.len() >= pre.len() + post.len()+ })).unwrap_or(false)+}++pub fn current<'a>(flow: &'a Flow, flow_dir: &Path) -> Option<&'a Step> {+ flow.steps.iter().find(|s| !s.done.iter().all(|p| exists(flow_dir, p)))+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn current_step_is_the_first_with_missing_artifacts() {+ if load().name != "example" { return; } // written for the template flow+ let d = std::env::temp_dir().join(format!("flow-test-{}", std::process::id()));+ std::fs::create_dir_all(d.join("work")).unwrap();+ std::fs::write(d.join("state.json"), "{}").unwrap();+ let f = load();+ assert_eq!(current(&f, &d).map(|s| s.name.as_str()), Some("work"));+ std::fs::write(d.join("work/a.json"), "{}").unwrap();+ assert!(exists(&d, "work/*.json"));+ assert_eq!(current(&f, &d).map(|s| s.name.as_str()), Some("report"));+ std::fs::remove_dir_all(&d).ok();+ }+}
src/kicad.rsadded+80@@ -0,0 +1,80 @@+//! The footprints of a .kicad_pcb, read straight from the file: reference, footprint name, placement, and each pad's+//! centre and size in the footprint's own frame (mm, y down).+use regex::Regex;+use std::collections::BTreeMap;++#[derive(Clone, Debug)]+pub struct Pad { pub x: f64, pub y: f64, pub w: f64, pub h: f64, pub rot: f64 }++#[derive(Clone, Debug)]+#[allow(dead_code)] // placement fields kept for stages that need them+pub struct Footprint { pub reference: String, pub name: String, pub x: f64, pub y: f64, pub rot: f64, pub bottom: bool, pub pads: BTreeMap<String, Pad> }++/// Each top-level `(footprint ...)` block, matching parentheses outside quoted strings.+fn blocks(t: &str) -> Vec<&str> {+ let b = t.as_bytes();+ let mut out = Vec::new();+ let mut i = 0;+ while let Some(j) = t[i..].find("(footprint ").map(|k| k + i) {+ let (mut depth, mut k, mut q) = (0i32, j, false);+ while k < b.len() {+ let c = b[k];+ if q { if c == b'\\' { k += 1; } else if c == b'"' { q = false; } }+ else if c == b'"' { q = true; }+ else if c == b'(' { depth += 1; }+ else if c == b')' { depth -= 1; if depth == 0 { k += 1; break; } }+ k += 1;+ }+ out.push(&t[j..k.min(b.len())]);+ i = k;+ }+ out+}++pub fn footprints(text: &str) -> BTreeMap<String, Footprint> {+ let re_ref = Regex::new(r#"\(property "Reference" "([^"]+)"|\(fp_text reference "([^"]+)""#).unwrap();+ let re_name = Regex::new(r#"\(footprint "([^"]+)""#).unwrap();+ let re_at = Regex::new(r"\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)").unwrap();+ let re_pad = Regex::new(r#"(?s)\(pad "([^"]*)" (?:smd|thru_hole) \w+.*?\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\).*?\(size ([-\d.]+) ([-\d.]+)\)"#).unwrap();+ let f = |s: &str| s.parse::<f64>().unwrap_or(0.0);+ let mut out = BTreeMap::new();+ for b in blocks(text) {+ let Some(r) = re_ref.captures(b) else { continue };+ let reference = r.get(1).or(r.get(2)).unwrap().as_str().to_string();+ let name = re_name.captures(b).map(|c| c[1].to_string()).unwrap_or_default();+ let (x, y, rot) = re_at.captures(b).map(|c| (f(&c[1]), f(&c[2]), c.get(3).map(|m| f(m.as_str())).unwrap_or(0.0))).unwrap_or((0.0, 0.0, 0.0));+ let head = &b[..b.len().min(400)];+ let mut pads = BTreeMap::new();+ for c in re_pad.captures_iter(b) {+ pads.entry(c[1].to_string()).or_insert(Pad { x: f(&c[2]), y: f(&c[3]), rot: c.get(4).map(|m| f(m.as_str())).unwrap_or(0.0), w: f(&c[5]), h: f(&c[6]) });+ }+ out.insert(reference.clone(), Footprint { reference, name, x, y, rot, bottom: head.contains("(layer \"B.Cu\")"), pads });+ }+ out+}++/// The reference designator's letters and number, so R2 sorts before R10.+pub fn ref_key(s: &str) -> (String, u64) {+ (s.chars().filter(|c| !c.is_ascii_digit()).collect(), s.chars().filter(|c| c.is_ascii_digit()).collect::<String>().parse().unwrap_or(0))+}++/// Python's "%g" for the values a CPL carries (whole degrees print without a decimal point).+pub fn g(v: f64) -> String {+ if (v - v.round()).abs() < 1e-9 { format!("{}", v.round() as i64) } else { let s = format!("{v:.6}"); s.trim_end_matches('0').trim_end_matches('.').to_string() }+}++#[cfg(test)]+mod tests {+ #[test]+ fn reads_a_footprint_and_its_pads() {+ let t = r#"(kicad_pcb (footprint "Package_TO_SOT_SMD:TSOT-23-6" (layer "F.Cu") (at 10 20 180) (property "Reference" "U1" (at 0 0)) (pad "1" smd roundrect (at -0.95 1.3 180) (size 0.6 1.1) (layers "F.Cu")) (pad "2" smd roundrect (at 0 1.3 180) (size 0.6 1.1))) (footprint "R" (at 1 2) (property "Reference" "R1 (x)" (at 0 0))))"#;+ let f = super::footprints(t);+ let u = &f["U1"];+ assert_eq!((u.x, u.y, u.rot, u.bottom), (10.0, 20.0, 180.0, false));+ assert_eq!(u.pads.len(), 2);+ assert_eq!(u.pads["1"].x, -0.95);+ assert!(f.contains_key("R1 (x)"));+ assert_eq!(super::g(90.0), "90");+ assert_eq!(super::ref_key("R10") > super::ref_key("R2"), true);+ }+}
src/ledger.rsadded+50@@ -0,0 +1,50 @@+//! The project's flow folder (`<project>/<flow>/`): state.json plus run.jsonl, one line per command+//! with the time, the calling AI thread and what it did. Never rewrite earlier lines.+use serde_json::{json, Value};+use std::io::Write;+use std::path::{Path, PathBuf};++pub fn now() -> String {+ let out = std::process::Command::new("date").args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]).output();+ out.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default()+}++#[allow(dead_code)]+pub struct Project { pub dir: PathBuf, pub flow_dir: PathBuf, pub state: Value }++impl Project {+ /// The nearest folder at or above `start` that holds `<flow>/state.json`.+ pub fn find(start: &Path, flow: &str) -> Option<Project> {+ let mut d = Some(start.to_path_buf());+ while let Some(p) = d {+ let s = p.join(flow).join("state.json");+ if s.is_file() {+ let state = std::fs::read_to_string(&s).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+ return Some(Project { flow_dir: p.join(flow), dir: p, state });+ }+ d = p.parent().map(Path::to_path_buf);+ }+ None+ }++ pub fn create(dir: &Path, flow: &str, state: Value) -> std::io::Result<Project> {+ let flow_dir = dir.join(flow);+ std::fs::create_dir_all(&flow_dir)?;+ let p = Project { dir: dir.to_path_buf(), flow_dir, state };+ p.save()?;+ Ok(p)+ }++ pub fn save(&self) -> std::io::Result<()> {+ std::fs::write(self.flow_dir.join("state.json"), serde_json::to_string_pretty(&self.state)? + "\n")+ }++ pub fn path(&self, rel: &str) -> PathBuf { self.flow_dir.join(rel) }++ pub fn log(&self, thread: &str, ev: &str, data: Value) {+ let line = json!({"t": now(), "aiThread": thread, "ev": ev, "data": data});+ if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(self.flow_dir.join("run.jsonl")) {+ let _ = writeln!(f, "{line}");+ }+ }+}
src/main.rsadded+448@@ -0,0 +1,448 @@+//! adom-aiflow-jlcpcb: order a finished board from JLCPCB (PCB + assembly) with every step proven and recorded:+//! parts JLCPCB stocks (substitutes proven from both datasheets), the manufacturing package from the desktop's own+//! KiCad, every part's placement rotation checked against JLCPCB's own footprint, the placement preview captured,+//! the quote, the human's OK before any money moves, the order, the delivered boards, and the rotation corrections+//! shared on the parts' wiki pages so the next board gets them right the first time.+mod bridge;+mod csvio;+mod export;+mod flow;+mod kicad;+mod ledger;+mod out;+mod rotation;+mod swap;++use clap::{Parser, Subcommand};+use out::{err, ok};+use serde_json::{json, Map, Value};+use sha2::{Digest, Sha256};+use std::path::{Path, PathBuf};++/// The folder this flow keeps inside the project (state.json, run.jsonl, clips/, fab/, preview/, evidence).+const FLOW_DIR: &str = "jlcpcb-flow";++#[derive(Parser)]+#[command(name = "adom-aiflow-jlcpcb", version, about = "Order a finished board from JLCPCB: proven part swaps, the manufacturing package, the rotation check, the placement preview, quote, the human's OK, order, delivery, and shared rotation corrections")]+struct Cli {+ /// The name of the AI conversation calling (required for commands that change state)+ #[arg(long, global = true)]+ ai_thread: Option<String>,+ /// Project folder (default: the nearest one above the current folder that has jlcpcb-flow/)+ #[arg(long, global = true)]+ project: Option<String>,+ #[command(subcommand)]+ cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+ /// Start on a finished board: `init --release <board-release.json>` or `init --board <B.kicad_pcb> --bom <csv>`, with `--target <desktop>` (the box with KiCad and the browser)+ Init { #[arg(long)] release: Option<String>, #[arg(long)] board: Option<String>, #[arg(long)] bom: Option<String>, #[arg(long)] target: Option<String> },+ /// What to do now: the first step whose artifacts are missing, and what the AI must do in it+ Next,+ /// Every step with its state+ Steps,+ /// The run ledger, one line per command+ Ledger,+ /// `clip start --name <step> --hwnd <window>` / `clip stop`: record one desktop window for the video+ Clip { what: String, #[arg(long)] name: Option<String>, #[arg(long)] hwnd: Option<i64> },+ /// Prove a substitute part from both datasheets: `swap --ref C6 --facts swaps/C6.json [--apply]` (`--apply` rewrites the BOM rows)+ Swap { #[arg(long = "ref")] reference: String, #[arg(long)] facts: String, #[arg(long)] apply: bool },+ /// Close sourcing: every placed part has an LCSC number; `sourcing --why "<what was swapped and why the rest stay>"`+ Sourcing { #[arg(long)] why: String },+ /// Check the board against JLCPCB's design rules (adom/pcb-design-rules, picked by copper layer count) with the desktop's KiCad DRC, on a copy: the design's own rules file is never touched+ Rules { #[arg(long, default_value = "adom/pcb-design-rules")] from: String },+ /// The manufacturing package plus the rotation check: gerber zip, JLCPCB BOM and CPL (corrected), manifest, rotation record+ Export { #[arg(long)] rotations: Option<String>, #[arg(long)] no_rotation_check: bool, /// export although the JLCPCB rules check has not passed (say why in the ledger)+ #[arg(long)] skip_rules: Option<String>, #[arg(long, default_value_t = 0.15)] tol: f64 },+ /// A JLCPCB placement-preview screenshot: `preview --png <file> --refs U1,L1` (pin 1 visible on each named part)+ Preview { #[arg(long)] png: String, #[arg(long)] refs: String, #[arg(long)] note: Option<String> },+ /// The quote JLCPCB shows before checkout: `quote --total 108.38 [--currency USD] [--quote-id <id>] [--note ...]`+ Quote { #[arg(long)] total: f64, #[arg(long, default_value = "USD")] currency: String, #[arg(long)] quote_id: Option<String>, #[arg(long)] note: Option<String> },+ /// The human's OK to pay, in their own words: `approve --by <name> --amount <total> --words "<what they said>"`+ Approve { #[arg(long)] by: String, #[arg(long)] amount: f64, #[arg(long)] words: String },+ /// The order went in: `ordered --order <JLCPCB order number>`+ Ordered { #[arg(long)] order: String },+ /// The boards came back: `delivered --boards 5 --working 5 [--note ...]`+ Delivered { #[arg(long)] boards: u64, #[arg(long)] working: u64, #[arg(long)] note: Option<String> },+ /// Share a part's rotation correction on its wiki component page: `share --ref U1 --page <owner/slug> [--screenshot <png>] [--dry-run]`; `share --done --why "..."` closes the step+ Share { #[arg(long = "ref")] reference: Option<String>, #[arg(long)] page: Option<String>, #[arg(long)] screenshot: Option<String>, #[arg(long)] dry_run: bool, #[arg(long)] done: bool, #[arg(long)] why: Option<String> },+ /// Write jlcpcb-flow/report.md: what was ordered, the evidence, and what the next stage must know+ Report,+}++pub fn sha256_file(p: &Path) -> String {+ std::fs::read(p).map(|b| format!("{:x}", Sha256::digest(&b))).unwrap_or_default()+}++fn thread(cli: &Cli) -> String {+ cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state: pass --ai-thread \"<your thread name>\"", &[]))+}++fn project(cli: &Cli) -> ledger::Project {+ let start = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());+ ledger::Project::find(&start, FLOW_DIR).unwrap_or_else(|| err(&format!("no {FLOW_DIR}/ project here"), &["Start one: adom-aiflow-jlcpcb init --release <board-release.json> --target <desktop> --ai-thread <you>".into()]))+}++fn read_json(p: &Path) -> Value {+ std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null)+}++fn write_json(p: &Path, v: &Value) {+ if let Some(d) = p.parent() { let _ = std::fs::create_dir_all(d); }+ std::fs::write(p, serde_json::to_string_pretty(v).unwrap() + "\n").unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[]));+}++fn board_of(p: &ledger::Project) -> PathBuf { PathBuf::from(p.state["release"]["board"]["path"].as_str().unwrap_or("")) }+fn bom_of(p: &ledger::Project) -> PathBuf { PathBuf::from(p.state["release"]["bom"]["path"].as_str().unwrap_or("")) }++fn bridge(p: &ledger::Project, th: &str) -> bridge::Bridge {+ let target = p.state["target"].as_str().map(str::to_string).unwrap_or_else(|| err("no desktop target: init with --target <box>", &[]));+ bridge::Bridge { target, ai_thread: th.to_string() }+}++/// Refuse when the board changed after the flow started: everything downstream would describe a different board.+fn same_board(p: &ledger::Project) {+ let b = board_of(p);+ let want = p.state["release"]["board"]["sha256"].as_str().unwrap_or("");+ if !want.is_empty() && sha256_file(&b) != want {+ err(&format!("{} changed since init (sha256 differs)", b.display()), &["Run init again on the new board (a new release); the export, preview and quote must all describe the board you order.".into()]);+ }+}++/// Parts the placement preview must show with pin 1 visible: every corrected, hand-set or footprint-less part.+fn flagged(p: &ledger::Project) -> Vec<String> {+ let man = latest_manifest(p).map(|m| read_json(&m)).unwrap_or(Value::Null);+ let rec = man["rotationCheck"]["file"].as_str().map(|f| read_json(Path::new(f))).unwrap_or(Value::Null);+ let mut out: Vec<String> = rec["parts"].as_array().into_iter().flatten()+ .filter(|r| r["delta"].as_i64().unwrap_or(0) != 0 || matches!(r["status"].as_str(), Some("manual") | Some("no-jlc-footprint")))+ .filter_map(|r| r["ref"].as_str().map(str::to_string)).collect();+ out.sort_by_key(|r| kicad::ref_key(r));+ out+}++fn latest_manifest(p: &ledger::Project) -> Option<PathBuf> {+ std::fs::read_dir(p.path("fab")).ok()?.flatten().map(|e| e.path()).find(|x| x.to_string_lossy().ends_with("-fab-manifest.json"))+}++fn after_hint(p: &ledger::Project, fl: &flow::Flow) -> String {+ flow::current(fl, &p.flow_dir).map(|s| format!("next step: {}: {}", s.name, s.todo)).unwrap_or("every step has its artifacts: the flow is done".into())+}++fn main() {+ let cli = Cli::parse();+ let fl = flow::load();+ match &cli.cmd {+ Cmd::Init { release, board, bom, target } => {+ let th = thread(&cli);+ let dir = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());+ let mut rel: Value = match release {+ Some(r) => serde_json::from_str(&std::fs::read_to_string(r).unwrap_or_else(|e| err(&format!("{r}: {e}"), &[]))).unwrap_or_else(|e| err(&format!("{r}: not JSON: {e}"), &[])),+ None => {+ let (Some(b), Some(m)) = (board, bom) else { err("init needs --release <board-release.json>, or --board and --bom", &["The board flow (adom-aiflow) writes board-release.json when it finishes (schema adom/board-release@1).".into()]) };+ json!({"schema": "adom/board-release@1", "board": {"path": b, "sha256": ""}, "bom": {"path": m, "sha256": ""}})+ }+ };+ for k in ["board", "bom"] {+ let raw = rel[k]["path"].as_str().unwrap_or("").to_string();+ let path = Path::new(&raw).canonicalize().unwrap_or_else(|_| err(&format!("the release's {k} is not here: {raw}"), &[]));+ let want = rel[k]["sha256"].as_str().unwrap_or("").to_string();+ let have = sha256_file(&path);+ if !want.is_empty() && want != have { err(&format!("{} changed since the release (sha256 differs)", path.display()), &["Finish the board again so the release names the board you mean to order.".into()]); }+ rel[k] = json!({"path": path.display().to_string(), "sha256": have});+ }+ let p = ledger::Project::create(&dir, FLOW_DIR, json!({"flow": fl.name, "release": rel, "target": target, "started": ledger::now()}))+ .unwrap_or_else(|e| err(&format!("cannot write {FLOW_DIR}/: {e}"), &[]));+ p.log(&th, "init", json!({"release": release, "board": rel["board"], "bom": rel["bom"]}));+ ok(&format!("JLCPCB flow started in {} for {}", p.flow_dir.display(), rel["board"]["path"].as_str().unwrap_or("")), &[after_hint(&p, &fl),+ if target.is_none() { "No --target: the export, rotation check and clips need the desktop with KiCad 10 and pup (init again with --target <box>).".into() } else { String::new() }]);+ }+ Cmd::Next => {+ let p = project(&cli);+ match flow::current(&fl, &p.flow_dir) {+ Some(s) => ok(&format!("step {}", s.name), &[s.todo.clone()]),+ None => ok("every step has its artifacts: the flow is done", &[]),+ }+ }+ Cmd::Steps => {+ let p = project(&cli);+ let cur = flow::current(&fl, &p.flow_dir).map(|s| s.name.clone());+ for s in &fl.steps {+ let done = s.done.iter().all(|x| flow::exists(&p.flow_dir, x));+ println!("{} {}", if done { "done" } else if Some(&s.name) == cur.as_ref() { "NOW " } else { " " }, s.name);+ }+ }+ Cmd::Ledger => { let p = project(&cli); print!("{}", std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default()); }+ Cmd::Clip { what, name, hwnd } => {+ let th = thread(&cli);+ let mut p = project(&cli);+ let br = bridge(&p, &th);+ match what.as_str() {+ "start" => {+ let (Some(n), Some(h)) = (name, hwnd) else { err("clip start needs --name <step> --hwnd <window>", &["Find the window with `adom-bridge desktop_list_windows`. Drive the browser with pup (pup_eval, pup_input_dispatch), never the OS mouse, so the clip shows no moving cursor.".into()]) };+ if p.state["clip"].is_object() { err("a clip is already recording: clip stop first", &[]); }+ let id = br.record_window_start(*h, &format!("{FLOW_DIR}: record step {n}")).unwrap_or_else(|r| err(&format!("recording did not start: {r}"), &[]));+ p.state["clip"] = json!({"id": id, "name": n, "hwnd": h, "started": ledger::now()});+ p.save().ok(); p.log(&th, "clip-start", p.state["clip"].clone());+ ok(&format!("recording {n} (window {h})"), &[]);+ }+ "stop" => {+ let c = p.state["clip"].clone();+ let id = c["id"].as_str().unwrap_or_else(|| err("no clip is recording", &[]));+ let dir = p.path("clips"); std::fs::create_dir_all(&dir).ok();+ let s = br.record_stop(id, &dir);+ p.state["clip"] = Value::Null; p.save().ok();+ p.log(&th, "clip-stop", json!({"name": c["name"], "remote": s.remote, "local": s.local, "pulled": s.pulled}));+ if !s.pulled { err(&format!("the clip did not land locally: {}", s.reply), &["Pull it with pull_file before the desktop cleans its recordings folder.".into()]); }+ ok(&format!("clip {} saved to {}", c["name"].as_str().unwrap_or(""), s.local.unwrap().display()), &[]);+ }+ _ => err("clip start|stop", &[]),+ }+ }+ Cmd::Swap { reference, facts, apply } => {+ let th = thread(&cli);+ let p = project(&cli);+ let fpath = PathBuf::from(facts);+ let mut f = read_json(&fpath);+ if f.is_null() { err(&format!("{facts}: missing or not JSON"), &["Write the facts first: both datasheets read, every number with its source (see `adom-aiflow-jlcpcb swap --help` and the skill).".into()]); }+ if f.get("ref").is_none() { f["ref"] = json!(reference); }+ let board = board_of(&p);+ let fps = kicad::footprints(&std::fs::read_to_string(&board).unwrap_or_default());+ let mut rows = swap::check(&f, fps.get(reference.as_str()), true);+ for r in rows.iter_mut() {+ if let Some(w) = f["waive"][&r.check].as_str() { if !r.pass { r.info = true; r.detail = format!("{}; WAIVED: {w}", r.detail); } }+ }+ let pass = !rows.is_empty() && rows.iter().all(|r| r.pass || r.info);+ let cand = &f["candidate"];+ print!("{} -> {} ({})\n{}", f["original"]["mpn"].as_str().unwrap_or(""), cand["mpn"].as_str().unwrap_or(""), cand["lcsc"].as_str().unwrap_or("no LCSC"), swap::print(&rows));+ println!("verdict: {}", if pass { "PASS" } else { "FAIL" });+ let out = fpath.parent().unwrap_or(Path::new(".")).join(format!("{reference}.result.json"));+ let mut res = json!({"ref": reference, "original": f["original"]["mpn"], "candidate": cand["mpn"], "lcsc": cand["lcsc"], "verdict": if pass { "PASS" } else { "FAIL" },+ "checks": rows.iter().map(|r| json!({"check": r.check, "pass": r.pass, "detail": r.detail, "info": r.info})).collect::<Vec<_>>(),+ "checkedAt": ledger::now(), "facts": facts, "notes": f["notes"]});+ if *apply {+ if !pass { write_json(&out, &res); p.log(&th, "swap", json!({"ref": reference, "verdict": "FAIL", "result": out})); err("not applied: the swap failed its checks", &[]); }+ let changed = swap::apply(&bom_of(&p), &f).unwrap_or_else(|e| err(&format!("BOM not rewritten: {e}"), &[]));+ res["bomRows"] = json!(changed);+ println!("BOM rows now {}: {}", cand["mpn"].as_str().unwrap_or(""), changed.join(", "));+ }+ write_json(&out, &res);+ p.log(&th, "swap", json!({"ref": reference, "verdict": res["verdict"], "applied": apply, "result": out}));+ if !pass { err(&format!("swap {reference}: FAIL (see the checks above); evidence {}", out.display()), &["Fix the facts only with what the datasheets say; a deliberate exception goes in \"waive\" with the reason.".into()]); }+ ok(&format!("swap {reference} verified, evidence {}", out.display()), &[if *apply { String::new() } else { "Re-run with --apply to rewrite the BOM rows.".into() }]);+ }+ Cmd::Sourcing { why } => {+ let th = thread(&cli);+ let p = project(&cli);+ let bom = csvio::Table::read(&bom_of(&p)).unwrap_or_else(|e| err(&format!("BOM: {e}"), &[]));+ let fps = kicad::footprints(&std::fs::read_to_string(board_of(&p)).unwrap_or_default());+ let split = regex::Regex::new(r"[ ,;]+").unwrap();+ let re_c = regex::Regex::new(r"^C\d+$").unwrap();+ let mut missing = Vec::new();+ let mut covered = std::collections::BTreeSet::new();+ for row in &bom.rows {+ let fit = bom.get(row, "fit").trim().to_lowercase();+ let l = { let l = bom.get(row, "lcsc").trim(); if l.is_empty() { bom.get(row, "vendor_pn").trim() } else { l } };+ for rf in split.split(bom.get(row, "ref")).filter(|s| !s.is_empty()) {+ covered.insert(rf.to_string());+ if !rotation::SKIP_FIT.contains(&fit.as_str()) && !re_c.is_match(l) { missing.push(rf.to_string()); }+ }+ }+ let mut absent: Vec<String> = fps.keys().filter(|r| !covered.contains(*r) && !r.starts_with('#')).cloned().collect();+ absent.sort_by_key(|r| kicad::ref_key(r));+ if !missing.is_empty() || !absent.is_empty() {+ err(&format!("sourcing is not closed: {}{}", if missing.is_empty() { String::new() } else { format!("no LCSC number for {}", missing.join(", ")) },+ if absent.is_empty() { String::new() } else { format!("{}board references with no BOM row: {}", if missing.is_empty() { "" } else { "; " }, absent.join(", ")) }),+ &["Search in Hydrogen (`adom-parts-search show \"<spec>\" --basic`), prove each substitute with `swap`, or mark parts fitted after assembly fit=post.".into()]);+ }+ let swaps: Vec<Value> = std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default().lines().filter_map(|l| serde_json::from_str::<Value>(l).ok())+ .filter(|e| e["ev"] == "swap" && e["data"]["applied"] == true).map(|e| e["data"].clone()).collect();+ write_json(&p.path("sourcing.json"), &json!({"closed": ledger::now(), "bom": bom_of(&p).display().to_string(), "bomSha256": sha256_file(&bom_of(&p)), "why": why, "swapsApplied": swaps}));+ p.log(&th, "sourcing", json!({"why": why, "swaps": swaps.len()}));+ ok(&format!("sourcing closed: every placed part has an LCSC number ({} swaps applied)", swaps.len()), &[after_hint(&p, &fl)]);+ }+ Cmd::Rules { from } => {+ let th = thread(&cli);+ let p = project(&cli);+ same_board(&p);+ let board = board_of(&p);+ let n = export::copper_layers(&std::fs::read_to_string(&board).unwrap_or_default()).max(2);+ let file = format!("kicad/jlcpcb-{n}L.kicad_dru");+ let o = std::process::Command::new("adom-wiki").args(["repo", "show", from, &file]).output().unwrap_or_else(|e| err(&format!("adom-wiki: {e}"), &[]));+ let dru = String::from_utf8_lossy(&o.stdout).to_string();+ if !o.status.success() || !dru.contains("(version") { err(&format!("no {file} on {from}"), &[format!("JLCPCB rules live on the wiki page {from} (kicad/jlcpcb-2L|4L|6L.kicad_dru); a {n}-layer board needs its file there.")]); }+ // the board's own rules first, JLCPCB's limits last (KiCad gives later rules priority), so a design's+ // deliberate overrides stand and only JLCPCB's minimums are judged on top of them+ let own = std::fs::read_to_string(board.with_extension("kicad_dru")).unwrap_or_default();+ let strip = |t: &str| t.lines().filter(|l| !l.trim_start().starts_with("(version")).collect::<Vec<_>>().join("\n");+ let combined = format!("(version 1)\n\n# the board's own rules\n{}\n\n# JLCPCB's limits ({from}/{file}), last so they take priority\n{}\n", strip(&own), strip(&dru));+ let br = bridge(&p, &th);+ let rep = export::drc_with_rules(&br, &board, &combined).unwrap_or_else(|e| err(&format!("rules check: {e}"), &[]));+ let viol: Vec<Value> = rep["violations"].as_array().cloned().unwrap_or_default();+ let errors: Vec<&Value> = viol.iter().filter(|v| v["severity"] == "error").collect();+ let unconnected = rep["unconnected_items"].as_array().map(|a| a.len()).unwrap_or(0);+ let mut kinds: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();+ for v in &errors { *kinds.entry(v["type"].as_str().unwrap_or("?").to_string()).or_default() += 1; }+ let pass = errors.is_empty() && unconnected == 0;+ write_json(&p.path("rules.json"), &json!({"pass": pass, "rules": format!("{from}/{file}"), "ownRules": if own.is_empty() { Value::Null } else { json!(board.with_extension("kicad_dru").display().to_string()) }, "rulesSha256": format!("{:x}", Sha256::digest(dru.as_bytes())), "copperLayers": n,+ "errors": errors.len(), "errorKinds": kinds, "warnings": viol.len() - errors.len(), "unconnected": unconnected, "boardSha256": sha256_file(&board), "at": ledger::now(),+ "violations": errors.iter().take(50).map(|v| json!({"type": v["type"], "description": v["description"], "items": v["items"].as_array().map(|a| a.iter().map(|i| i["description"].clone()).collect::<Vec<_>>())})).collect::<Vec<_>>()}));+ p.log(&th, "rules", json!({"pass": pass, "rules": file, "errors": errors.len(), "unconnected": unconnected}));+ if !pass {+ err(&format!("the board breaks JLCPCB's {n}-layer rules: {} errors ({}), {unconnected} unconnected; details in jlcpcb-flow/rules.json", errors.len(), kinds.iter().map(|(k, c)| format!("{k} x{c}")).collect::<Vec<_>>().join(", ")),+ &["Fix the board in the board flow (adom-aiflow) and finish it again, then init this flow on the new release. A JLCPCB upgrade (smaller drills, tighter spacing) is a cost decision for the human.".into()]);+ }+ ok(&format!("the board meets JLCPCB's {n}-layer rules ({from}/{file}): 0 errors, 0 unconnected, {} warnings", viol.len()), &[after_hint(&p, &fl)]);+ }+ Cmd::Export { rotations, no_rotation_check, tol, skip_rules } => {+ let th = thread(&cli);+ let p = project(&cli);+ same_board(&p);+ let rules = read_json(&p.path("rules.json"));+ let rules_ok = rules["pass"] == true && rules["boardSha256"].as_str() == Some(sha256_file(&board_of(&p)).as_str());+ match (rules_ok, skip_rules) {+ (true, _) => {}+ (false, Some(why)) => p.log(&th, "rules-skipped", json!({"why": why})),+ (false, None) => err("the board has not passed JLCPCB's design rules yet: run `rules`", &["`rules` runs KiCad DRC with JLCPCB's rules from adom/pcb-design-rules on a copy of the board. To export anyway, pass --skip-rules \"<why>\" (it goes in the ledger).".into()]),+ }+ let br = bridge(&p, &th);+ let ov: Map<String, Value> = rotations.as_ref().map(|r| read_json(Path::new(r))).and_then(|v| v.as_object().cloned()).unwrap_or_default()+ .into_iter().filter(|(k, _)| !k.starts_with('_')).collect();+ let out = p.path("fab");+ let pkg = export::export(&br, &board_of(&p), &bom_of(&p), &out, &ov).unwrap_or_else(|e| { p.log(&th, "export-failed", json!({"why": e})); err(&format!("export: {e}"), &[]) });+ println!("OK: {}", pkg.summary);+ let mut hints = vec![];+ if !*no_rotation_check {+ let bom = csvio::Table::read(&bom_of(&p)).unwrap_or_else(|e| err(&format!("BOM: {e}"), &[]));+ let c = rotation::check(&br, &board_of(&p), &bom, &ov, *tol);+ let rec_path = out.join(format!("{}-jlc-rotation.json", pkg.base));+ write_json(&rec_path, &c.record);+ let applied = c.failed.is_empty();+ if applied { rotation::apply(&pkg.cpl, &c.record).unwrap_or_else(|e| err(&format!("CPL not corrected: {e}"), &[])); }+ println!("{}", rotation::summary(&c, applied));+ for (r, w) in &c.failed { println!(" FAIL {r}: {w}"); }+ for (r, w) in &c.warned { println!(" WARN {r}: {w}"); }+ let parts = c.record["parts"].as_array().cloned().unwrap_or_default();+ let mut man = read_json(&pkg.manifest);+ man["rotationCheck"] = json!({"file": rec_path.display().to_string(), "applied": applied, "parts": parts.len(),+ "corrected": parts.iter().filter(|r| r["delta"].as_i64().unwrap_or(0) != 0).map(|r| (r["ref"].as_str().unwrap_or("").to_string(), json!(rotation::signed(r["delta"].as_i64().unwrap_or(0))))).collect::<Map<_, _>>(),+ "failed": c.failed.iter().map(|(r, _)| r.clone()).collect::<Vec<_>>(),+ "noJlcFootprint": parts.iter().filter(|r| matches!(r["status"].as_str(), Some("manual") | Some("no-jlc-footprint"))).filter_map(|r| r["ref"].as_str()).collect::<Vec<_>>()});+ man["cplJlcpcb"]["sha256"] = json!(sha256_file(&pkg.cpl));+ write_json(&pkg.manifest, &man);+ p.log(&th, "export", json!({"manifest": pkg.manifest, "rotationCheck": man["rotationCheck"]}));+ if !applied { err("the JLCPCB rotation check failed (see above); the CPL was NOT corrected", &["A part whose pads fit no rotation of JLCPCB's footprint has the wrong footprint or LCSC number, or is mirrored: fix the board or the BOM, never the CPL by hand.".into()]); }+ let fl_refs = flagged(&p);+ if !fl_refs.is_empty() { hints.push(format!("In JLCPCB's placement preview, look at pin 1 on {} and save a screenshot of each with `preview --png <file> --refs <refs>`.", fl_refs.join(", "))); }+ } else {+ p.log(&th, "export", json!({"manifest": pkg.manifest, "rotationCheck": "skipped"}));+ hints.push("The rotation check was skipped: every part's pin 1 must then be checked by eye in JLCPCB's preview.".into());+ }+ hints.push(after_hint(&p, &fl));+ ok(&format!("JLCPCB package in {}", out.display()), &hints);+ }+ Cmd::Preview { png, refs, note } => {+ let th = thread(&cli);+ let p = project(&cli);+ let src = Path::new(png);+ if !src.is_file() { err(&format!("{png}: no such file"), &[]); }+ let name = format!("{}-{}.png", refs.replace([',', ' '], "-"), &sha256_file(src)[..8]);+ let dest = p.path("preview").join(&name);+ std::fs::create_dir_all(dest.parent().unwrap()).ok();+ std::fs::copy(src, &dest).unwrap_or_else(|e| err(&format!("{e}"), &[]));+ let mut doc = read_json(&p.path("preview.json"));+ if doc.is_null() { doc = json!({"shots": []}); }+ let list: Vec<String> = refs.split([',', ' ']).filter(|s| !s.is_empty()).map(str::to_string).collect();+ doc["shots"].as_array_mut().unwrap().push(json!({"file": format!("preview/{name}"), "refs": list, "note": note, "at": ledger::now()}));+ let seen: std::collections::BTreeSet<String> = doc["shots"].as_array().unwrap().iter().flat_map(|s| s["refs"].as_array().cloned().unwrap_or_default()).filter_map(|r| r.as_str().map(str::to_string)).collect();+ let left: Vec<String> = flagged(&p).into_iter().filter(|r| !seen.contains(r)).collect();+ doc["missing"] = json!(left);+ write_json(&p.path("preview.json"), &doc);+ p.log(&th, "preview", json!({"file": dest, "refs": list}));+ ok(&format!("preview saved for {}", list.join(", ")), &[if left.is_empty() { "Every flagged part has its preview: get the quote next.".into() } else { format!("Still to capture in the preview: {}", left.join(", ")) }]);+ }+ Cmd::Quote { total, currency, quote_id, note } => {+ let th = thread(&cli);+ let p = project(&cli);+ same_board(&p);+ let doc = read_json(&p.path("preview.json"));+ let seen: std::collections::BTreeSet<String> = doc["shots"].as_array().into_iter().flatten().flat_map(|s| s["refs"].as_array().cloned().unwrap_or_default()).filter_map(|r| r.as_str().map(str::to_string)).collect();+ let left: Vec<String> = flagged(&p).into_iter().filter(|r| !seen.contains(r)).collect();+ if !left.is_empty() { err(&format!("no placement-preview screenshot yet for {}", left.join(", ")), &["These parts were corrected or have no JLCPCB footprint: look at pin 1 on each in JLCPCB's preview and save it with `preview` before quoting.".into()]); }+ write_json(&p.path("quote.json"), &json!({"total": total, "currency": currency, "quoteId": quote_id, "note": note, "at": ledger::now(), "boardSha256": sha256_file(&board_of(&p))}));+ p.log(&th, "quote", json!({"total": total, "currency": currency, "quoteId": quote_id}));+ ok(&format!("quote {total:.2} {currency} recorded"), &["Show the human the total and what it buys, and wait for their explicit OK in their own words before anything is paid. Then record it with `approve`.".into()]);+ }+ Cmd::Approve { by, amount, words } => {+ let th = thread(&cli);+ let p = project(&cli);+ let q = read_json(&p.path("quote.json"));+ let total = q["total"].as_f64().unwrap_or_else(|| err("no quote yet: `quote` first", &[]));+ if (total - amount).abs() > 0.005 { err(&format!("the approval is for {amount:.2} but the quote is {total:.2}"), &["Approval covers one amount. Show the human the current total and ask again.".into()]); }+ if words.trim().len() < 2 { err("record the human's own words", &[]); }+ write_json(&p.path("approval.json"), &json!({"by": by, "amount": amount, "currency": q["currency"], "words": words, "aiThread": th, "at": ledger::now()}));+ p.log(&th, "approve", json!({"by": by, "amount": amount, "words": words}));+ ok(&format!("{by} approved {amount:.2} {}", q["currency"].as_str().unwrap_or("")), &["Place the order and pay at JLCPCB (record the checkout clip privately: it shows addresses), then `ordered --order <number>`.".into()]);+ }+ Cmd::Ordered { order } => {+ let th = thread(&cli);+ let p = project(&cli);+ let (q, a) = (read_json(&p.path("quote.json")), read_json(&p.path("approval.json")));+ if a.is_null() { err("no recorded approval: nothing may be paid without the human's OK (`approve`)", &[]); }+ if (q["total"].as_f64().unwrap_or(-1.0) - a["amount"].as_f64().unwrap_or(-2.0)).abs() > 0.005 { err("the approval does not match the quote", &[]); }+ write_json(&p.path("order.json"), &json!({"order": order, "total": q["total"], "currency": q["currency"], "approvedBy": a["by"], "at": ledger::now(), "boardSha256": sha256_file(&board_of(&p))}));+ p.log(&th, "ordered", json!({"order": order}));+ ok(&format!("order {order} recorded"), &["When the boards arrive and have been tried: `delivered --boards N --working N`.".into()]);+ }+ Cmd::Delivered { boards, working, note } => {+ let th = thread(&cli);+ let p = project(&cli);+ if working > boards { err("more working boards than boards", &[]); }+ let order = read_json(&p.path("order.json"));+ write_json(&p.path("delivered.json"), &json!({"boards": boards, "working": working, "note": note, "order": order["order"], "at": ledger::now()}));+ p.log(&th, "delivered", json!({"boards": boards, "working": working}));+ ok(&format!("{working} of {boards} boards work"), &[if working == boards { "Every board works: the rotation corrections are now proven on real boards. Share them (`share`).".into() } else { "Some boards fail: find out why before sharing any rotation correction as proven (a part turned the wrong way shows up here).".into() }]);+ }+ Cmd::Share { reference, page, screenshot, dry_run, done, why } => {+ let th = thread(&cli);+ let p = project(&cli);+ if *done {+ let why = why.clone().unwrap_or_else(|| err("share --done needs --why \"<what was shared, and why the rest was not>\"", &[]));+ write_json(&p.path("shared.json"), &json!({"why": why, "at": ledger::now()}));+ p.log(&th, "share-done", json!({"why": why}));+ return ok("sharing closed", &[after_hint(&p, &fl)]);+ }+ let (Some(rf), Some(pg)) = (reference, page) else { err("share needs --ref <REF> --page <owner/slug> (or --done --why ...)", &["Share only on pages you own or were asked to update; on anyone else's page, open an issue with the record instead.".into()]) };+ let man = latest_manifest(&p).map(|m| read_json(&m)).unwrap_or(Value::Null);+ let rec = man["rotationCheck"]["file"].as_str().map(|f| read_json(Path::new(f))).unwrap_or_else(|| err("no rotation record: run `export` first", &[]));+ let delivered = read_json(&p.path("delivered.json"));+ let boards = delivered["working"].as_u64().filter(|n| *n > 0 && delivered["working"] == delivered["boards"]);+ let order = delivered["order"].as_str().map(str::to_string);+ let ev = rotation::Evidence { screenshot: screenshot.as_ref().map(Path::new), boards, order: order.as_deref() };+ match rotation::publish(&rec, rf, pg, &ev, *dry_run) {+ Ok(msg) => { if !dry_run { p.log(&th, "share", json!({"ref": rf, "page": pg, "screenshot": screenshot, "boards": boards})); } print!("{}", if *dry_run { format!("{msg}") } else { format!("OK: {msg}\n") }); }+ Err(e) => err(&format!("share {rf}: {e}"), &[]),+ }+ }+ Cmd::Report => {+ let th = thread(&cli);+ let p = project(&cli);+ let man = latest_manifest(&p).map(|m| read_json(&m)).unwrap_or(Value::Null);+ let (q, a, o, d) = (read_json(&p.path("quote.json")), read_json(&p.path("approval.json")), read_json(&p.path("order.json")), read_json(&p.path("delivered.json")));+ let src = read_json(&p.path("sourcing.json"));+ let corrected = man["rotationCheck"]["corrected"].as_object().map(|m| m.iter().map(|(k, v)| format!("{k} {v}")).collect::<Vec<_>>().join(", ")).unwrap_or_default();+ let text = format!("# JLCPCB order report\n\nBoard: `{}` (sha256 {})\n\n| Step | Result |\n|---|---|\n| Sourcing | {} swaps applied; {} |\n| Package | {} BOM lines, {} placed parts; left out: {} |\n| Rotation check | {} parts; corrected: {}; no JLCPCB footprint: {} |\n| Quote | {} {} |\n| Approval | {} said \"{}\" |\n| Order | {} |\n| Delivered | {} of {} boards work |\n",+ board_of(&p).display(), &sha256_file(&board_of(&p))[..12],+ src["swapsApplied"].as_array().map(|a| a.len()).unwrap_or(0), src["why"].as_str().unwrap_or("not closed"),+ man["bomJlcpcb"]["lines"], man["cplJlcpcb"]["parts"], man["leftOut"]["fitPost"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" ")).unwrap_or_default(),+ man["rotationCheck"]["parts"], if corrected.is_empty() { "none".into() } else { corrected }, man["rotationCheck"]["noJlcFootprint"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" ")).unwrap_or_default(),+ q["total"], q["currency"].as_str().unwrap_or(""), a["by"].as_str().unwrap_or("nobody yet"), a["words"].as_str().unwrap_or(""),+ o["order"].as_str().unwrap_or("not placed"), d["working"], d["boards"]);+ std::fs::write(p.path("report.md"), &text).unwrap_or_else(|e| err(&format!("{e}"), &[]));+ p.log(&th, "report", json!({}));+ print!("{text}");+ ok(&format!("report written to {}", p.path("report.md").display()), &[]);+ }+ }+}
src/out.rsadded+13@@ -0,0 +1,13 @@+//! Every command answers the AI the same way: `OK: <what happened>` or `ERROR: <what is wrong>`,+//! then `Hint:` lines that depend on this project and this run. Exit 1 on ERROR.++pub fn ok(msg: &str, hints: &[String]) {+ println!("OK: {msg}");+ for h in hints.iter().filter(|h| !h.is_empty()) { println!("Hint: {h}"); }+}++pub fn err(msg: &str, hints: &[String]) -> ! {+ println!("ERROR: {msg}");+ for h in hints.iter().filter(|h| !h.is_empty()) { println!("Hint: {h}"); }+ std::process::exit(1)+}
src/rotation.rsadded+291@@ -0,0 +1,291 @@+//! The JLCPCB rotation check: prove every placed part lands pin 1 on pad 1 before the order goes in.+//!+//! JLCPCB places each part from the footprint attached to its LCSC number (EasyEDA's library), at the rotation and+//! centroid in our CPL. KiCad's footprint for the same package often has a different zero rotation (SOT-23, TSOT,+//! SOIC, QFN, electrolytics), so the CPL needs a per-part correction. This finds it by geometry, not by a table:+//!+//! E = JLCPCB's pads (EasyEDA footprint for the C-number, pad numbers, mm, footprint frame)+//! K = the board footprint's pads (same numbers, footprint frame)+//! the rotation d in {0, 90, 180, 270} and offset t with R(d)E + t == K pad for pad (max error <= tol)+//! CPL rotation = KiCad rotation + d; CPL centroid shifted by t turned into the board frame+//!+//! JLCPCB's footprint API answers browsers only, so it is read through a pup window on the target desktop.+use crate::bridge::Bridge;+use crate::csvio::{self, Table};+use crate::kicad::{self, g, ref_key};+use regex::Regex;+use serde_json::{json, Map, Value};+use std::collections::BTreeMap;+use std::path::Path;++const MIL: f64 = 0.254; // EasyEDA units are 10 mil+const WINDOW: &str = "aiflow-jlcpcb";+pub const SKIP_FIT: [&str; 5] = ["post", "dnp", "hand", "after", "no"];+pub const PAGE_FILE: &str = "jlcpcb-rotation.json";++type Pads = BTreeMap<String, (f64, f64)>;++pub struct JlcFootprint { pub title: String, pub pads: Pads }++/// JLCPCB's footprint for one LCSC number, or None when EasyEDA has none.+pub fn jlc_footprint(br: &Bridge, lcsc: &str) -> Option<JlcFootprint> {+ let url = format!("https://easyeda.com/api/products/{lcsc}/components?version=6.4.19.5");+ let w = br.call("pup_list_windows", &json!({"reason": "find the JLCPCB footprint window"}));+ let have = Bridge::field(&w, "sessions").and_then(|s| s.as_array()).map(|a| a.iter().any(|s| s["window"] == WINDOW)).unwrap_or(false);+ if have { br.call("pup_navigate", &json!({"window": WINDOW, "url": url, "reason": format!("JLCPCB footprint for {lcsc}")})); }+ else { br.call("pup_open_window", &json!({"window": WINDOW, "url": url, "reason": "read JLCPCB footprints for the rotation check"})); }+ // the page must be THIS part's (never the previous part's answer); a missing part answers at once+ let expr = format!("(async()=>{{for(let i=0;i<40;i++){{try{{if(!location.href.includes('/{lcsc}/'))throw 0;const d=JSON.parse(document.body.innerText);const p=(d.result||{{}}).packageDetail||{{}};\+const ds=p.dataStr||{{}};if(ds.head)return JSON.stringify({{title:p.title,head:ds.head,pads:(ds.shape||[]).filter(s=>s.startsWith('PAD'))}});\+if(d.success===false||d.code===404||!d.result)return JSON.stringify({{notFound:String(d.message||d.code||'no result')}})}}catch(e){{}}\+await new Promise(r=>setTimeout(r,500))}}return JSON.stringify({{error:location.href+' '+document.body.innerText.slice(0,200)}})}})()");+ let mut d = Value::Null;+ for _ in 0..3 {+ let r = br.pup_eval(WINDOW, &expr, &format!("parse the JLCPCB footprint for {lcsc}"));+ d = match Bridge::field(&r, "result") { Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(Value::Null), Some(v) => v.clone(), None => Value::Null };+ if d.get("head").is_some() || d.get("notFound").is_some() { break; }+ br.call("pup_navigate", &json!({"window": WINDOW, "url": url, "reason": format!("retry JLCPCB footprint for {lcsc}")}));+ }+ let head = d.get("head")?;+ let num = |v: &Value| v.as_str().and_then(|s| s.parse::<f64>().ok()).or_else(|| v.as_f64()).unwrap_or(0.0);+ let (hx, hy) = (num(&head["x"]), num(&head["y"]));+ let mut pads = Pads::new();+ for s in d["pads"].as_array().into_iter().flatten().filter_map(|s| s.as_str()) {+ let f: Vec<&str> = s.split('~').collect(); // PAD~shape~x~y~w~h~layer~net~number~...+ if f.len() < 9 { continue; }+ if let (Ok(x), Ok(y)) = (f[2].parse::<f64>(), f[3].parse::<f64>()) {+ pads.entry(f[8].to_string()).or_insert(((x - hx) * MIL, (y - hy) * MIL));+ }+ }+ Some(JlcFootprint { title: d["title"].as_str().unwrap_or("").to_string(), pads })+}++/// Rotate CCW-positive (as seen y-up) in y-down coordinates.+pub fn rot(p: (f64, f64), deg: f64) -> (f64, f64) {+ let (s, c) = deg.to_radians().sin_cos();+ (p.0 * c + p.1 * s, -p.0 * s + p.1 * c)+}++pub struct Fit { pub delta: i64, pub residual: f64, pub offset: (f64, f64), pub compared: usize }++/// The rotation of E that best lands its pads on K, pad number for pad number.+pub fn fit(e: &Pads, k: &Pads) -> Option<Fit> {+ let common: Vec<&String> = k.keys().filter(|n| !n.is_empty() && e.contains_key(*n)).collect();+ if common.len() < 2 { return None; }+ let n = common.len() as f64;+ let mut best: Option<Fit> = None;+ for d in [0i64, 90, 180, 270] {+ let rp: Vec<(f64, f64)> = common.iter().map(|c| rot(e[*c], d as f64)).collect();+ let t = (common.iter().zip(&rp).map(|(c, r)| k[*c].0 - r.0).sum::<f64>() / n, common.iter().zip(&rp).map(|(c, r)| k[*c].1 - r.1).sum::<f64>() / n);+ let err = common.iter().zip(&rp).map(|(c, r)| ((r.0 + t.0 - k[*c].0).powi(2) + (r.1 + t.1 - k[*c].1).powi(2)).sqrt()).fold(0.0, f64::max);+ if best.as_ref().map(|b| err < b.residual).unwrap_or(true) { best = Some(Fit { delta: d, residual: err, offset: t, compared: common.len() }); }+ }+ best+}++fn r4(v: f64) -> f64 { (v * 10000.0).round() / 10000.0 }+pub fn signed(delta: i64) -> i64 { (delta + 180).rem_euclid(360) - 180 }++pub struct Check { pub record: Value, pub failed: Vec<(String, String)>, pub warned: Vec<(String, String)> }++/// Check every placed part with an LCSC number; `overrides` are hand-set corrections (ref or footprint -> degrees),+/// used only where JLCPCB publishes no footprint.+pub fn check(br: &Bridge, board: &Path, bom: &Table, overrides: &Map<String, Value>, tol: f64) -> Check {+ let text = std::fs::read_to_string(board).unwrap_or_default();+ let parts = kicad::footprints(&text);+ let re_c = Regex::new(r"^C\d+$").unwrap();+ let split = Regex::new(r"[ ,;]+").unwrap();+ let (mut lcsc, mut mpn) = (BTreeMap::new(), BTreeMap::new());+ for row in &bom.rows {+ if SKIP_FIT.contains(&bom.get(row, "fit").trim().to_lowercase().as_str()) { continue; }+ let c = { let l = bom.get(row, "lcsc").trim(); if l.is_empty() { bom.get(row, "vendor_pn").trim() } else { l } }.to_string();+ if !re_c.is_match(&c) { continue; }+ for rf in split.split(bom.get(row, "ref")).filter(|s| !s.is_empty()) { lcsc.insert(rf.to_string(), c.clone()); mpn.insert(rf.to_string(), bom.get(row, "mpn").to_string()); }+ }+ let mut order: Vec<String> = lcsc.keys().filter(|r| parts.contains_key(*r)).cloned().collect();+ order.sort_by_key(|r| ref_key(r));+ let mut cache: BTreeMap<String, Option<JlcFootprint>> = BTreeMap::new();+ for rf in &order { let c = &lcsc[rf]; if !cache.contains_key(c) { cache.insert(c.clone(), jlc_footprint(br, c)); } }+ let mut by_fp: BTreeMap<String, String> = BTreeMap::new(); // KiCad footprint -> an LCSC number with a JLCPCB footprint+ for rf in &order { if cache[&lcsc[rf]].is_some() { by_fp.entry(parts[rf].name.clone()).or_insert(lcsc[rf].clone()); } }+ let ov = |rf: &str, fp: &str| overrides.get(rf).or_else(|| overrides.get(fp)).and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)));+ let (mut recs, mut failed, mut warned) = (Vec::new(), Vec::new(), Vec::new());+ for rf in &order {+ let (c, p) = (&lcsc[rf], &parts[rf]);+ let mut src = c.clone();+ let mut jf = cache[c].as_ref();+ if jf.is_none() { if let Some(o) = by_fp.get(&p.name) { src = o.clone(); jf = cache[o].as_ref(); } }+ let base = json!({"ref": rf, "mpn": mpn[rf], "lcsc": c, "kicadFootprint": p.name, "kicadRotation": p.rot});+ let Some(jf) = jf else {+ let mut rec = base;+ match ov(rf, &p.name) {+ Some(m) => {+ rec["status"] = json!("manual"); rec["delta"] = json!(m.rem_euclid(360)); rec["jlcRotation"] = json!((p.rot + m as f64).rem_euclid(360.0));+ warned.push((rf.clone(), format!("no JLCPCB footprint; hand-set correction {m:+} kept (confirm it in JLCPCB's placement preview)")));+ }+ None => {+ rec["status"] = json!("no-jlc-footprint");+ warned.push((rf.clone(), format!("JLCPCB publishes no footprint for {c} and no part on this board shares {}: confirm it in JLCPCB's placement preview", p.name)));+ }+ }+ recs.push(rec); continue;+ };+ let k: Pads = p.pads.iter().map(|(n, q)| (n.clone(), (q.x, q.y))).collect();+ let mut rec = base;+ rec["jlcFootprint"] = json!(jf.title);+ if src != *c { rec["jlcFootprintFrom"] = json!(src); }+ let Some(f) = fit(&jf.pads, &k) else {+ failed.push((rf.clone(), format!("no common pad numbers between {} and {}", p.name, jf.title)));+ rec["status"] = json!("no-match"); recs.push(rec); continue;+ };+ rec["delta"] = json!(f.delta); rec["residual_mm"] = json!(r4(f.residual)); rec["offset_mm"] = json!([r4(f.offset.0), r4(f.offset.1)]); rec["pads_compared"] = json!(f.compared);+ rec["jlcRotation"] = json!((p.rot + f.delta as f64).rem_euclid(360.0));+ let (ox, oy) = rot((r4(f.offset.0), r4(f.offset.1)), p.rot); // into the board frame (y down)+ rec["centroidShiftBoard_mm"] = json!([r4(-ox), r4(-oy)]);+ let ok = r4(f.residual) <= tol;+ rec["status"] = json!(if ok { "ok" } else { "mismatch" });+ rec["pads"] = json!({"kicad": p.pads.iter().map(|(n, q)| (n.clone(), json!([r4(q.x), r4(q.y), r4(q.w), r4(q.h)]))).collect::<Map<_, _>>(),+ "jlc": jf.pads.iter().map(|(n, q)| (n.clone(), json!([r4(q.0), r4(q.1)]))).collect::<Map<_, _>>()});+ if let Some(m) = ov(rf, &p.name) {+ if ok && m.rem_euclid(360) != f.delta { warned.push((rf.clone(), format!("hand-set correction {m:+} disagrees with the pad fit ({:+}); the pad fit wins", signed(f.delta)))); }+ }+ if !ok { failed.push((rf.clone(), format!("best rotation leaves {:.3} mm error (> {tol:.2}): wrong footprint or mirrored part", f.residual))); }+ recs.push(rec);+ }+ let record = json!({"schema": "adom/jlcpcb-rotation-run@1", "board": board.canonicalize().unwrap_or(board.to_path_buf()).display().to_string(),+ "boardSha256": crate::sha256_file(board), "checked": crate::ledger::now(), "method": "pad-by-pad fit of JLCPCB's (EasyEDA) footprint to the board footprint",+ "tolerance_mm": tol, "parts": recs, "failed": failed.iter().map(|(r, w)| json!({"ref": r, "why": w})).collect::<Vec<_>>(),+ "warnings": warned.iter().map(|(r, w)| json!({"ref": r, "why": w})).collect::<Vec<_>>()});+ Check { record, failed, warned }+}++/// Write each part's corrected rotation (and centroid, when the origins differ) into the CPL.+pub fn apply(cpl_path: &Path, record: &Value) -> std::io::Result<()> {+ let t = Table::read(cpl_path)?;+ let by: BTreeMap<&str, &Value> = record["parts"].as_array().into_iter().flatten().filter_map(|r| r["ref"].as_str().map(|s| (s, r))).collect();+ let (ci, cr, cx, cy) = (t.col("Designator"), t.col("Rotation"), t.col("Mid X"), t.col("Mid Y"));+ let mut rows = vec![t.head.clone()];+ for mut row in t.rows.clone() {+ if let (Some(ci), Some(cr)) = (ci, cr) {+ if let Some(r) = by.get(row[ci].as_str()).filter(|r| r.get("jlcRotation").is_some()) {+ row[cr] = g(r["jlcRotation"].as_f64().unwrap_or(0.0));+ let (sx, sy) = (r["centroidShiftBoard_mm"][0].as_f64().unwrap_or(0.0), r["centroidShiftBoard_mm"][1].as_f64().unwrap_or(0.0));+ if sx.abs() > 0.005 || sy.abs() > 0.005 { // CPL Y is the board Y negated (KiCad exports y up)+ let mm = |s: &str| s.trim_end_matches("mm").parse::<f64>().unwrap_or(0.0);+ if let (Some(cx), Some(cy)) = (cx, cy) { row[cx] = format!("{:.4}mm", mm(&row[cx]) + sx); row[cy] = format!("{:.4}mm", mm(&row[cy]) - sy); }+ }+ }+ }+ rows.push(row);+ }+ std::fs::write(cpl_path, csvio::write(&rows, "\r\n"))+}++pub fn summary(c: &Check, applied: bool) -> String {+ let parts = c.record["parts"].as_array().cloned().unwrap_or_default();+ let turned: Vec<String> = parts.iter().filter(|r| r["delta"].as_i64().unwrap_or(0) != 0).map(|r| format!("{} {:+}", r["ref"].as_str().unwrap_or(""), signed(r["delta"].as_i64().unwrap_or(0)))).collect();+ format!("rotation check: {} parts, {} need a JLCPCB rotation correction ({}), {} failed{}", parts.len(), turned.len(),+ if turned.is_empty() { "none".into() } else { turned.join(", ") }, c.failed.len(), if applied { "; CPL updated" } else { "" })+}++fn wiki(args: &[&str], cwd: Option<&Path>) -> (bool, String) {+ let mut c = std::process::Command::new("adom-wiki");+ c.args(args);+ if let Some(d) = cwd { c.current_dir(d); }+ match c.output() { Ok(o) => (o.status.success(), String::from_utf8_lossy(&o.stdout).to_string() + &String::from_utf8_lossy(&o.stderr)), Err(e) => (false, e.to_string()) }+}++pub struct Evidence<'a> { pub screenshot: Option<&'a Path>, pub boards: Option<u64>, pub order: Option<&'a str> }++/// Merge one part's correction and evidence into jlcpcb-rotation.json (adom/jlcpcb-rotation@1) on its wiki page.+/// One entry per (LCSC number, KiCad footprint); evidence is deduplicated so re-publishing does not inflate counts.+pub fn publish(record: &Value, reference: &str, page: &str, ev: &Evidence, dry_run: bool) -> Result<String, String> {+ let rec = record["parts"].as_array().into_iter().flatten().find(|r| r["ref"] == reference).ok_or(format!("{reference} is not in the rotation record"))?;+ let status = rec["status"].as_str().unwrap_or("");+ if status != "ok" && status != "manual" { return Err(format!("{reference} has status {status}; only a fitted (ok) or hand-set (manual) correction is published")); }+ if status == "manual" && ev.screenshot.is_none() && ev.boards.is_none() { return Err(format!("{reference} is hand-set (no JLCPCB footprint to fit): publish it only with a JLCPCB preview screenshot or delivered boards")); }+ let by = serde_json::from_str::<Value>(&wiki(&["whoami", "--json"], None).1).ok()+ .and_then(|w| if w.get("data").is_some() { w["data"]["user"]["username"].as_str().map(str::to_string) } else { w["user"]["username"].as_str().map(str::to_string) }).unwrap_or("unknown".into());+ let (got, cur) = wiki(&["repo", "show", page, PAGE_FILE], None);+ let mut doc = if got { serde_json::from_str::<Value>(&cur).ok().filter(|d| d.get("entries").is_some()) } else { None }.unwrap_or_else(|| json!({+ "schema": "adom/jlcpcb-rotation@1", "part": rec["mpn"], "entries": [],+ "about": "Rotation to add to the KiCad footprint's rotation in a JLCPCB CPL so JLCPCB places this part pin 1 on pad 1. correction_deg is CCW-positive as seen from the top; centroidOffset_mm is in the KiCad footprint frame (mm, y down). Written by adom-aiflow-jlcpcb (pad-by-pad fit of JLCPCB's EasyEDA footprint to the KiCad footprint)."}));+ let corr = signed(rec["delta"].as_i64().unwrap_or(0));+ let entries = doc["entries"].as_array_mut().unwrap();+ let idx = match entries.iter().position(|e| e["lcsc"] == rec["lcsc"] && e["kicadFootprint"] == rec["kicadFootprint"]) {+ Some(i) => i,+ None => { entries.push(json!({"lcsc": rec["lcsc"], "kicadFootprint": rec["kicadFootprint"], "evidence": []})); entries.len() - 1 }+ };+ let e = &mut entries[idx];+ if let Some(old) = e["correction_deg"].as_i64() {+ if old != corr { return Err(format!("the page says {old:+} for {} on {} but this run found {corr:+}; settle it in JLCPCB's preview before publishing", rec["lcsc"], rec["kicadFootprint"])); }+ }+ e["correction_deg"] = json!(corr);+ e["method"] = json!(if status == "ok" { "pad-fit" } else { "hand-set" });+ for k in ["jlcFootprint", "jlcFootprintFrom", "residual_mm", "pads_compared", "pads"] { if let Some(v) = rec.get(k) { e[k] = v.clone(); } }+ if let Some(v) = rec.get("offset_mm") { e["centroidOffset_mm"] = v.clone(); }+ let today = crate::ledger::now()[..10].to_string();+ let day = record["checked"].as_str().map(|s| s[..10.min(s.len())].to_string()).unwrap_or(today.clone());+ let board_sha = record["boardSha256"].clone();+ let mut new = Vec::new();+ if status == "ok" { new.push(json!({"kind": "pad-fit", "date": day, "by": by, "boardSha256": board_sha, "residual_mm": rec["residual_mm"]})); }+ let mut shot_path = None;+ if let Some(s) = ev.screenshot {+ let ext = s.extension().and_then(|x| x.to_str()).unwrap_or("png");+ let p = format!("docs/jlcpcb-rotation/{}-{}-preview.{ext}", rec["lcsc"].as_str().unwrap_or(""), &crate::sha256_file(s)[..8]);+ let shot_day = std::process::Command::new("date").args(["-r", &s.display().to_string(), "+%F"]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).filter(|d| !d.is_empty()).unwrap_or(today.clone());+ new.push(json!({"kind": "jlc-preview", "date": shot_day, "by": by, "screenshot": p, "boardSha256": board_sha}));+ shot_path = Some(p);+ }+ if let Some(n) = ev.boards { new.push(json!({"kind": "boards-delivered", "date": today, "by": by, "count": n, "order": ev.order, "boardSha256": board_sha})); }+ let sig = |v: &Value| (v["kind"].to_string(), v["boardSha256"].to_string(), v["screenshot"].to_string(), v["order"].to_string(), v["by"].to_string());+ let have: Vec<_> = e["evidence"].as_array().cloned().unwrap_or_default().iter().map(sig).collect();+ let added: Vec<String> = new.iter().filter(|v| !have.contains(&sig(v))).map(|v| v["kind"].as_str().unwrap_or("").to_string()).collect();+ for v in new.into_iter().filter(|v| !have.contains(&sig(v))) { e["evidence"].as_array_mut().unwrap().push(v); }+ let evs = e["evidence"].as_array().cloned().unwrap_or_default();+ let count = |k: &str| evs.iter().filter(|v| v["kind"] == k).count();+ let users: std::collections::BTreeSet<String> = evs.iter().map(|v| v["by"].to_string()).collect();+ e["verified"] = json!({"padFit": count("pad-fit"), "jlcPreview": count("jlc-preview"),+ "boardsDelivered": evs.iter().filter(|v| v["kind"] == "boards-delivered").map(|v| v["count"].as_u64().unwrap_or(0)).sum::<u64>(), "users": users.len()});+ let verified = e["verified"].clone();+ doc["updated"] = json!(today);+ let text = serde_json::to_string_pretty(&doc).unwrap() + "\n";+ if dry_run { return Ok(text); }+ let tmp = std::env::temp_dir().join(format!("jlcrot-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&tmp);+ std::fs::create_dir_all(&tmp).map_err(|e| e.to_string())?;+ std::fs::write(tmp.join(PAGE_FILE), &text).map_err(|e| e.to_string())?;+ let mut files = vec![PAGE_FILE.to_string()];+ if let (Some(p), Some(s)) = (&shot_path, ev.screenshot) {+ let dest = tmp.join(p);+ std::fs::create_dir_all(dest.parent().unwrap()).map_err(|e| e.to_string())?;+ std::fs::copy(s, &dest).map_err(|e| e.to_string())?;+ files.push(p.clone());+ }+ let msg = format!("jlcpcb-rotation: {} {corr:+} on {} ({})", rec["lcsc"].as_str().unwrap_or(""), rec["kicadFootprint"].as_str().unwrap_or(""), if added.is_empty() { "no new evidence".into() } else { added.join(", ") });+ let mut args = vec!["repo", "push", page, "--files"];+ args.extend(files.iter().map(|s| s.as_str()));+ args.extend(["-m", msg.as_str()]);+ let (pushed, out) = wiki(&args, Some(&tmp));+ let _ = std::fs::remove_dir_all(&tmp);+ if !pushed { return Err(format!("push failed: {}", out.chars().take(600).collect::<String>())); }+ let (got, back) = wiki(&["repo", "show", page, PAGE_FILE], None);+ let same = got && serde_json::from_str::<Value>(&back).ok() == Some(doc.clone());+ Ok(format!("published {} {corr:+} to {page}/{PAGE_FILE} ({verified}); read back {}", rec["lcsc"].as_str().unwrap_or(""), if same { "ok" } else { "MISMATCH" }))+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn finds_a_quarter_turn() {+ // a SOT-23-6 style footprint, turned 90 degrees and shifted on the board side+ let e: Pads = [("1", (-0.95, 1.3)), ("2", (0.0, 1.3)), ("3", (0.95, 1.3)), ("4", (0.95, -1.3)), ("5", (0.0, -1.3)), ("6", (-0.95, -1.3))]+ .iter().map(|(n, p)| (n.to_string(), *p)).collect();+ let k: Pads = e.iter().map(|(n, p)| { let r = rot(*p, 270.0); (n.clone(), (r.0 + 0.1, r.1)) }).collect();+ let f = fit(&e, &k).unwrap();+ assert_eq!((f.delta, signed(f.delta)), (270, -90));+ assert!(f.residual < 1e-9 && (f.offset.0 - 0.1).abs() < 1e-9);+ }+}
src/swap.rsadded+180@@ -0,0 +1,180 @@+//! A substitute part, proven against the one the design chose, from both datasheets.+//!+//! The common case: a BOM part is not stocked at JLCPCB (a JLCPCB build needs LCSC numbers), or a JLCPCB basic part+//! avoids the per-type extended-part fee. A swap is a design decision, so it is checked against facts, not names. The+//! facts file is the AI's reading of BOTH datasheets, with where each number came from:+//!+//! { "ref": "L1", "kind": "inductor" | "resistor" | "capacitor" | "electrolytic" | "led" | "ic" | "other",+//! "original": { "mpn", "manufacturer", "datasheet": "url (rev, page)", "params": {...}, "terminals": [...], "polarity": {...},+//! "lcscSearch": { "queries": ["EEE-FK1V470P", "EEEFK1V470P"], "found": false, "checked": "2026-10-05" } },+//! "candidate": { "mpn", "manufacturer", "lcsc": "C123", "stock": 1234, "stockChecked": "2026-10-05", "jlcLibrary": "Basic",+//! "datasheet", "params": {...}, "terminals": [{"pad": "1", "x": -2.0, "y": 0, "w": 1.5, "h": 3.0}], "polarity": {...} },+//! "requires": { "isat_A": [">=", 2.5, "calcs.json inductor.Isat_required_A"] },+//! "reason": "basic-part" (optional), "waive": {"<check>": "why"}, "slack": {"esr_ohm": 0.2}, "drive": {...}, "notes": "..." }+//!+//! params: value (L_uH | C_uF | R_ohm, within the original's tolerance), tol_pct (<=), ratings (>=: voltage_V isat_A+//! irms_A power_W ripple_mA if_max_mA tc_max_C), losses (<= x (1 + slack): dcr_mohm_max esr_ohm tcr_ppm leakage_uA),+//! dielectric (C0G > X7R > X5R > X6S > Y5V), temp_C [min, max] (contains), body_mm.H (<= original + 0.3),+//! wavelength_nm (within 15 nm). A design limit in `requires` decides over the original's rating for that quantity.+//! Terminals must sit >= 85 % on their own pad; polarity must agree.+use crate::csvio::{self, Table};+use crate::kicad::{Footprint, Pad};+use serde_json::{json, Value};+use std::path::Path;++const GE: [&str; 7] = ["voltage_V", "isat_A", "irms_A", "power_W", "ripple_mA", "if_max_mA", "tc_max_C"];+const LE: [&str; 4] = ["dcr_mohm_max", "esr_ohm", "tcr_ppm", "leakage_uA"];+const VALUE: [&str; 3] = ["L_uH", "C_uF", "R_ohm"];++fn rank(d: &str) -> i32 {+ match d.to_uppercase().as_str() { "C0G" | "NP0" => 5, "X8R" | "X7R" => 4, "X7S" | "X6S" | "X5R" => 3, "X7T" => 2, "Y5V" | "Z5U" => 1, _ => 0 }+}++pub struct Row { pub check: String, pub pass: bool, pub detail: String, pub info: bool }++fn n(v: &Value) -> Option<f64> { v.as_f64() }+fn s(v: &Value) -> String { match v { Value::Null => "None".into(), Value::String(x) => x.clone(), Value::Number(x) => x.as_f64().map(gnum).unwrap_or(x.to_string()), other => other.to_string() } }+fn gnum(v: f64) -> String { crate::kicad::g(v) }++/// The candidate's terminal rectangle against one board pad, as the fraction of the terminal on copper. The pad's own+/// rotation is relative to the footprint (KiCad stores pad angles with the footprint's angle included).+fn on_pad(t: (f64, f64, f64, f64), p: &Pad, fp_rot: f64) -> f64 {+ let (w, h) = if ((p.rot - fp_rot).round() as i64).rem_euclid(180) == 90 { (p.h, p.w) } else { (p.w, p.h) };+ let ov = |a0: f64, a1: f64, b0: f64, b1: f64| (a1.min(b1) - a0.max(b0)).max(0.0);+ let area = t.2 * t.3;+ if area <= 0.0 { return 0.0; }+ ov(t.0 - t.2 / 2.0, t.0 + t.2 / 2.0, p.x - w / 2.0, p.x + w / 2.0) * ov(t.1 - t.3 / 2.0, t.1 + t.3 / 2.0, p.y - h / 2.0, p.y + h / 2.0) / area+}++pub fn check(f: &Value, fp: Option<&Footprint>, board_given: bool) -> Vec<Row> {+ let (o, c) = (&f["original"], &f["candidate"]);+ let (po, pc) = (&o["params"], &c["params"]);+ let req = &f["requires"];+ let mut rows = Vec::new();+ let mut add = |name: String, ok: bool, detail: String, info: bool| rows.push(Row { check: name, pass: ok, detail, info });+ for k in VALUE {+ let Some(vo) = n(&po[k]) else { continue };+ let Some(vc) = n(&pc[k]) else { add(k.into(), false, "candidate datasheet value missing".into(), false); continue };+ let tol = n(&po["tol_pct"]).unwrap_or(0.0).max(0.5) / 100.0;+ add(k.into(), (vc - vo).abs() <= tol * vo + 1e-12, format!("{} vs {} (original +/-{} %)", gnum(vc), gnum(vo), gnum(tol * 100.0)), false);+ }+ if let Some(t) = n(&po["tol_pct"]) { add("tolerance".into(), n(&pc["tol_pct"]).unwrap_or(1e9) <= t, format!("{} % vs {} %", s(&pc["tol_pct"]), s(&po["tol_pct"])), false); }+ for k in GE {+ let Some(vo) = n(&po[k]) else { continue };+ let design = req.get(k).is_some();+ add(k.into(), n(&pc[k]).map(|v| v >= vo).unwrap_or(false), format!("{} vs original {}{}", s(&pc[k]), s(&po[k]), if design { " (the design limit below decides)" } else { " (needs >=)" }), design);+ }+ for k in LE {+ let Some(vo) = n(&po[k]) else { continue };+ let sl = n(&f["slack"][k]);+ let design = req.get(k).is_some();+ add(k.into(), n(&pc[k]).map(|v| v <= vo * (1.0 + sl.unwrap_or(0.0))).unwrap_or(false),+ format!("{} vs original {}{}{}", s(&pc[k]), s(&po[k]), sl.map(|x| format!(" +{} % allowed", (100.0 * x).round())).unwrap_or_default(), if design { " (the design limit below decides)" } else { " (needs <=)" }), design);+ }+ if let Some(d) = po["dielectric"].as_str() {+ let dc = pc["dielectric"].as_str().unwrap_or("");+ add("dielectric".into(), rank(dc) >= rank(d), format!("{} vs {d}", s(&pc["dielectric"])), false);+ }+ if let Some(t) = po["temp_C"].as_array() {+ let (t0, t1) = (n(&t[0]).unwrap_or(0.0), n(&t[1]).unwrap_or(0.0));+ let tc = pc["temp_C"].as_array();+ let ok = tc.map(|a| n(&a[0]).unwrap_or(1e9) <= t0 && n(&a[1]).unwrap_or(-1e9) >= t1).unwrap_or(false);+ add("temperature range".into(), ok, format!("{} covers {}", tc.map(|a| Value::from(a.clone()).to_string()).unwrap_or("[1e9, -1e9]".into()).replace(',', ", "), po["temp_C"].to_string().replace(',', ", ")), false);+ }+ if let Some(w) = n(&po["wavelength_nm"]) {+ add("colour".into(), n(&pc["wavelength_nm"]).map(|v| (v - w).abs() <= 15.0).unwrap_or(false), format!("dominant {} nm vs {} nm (within 15)", s(&pc["wavelength_nm"]), s(&po["wavelength_nm"])), false);+ }+ let body = |side: &Value, p: &Value| if side["body_mm"].is_object() { side["body_mm"].clone() } else { p["body_mm"].clone() };+ let (bo, bc) = (body(o, po), body(c, pc));+ if let (Some(h0), Some(h1)) = (n(&bo["H"]), n(&bc["H"])) { if h0 != 0.0 && h1 != 0.0 { add("height".into(), h1 <= h0 + 0.3, format!("{} mm vs {} mm", gnum(h1), gnum(h0)), false); } }+ if let Some(r) = req.as_object() {+ for (k, spec) in r {+ let (op, lim, src) = (spec[0].as_str().unwrap_or(""), n(&spec[1]).unwrap_or(0.0), s(&spec[2]));+ let v = n(&pc[k]);+ let ok = v.map(|v| match op { ">=" => v >= lim, "<=" => v <= lim, "==" => (v - lim).abs() <= 1e-9 * lim.abs().max(1.0), _ => false }).unwrap_or(false);+ add(format!("design: {k} {op} {}", gnum(lim)), ok, format!("{} (from {src})", s(&pc[k])), false);+ }+ }+ // land pattern: every candidate terminal on its own pad+ let kind = f["kind"].as_str().unwrap_or("");+ match (board_given, c["terminals"].as_array()) {+ (true, Some(ts)) => {+ let reference = f["ref"].as_str().unwrap_or("");+ match fp {+ None => add("land fit".into(), false, format!("no footprint {reference} on the board"), false),+ Some(fp) => for t in ts {+ let pad = match &t["pad"] { Value::String(x) => x.clone(), v => v.to_string() };+ let Some(p) = fp.pads.get(&pad) else { add(format!("land fit pad {pad}"), false, format!("no pad {pad} on {reference}"), false); continue };+ let tr = (n(&t["x"]).unwrap_or(0.0), n(&t["y"]).unwrap_or(0.0), n(&t["w"]).unwrap_or(0.0), n(&t["h"]).unwrap_or(0.0));+ let inside = on_pad(tr, p, fp.rot);+ add(format!("land fit pad {pad}"), inside >= 0.85, format!("{:.0} % of the terminal on pad {pad} ({:.2} x {:.2} at {:.2}, {:.2})", 100.0 * inside, p.w, p.h, p.x, p.y), false);+ },+ }+ }+ (_, None) if c.get("terminals").map(|v| v.is_null()).unwrap_or(true) && kind != "resistor" && kind != "capacitor" => {+ add("land fit".into(), false, "candidate terminals not given: read them off its datasheet drawing".into(), false);+ }+ _ => {}+ }+ let (pol_o, pol_c) = (&o["polarity"], &c["polarity"]);+ if !pol_o.is_null() || !pol_c.is_null() { add("polarity".into(), pol_o == pol_c, format!("original {}, candidate {}", s(pol_o), s(pol_c)), false); }+ if kind == "led" { if let (Some(vf), Some(d)) = (n(&pc["vf_V"]), f["drive"].as_object()) {+ let i = (n(&d["supply_V"]).unwrap_or(0.0) - vf) / n(&d["R_ohm"]).unwrap_or(1.0) * 1000.0;+ add("LED current".into(), n(&d.get("min_mA").cloned().unwrap_or(json!(0.5))).unwrap_or(0.5) <= i && i <= n(&pc["if_max_mA"]).unwrap_or(1e9),+ format!("{i:.2} mA through {} ohm from {} V (Vf {} V)", s(&d["R_ohm"]), s(&d["supply_V"]), gnum(vf)), false);+ } }+ if c["lcsc"].as_str().map(|x| x.is_empty()).unwrap_or(true) { add("LCSC number".into(), false, "a JLCPCB build needs the candidate's LCSC number".into(), false); }+ // the exact part first: a swap is only justified when the original is really not orderable, or a basic part saves the fee+ if f["reason"] == "basic-part" {+ let lib = c["jlcLibrary"].as_str();+ add("candidate is a JLCPCB basic part".into(), lib == Some("Basic"), format!("{} (checked on {})", lib.unwrap_or("not checked"), c["jlcLibraryChecked"].as_str().unwrap_or("?")), false);+ } else {+ match o["lcscSearch"].as_object() {+ None => add("original not on LCSC".into(), false, "search for the original MPN first (with and without hyphens: Panasonic EEE-FK1V470P is listed as EEEFK1V470P) and record original.lcscSearch {queries, found, checked}".into(), false),+ Some(sr) => {+ let q: Vec<String> = sr.get("queries").and_then(|v| v.as_array()).map(|a| a.iter().map(s).collect()).unwrap_or_default();+ let not_found = sr.get("found") == Some(&json!(false));+ add("original not on LCSC".into(), not_found, format!("searched {} on {}: {}", q.join(", "), sr.get("checked").map(s).unwrap_or("?".into()), if not_found { "not found" } else { "FOUND, order the original instead" }), false);+ }+ }+ }+ rows+}++/// Rewrite every BOM row that carried the original MPN (or only this reference with "all_refs": false).+pub fn apply(bom_path: &Path, f: &Value) -> std::io::Result<Vec<String>> {+ let t = Table::read(bom_path)?;+ let (old, c) = (f["original"]["mpn"].as_str().unwrap_or(""), &f["candidate"]);+ let reference = f["ref"].as_str().unwrap_or("");+ let all = f["all_refs"].as_bool().unwrap_or(true);+ let stock_col = t.head.iter().position(|h| h.starts_with("stock_checked"));+ let idx = |k: &str| t.col(k);+ let today = crate::ledger::now()[..10].to_string();+ let mut changed = Vec::new();+ let mut rows = vec![t.head.clone()];+ for mut r in t.rows.clone() {+ let get = |r: &Vec<String>, k: &str| idx(k).and_then(|i| r.get(i)).cloned().unwrap_or_default();+ if get(&r, "mpn") == old && (get(&r, "ref") == reference || all) {+ let set = |r: &mut Vec<String>, k: &str, v: String| if let Some(i) = idx(k) { if r.len() <= i { r.resize(i + 1, String::new()); } r[i] = v; };+ let lcsc = c["lcsc"].as_str().unwrap_or("").to_string();+ set(&mut r, "mpn", c["mpn"].as_str().unwrap_or("").into());+ set(&mut r, "manufacturer", c["manufacturer"].as_str().unwrap_or("").into());+ if !lcsc.is_empty() { set(&mut r, "source", "JLCPCB".into()); }+ if idx("lcsc").is_some() { set(&mut r, "lcsc", lcsc.clone()); } else if idx("vendor_pn").is_some() { set(&mut r, "vendor_pn", lcsc.clone()); }+ if let Some(i) = stock_col { r[i] = format!("{} (checked {})", s(&c["stock"]).replace("None", "?"), c["stockChecked"].as_str().unwrap_or(&today)); }+ if idx("note").is_some() {+ let note = format!("substitute for {old}, verified: design/swaps/{reference}.result.json; {}", get(&r, "note"));+ set(&mut r, "note", note.trim_matches(|ch| ch == ';' || ch == ' ').to_string());+ }+ changed.push(get(&r, "ref"));+ }+ rows.push(r);+ }+ std::fs::write(bom_path, csvio::write(&rows, "\n"))?;+ Ok(changed)+}++pub fn print(rows: &[Row]) -> String {+ let w = rows.iter().map(|r| r.check.len()).max().unwrap_or(10);+ rows.iter().map(|r| format!(" {} {:<w$} {}\n", if r.pass || r.info { if r.info { "note" } else { "PASS" } } else { "FAIL" }, r.check, r.detail)).collect()+}
uninstall.shadded+6@@ -0,0 +1,6 @@+#!/bin/bash+# adom-wiki package uninstall hook: remove the binary and the skill.+NAME="adom-aiflow-jlcpcb"+rm -f "$HOME/.local/bin/$NAME"+for consumer in .claude .codex; do rm -rf "$HOME/$consumer/skills/$NAME"; done+echo "OK: $NAME removed"