Skip to content
bitcrushtestingPublic

About

Auto create test fixtures from KiCad projects

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Pinside

CI PyPI Python 3.10+ License: MIT Dependencies: none Ruff

Read a KiCad board and answer three questions: can a bed-of-nails fixture be built against it?, what does that fixture board look like?, and what firmware does it run?

A pogo-pin fixture is three lists taken from the device under test: where the test pads are, where it can be bolted down, and how big it is. All three are already in the .kicad_pcb, so copying them by hand is how a fixture ends up 0.3 mm out with nobody able to say why. Pinside reads them instead, and then checks the things that only turn up after the fixture comes back from the fab.

The board file is only ever read. Pinside never writes KiCad sources.

A direct fixture, routed: ten pogo pins standing above the plate, a 9-way D-sub on the back

A generated direct fixture, in KiCad's own 3D view: every probe at its DUT test point's coordinates, the pins standing above the plate, and the board's identity on its silkscreen. The footprints and the 3D models are written into the project, so it opens on a machine that has never heard of this probe. The copper between the pads is not pinside's -- see tools/fastroute for the proof of concept that put it there.

Install

pip install pinside

No dependencies; Python 3.10+. Add pinside[client] for the one command that needs pyserial:

pip install 'pinside[client]'     # ... if you want `pinside probe` as well

It also runs straight from a checkout, with nothing installed:

PYTHONPATH=src python3 -m pinside check board.kicad_pcb

Try it

examples/ has a small board that passes every check, and the fixture config drafted from it:

pinside check examples/demo-board.kicad_pcb           # a board with nothing wrong with it
pinside project examples/demo-fixture.json --out /tmp/demo-board
pinside generate examples/demo-fixture.json --out /tmp/demo-firmware
/tmp/demo-firmware/test/run.sh                        # host tests, no board needed

Use

pinside check board.kicad_pcb                       # probes, holes and findings
pinside check board.kicad_pcb -f csv > probes.csv   # placement data for a spreadsheet or script
pinside check board.kicad_pcb -f json               # everything, findings included
pinside check board.kicad_pcb -f svg > plan.svg     # 1:1 drill plan; print and lay it on the board
pinside check board.kicad_pcb --mirror x            # the frame for a face-down DUT

pinside init board.kicad_pcb -o fixture.json        # draft a config covering every test point
pinside init board.kicad_pcb --carrier bare        # ... for a fixture carrying the chip itself
pinside project fixture.json --out fixture-board/   # a KiCad project for the fixture
pinside generate fixture.json --out firmware/       # firmware that matches the board

pinside probe fixture.json                          # talk to a flashed fixture and check it

pinside board.kicad_pcb with no subcommand still means check.

probe is the only command that needs anything installed beyond the standard library: pip install 'pinside[client]' for pyserial.

Exit status is 0 clean, 1 warnings under --strict, 2 errors, 3 bad usage, so it drops into CI as a gate on the board, not just as a report.

Coordinates

KiCad board space has +X right, +Y down, origin at the page corner. --origin outline (the default) shifts everything so the outline's top-left corner is (0, 0), which is what you want when the fixture is drawn as its own board. --mirror x additionally flips X: that is the transform for a DUT laid face-down onto upward-pointing probes. Getting it wrong yields a perfect mirror image of the fixture you need, which is not obvious until the pins miss.

Every row carries both frames: dut_x/dut_y as the board stores them, and fix_x/fix_y after the transform, so you can always check one against the other.

Tuning the limits

The checks are measured against real hardware, and the defaults describe a Mill-Max 0985 receptacle. Change them when your probes differ:

pinside board.kicad_pcb --probe-pitch 1.9 --probe-body 1.27 --edge-clearance 1.5 --min-pad 0.7
pinside board.kicad_pcb --probe-force 0.55 --max-force 100 --max-deflection 0.5

--ignore PS041,PS042 silences findings you have already decided about.

Baselines

--ignore is per-invocation and global: it silences a code on every board, forever. That is the wrong shape for the usual situation, which is a board with two findings somebody has looked at and accepted and one that has not happened yet.

pinside check board.kicad_pcb --write-baseline pinside-baseline.json
pinside check board.kicad_pcb --baseline pinside-baseline.json   # exit 0 for the accepted ones

A baseline records each accepted finding by code and by reference. Accepting PS041 on TP5 says nothing about PS041 on TP9, so a new occurrence still fails while the old one stays quiet. That is what makes the file safe to commit: it cannot absorb a finding nobody has seen. Every entry is written with an empty note; fill them in before committing, because a suppression whose reason nobody wrote down is indistinguishable from a mistake six months later.

generate and project take --baseline too, and both accept --json to put their findings on stdout as a machine-readable object rather than prose on stderr:

pinside generate fixture.json --out firmware/ --json | jq '.errors'

What it checks

Code Severity What it means
PS001 error No Edge.Cuts outline: the board size is unknown
PS002 error Edge.Cuts segments that close into nothing: unfillable, unmillable
PS003 info The outline has internal cutouts; they are holes, not a broken edge
PS004 warning Closed Edge.Cuts shapes outside the board: a panel, or a leftover
PS010 error Test points sit outside the outline, so they were never placed
PS011 error Mounting holes sit outside the outline
PS012 warning The probes lie on a uniform lattice: KiCad's import spread, not a layout
PS013 error A test point sits over a cutout, where there is no board
PS014 error A mounting hole sits over a cutout, so there is nothing to bolt to
PS020 error Two test points share a position
PS021 error Two probes are closer than the receptacle pitch; the bodies collide. Names the probe that would clear them
PS022 warning A probe is close enough to the board edge that the receptacle's own pad does not fit beside it
PS023 warning A probe crowds a mounting hole, where the standoff lives
PS024 error A probe lands inside another footprint's courtyard, so it would strike the component
PS025 warning A test pad is too small for a spring tip plus placement tolerance
PS026 warning Test points on both sides; one plate cannot reach them all
PS027 warning The tip clears a component's courtyard but the receptacle body does not
PS028 warning The probes add up to more force than a hand clamp will close
PS029 warning That force bows the DUT between its supports, so contact goes uneven
PS030 error No ground test point: there is no return path to measure against
PS031 warning Only one ground probe
PS032 info Plated mounting holes carry no net; grounding them is a free return path
PS033 warning A supply rail on the board has no test point, so nothing proves it came up
PS034 info A reset or strap line has no test point: readable, but not resettable
PS035 warning Two test points declare the same role on one net
PS038 warning The only declared grounds are power returns, so measurements ride on their drop
PS040 warning A test point has no net, so it probes nothing
PS041 info A test point is on a KiCad auto-named net; label it in the schematic
PS042 info Two probes on one signal net: a wasted fixture channel
PS043 error One net number with several names: KiCad will merge them and drop the rest
PS044 error A named net on number 0, KiCad's no-connection net, so it probes nothing
PS045 error A label references a net with no test point, so the fixture cannot reach it
PS046 error A test-point label could not be read, so its intent is unknown
PS047 warning A label disagrees with what its own net name says
PS048 info A label references a net this board does not have
PS049 info Some test points carry a label and some do not
PS050 warning Fewer mounting holes than are needed to locate the board
PS051 info Mounting holes have differing drill sizes
PS052 info Probes lie outside the mounting-hole span, where the plate cantilevers. project adds standoffs of its own there
PS054 warning A label carries a key this pinside does not recognise
PS055 warning A presented channel names no reference, so it has no far end to reach
PS056 error A part stands into the room under the DUT, so the plate meets it before the pins compress
PS057 info The room the DUT's tallest declared part needs, for choosing standoffs

PS010 and PS012 are the two that matter most in practice: both mean the layout is not finished, and any fixture cut from those coordinates is scrap.

PS028 and PS029 are about force rather than geometry, and they are the two that only appear on a board somebody kept adding test points to. One spring pin pushes 0.75 N, which is nothing; two hundred of them push 150 N, which is a press. That load closes the fixture, is carried by the DUT's standoffs, and between them the board is an unsupported plate with the whole of it spread across its middle. It bows, the probes in the centre over-travel while the outer ones stop reaching, and the fixture reads intermittent on whichever channel is furthest from a hole. The bow is estimated as a uniformly loaded, simply supported plate, using the stackup thickness from the board file when it declares one and 1.6 mm FR-4 when it does not.

PS043 and PS044 are about net numbering rather than geometry. Through KiCad 9 a net is identified by its ordinal and the name beside it is only a label, so two names on one number are one net as far as KiCad is concerned: it keeps the first and drops the rest to no-net on the next save. The file stays well-formed and nothing warns anyone. pinside's own example board had exactly this, and pinside called it clean for two releases. Both checks are verified against kicad-cli pcb export ipcd356, which is KiCad's own answer to what a board's netlist is.

PS024, PS027 and PS056 are the three that need a part's height, and a board file does not carry one: it is in the 3D model reference and nowhere a checker can read it. So the clearance checks are planar by default and assume the worst -- that every part reaches the receptacle body. Declaring what a part actually stands at makes them three-dimensional:

"fixture": { "clearance_mm": 8.0 },
"dut": {
  "component_heights": {
    "C1": 11.2,
    "J*": 9.0,
    "Capacitor_SMD:C_0402*": 0.5
  }
}

A key is a reference or a glob, and the glob is matched against the reference and the footprint's library id, so a family can be declared once. A reference beats a pattern, and among patterns the tallest match wins -- several can match one part, dict order is not a specification, and taking the lower number is wrong in the direction that breaks a plate. On the command line the same facts are --clearance 8.0 --height C1=11.2 --height "J*=9.0".

clearance_mm is the room between the DUT's probed face and the fixture surface, which is whatever the standoffs leave. Nothing is checked against a height until it is declared: the standoff length is a choice pinside cannot read off any file, and a guessed gap would either invent collisions or excuse real ones. Declare heights alone and PS057 reports the gap they need, which is the number to choose standoffs by; declare both and PS056 reports every part that will not fit in it.

Labelling the DUT

Everything above infers a probe's purpose from its net name. That works until it does not: a net KiCad auto-named says nothing, and a board whose conventions differ from the regexes gets quietly misread.

A Pinside field on a test point in your board says what the pin is instead:

Pinside = uart:tx baud=115200
Pinside = rtd:pt100 ref=RTD_LO range=-20..150
Pinside = power:in volts=5.0 current=0.6 sense=VBUS_SENSE
Pinside = vref:out volts=5.0
Pinside = ignore

Roles fall into three groups, and only one of them is a test: pins the fixture measures, pins where it presents the thing under test, and service pins that exist so the fixture can work — a power feed, its return, a Kelvin sense tap, a reference voltage in either direction. That last group is why the split matters: a power pin counted as test coverage proves nothing, and require_all_test_points has to know the difference.

Labelling is optional and additive — an unlabelled board behaves exactly as it does now. What it buys you is an init that drafts a config you do not have to correct, a board that states which pads are plumbing rather than measurements, and the ability to describe something involving two pins, which no net name can express.

Full grammar, where the field goes in KiCad, and what the checks tell you: docs/writing-test-point-labels.md. The reader ships in 0.3.0; labels added now are carried by KiCad and read when it lands.

A fixture with no electronics on it

Not every fixture measures anything. A direct fixture is a passive interposer: pogo pins on one side, a connector on the other, and all the instrumentation external, a switch matrix, a bench meter, somebody else's tester. The fixture is then a wiring harness with precise geometry, which is the hard part and the part pinside is for.

{
  "name": "test-harness",
  "fixture": { "mode": "direct", "connector": "dsub_78", "probe": "millmax_0985", "mirror": "x" },
  "dut": { "board": "../board.kicad_pcb" }
}
pinside project harness.json --out harness/          # probes, connector, pin map
pinside generate harness.json                        # refuses: there is no firmware here

A direct fixture has no channels. It probes whatever the DUT brings out, so there is no pin budget to spend and nothing to allocate; the connector's way count is the limit instead. Label a pad Pinside = ignore and the fixture will not touch it -- in direct mode that label is the only thing between "the DUT has a pad there" and "drill a hole and press a receptacle into it".

Connector Ways Shell Width
dsub_78 (default) 78, 8 kept for returns DD-78 high density, four rows 67 mm
dsub_62 62, 8 kept for returns DC-62 high density, three rows 69.4 mm
header_78 78, 8 kept for returns 2x39 pin header, 0.1 in 5 x 99 mm
dsub_37 37, 4 kept for returns DC-37 69.4 mm
dsub_25 25, 3 kept for returns DB-25 53.1 mm
dsub_15 15, 2 kept for returns DA-15 39.2 mm
dsub_15hd 15, 2 kept for returns DE-15 high density, three rows 30.85 mm
dsub_9 9, 1 kept for returns DE-9 30.85 mm

The width is the column to read first. A fixture is the DUT's own outline, so the connector has to stand on the board being tested: a 78-way shell is 67 mm wide and has nowhere to go on a 50 x 40 mm board. dsub_15hd is the useful one at the small end -- the same DE shell as the 9-way, six more ways. PF054 refuses a fixture with more signals than its connector has ways, and says which ones did not fit.

pinside draws every footprint it places. A generated board references no footprint library at all -- not KiCad's, not anybody's. The pogo receptacle, the guide hole, the mounting hole and every connector above are written into the project's own pinside.pretty from dimensions recorded in connectors.py, because a board that depends on a library depends on that library's version: a part renamed between KiCad releases, or absent on the machine the fab files are finally plotted from, is a board that stops opening.

The dimensions are checked rather than trusted. For every connector KiCad does ship, the test suite compares pinside's generated pads against KiCad's own footprint, pad for pad, measured from pin 1 -- which is how the high-density shells were caught: they stagger their middle row half a pitch and are not symmetric about the jackscrews the way a standard-density shell is.

KiCad ships the high-density family at 15, 26, 44 and 62 ways, and not at 78, so the DD-78 is the one entry with nothing to check against: four rows of 20/19/20/19 on a 2.41 mm contact pitch, rows 2.08 mm apart, 1.05 mm drills, jackscrews 61 mm apart, off the part's drawing. A DD-78 is four rows and a DC-62 is three, so deriving one from the other produces a footprint wrong in every pad. Those numbers were checked pad-for-pad against an independently written footprint for the same part and agree to 0.005 mm.

The generated README carries the pin map, and on a harness that map is the product: whatever is on the other end is wired against it and has nothing else to go on. Ways are assigned from the probe order and are stable, so adding a test point does not renumber the ones already wired.

The guide plate

pinside project fixture.json --out board/ --guide --guide-hole 0.62

A bed-of-nails is two plates. The probe plate carries the receptacles and the copper; the guide plate sits above it on the same standoffs, with a close-fitting hole for every pin, and holds each one perpendicular near its tip. A pin leaning one degree over 10 mm of exposed length has moved 0.17 mm where it lands, which is most of the placement budget on a small pad -- and the symptom is an intermittent channel rather than anything that looks mechanical.

It is a drilled sheet: no copper, unplated holes, same outline and mounting holes, so it panels alongside the probe plate for almost nothing.

--guide-hole is not optional, and that is deliberate. The hole has to fit the spring pin, and the probe catalogue records the receptacle -- its press-fit hole, its pad, its body. Those are different diameters, and a hole sized on the receptacle's would guide nothing. pinside will not infer one from the other: a guessed mechanical fit is a plate that binds every probe or guides none of them. Measure one pin with a micrometer, or fill in guide_dia_mm in pogo.py from the supplier's drawing.

The fixture board

pinside project writes a KiCad project for the fixture itself. The part it generates is the part that has to be exact:

  • Every probe at its DUT test point's own coordinates, carried through the fixture transform.
  • The outline and mounting holes, taken from the DUT, so the two boards bolt together.
  • A pogo receptacle footprint, generated to the chosen probe's dimensions, with the project's fp-lib-table already pointing at it.
  • A GND pour on both copper layers, held back from the board edge. On a fixture that pour is most of the return path, and unlike a trace it is one fixed shape rather than a guess.

Routing is not generated. A ratsnest and an accurate drill plan are the useful part; guessing trace paths is not, and an autorouter or a person does it better.

The generated README.md turns the plate force into hardware: how many newtons the probes add up to, whether that needs a clamp or a thumbscrew, what each standoff carries, and how long the standoffs have to be to leave the probes their travel.

pinside project fixture.json --out fixture-board/

It refuses to lay out a fixture against a DUT that has not been laid out itself. Unplaced test points would put every hole in the wrong place, and nothing about the output would look wrong until the boards came back:

pinside: 3 error(s); no project was written
pinside: error: PS010 30 of 30 test points sit outside the board outline ...

The default board

The fixture is built around a Raspberry Pi Pico 2 unless told otherwise. Soldering a module onto a carrier costs one part and no support circuitry (no crystal, no flash, no USB connector, no regulator), and it unplugs when a probe shorts something.

A module exposes only some of its chip's pins, and pinside checks against the board rather than the chip:

error: PF024 1 pins are not brought out on the pico2 [GPIO25 (led)] -- GPIO23/24/25/29 are
       consumed by the module ... or set target.board to "bare" and put the rp2350a on the
       fixture itself.
Board Chip GPIO on the header
pico2 (default) RP2350A 26
pico2w RP2350A + wireless 26
bare whatever target.mcu says all of them

Naming a board is enough; the chip follows from it. A fixture needing more than 26 channels has to carry the chip itself, which is what bare is for.

The targets

target.mcu is what bare needs, and what a carrier board resolves to. targets.py holds pin capability rather than a datasheet: how many GPIO the part has, which of them reach an ADC, and which peripheral function each one can carry.

target.mcu Package GPIO ADC inputs
rp2350b (the init default) QFN-80 48 8, on GPIO40-47
rp2350a QFN-60 30 4, on GPIO26-29
rp2354b QFN-80, 2 MB stacked flash 48 8, on GPIO40-47
rp2354a QFN-60, 2 MB stacked flash 30 4, on GPIO26-29

The stacked-flash parts are the plain ones with flash in the package, so they share a pin map; KiCad's own library says the same thing, and the tests check pinside's map against it.

All four are the RP2350 family, so one HAL covers them. Adding a part from another vendor means a second HAL beside fixture_hal_rp2350.c. That contract is 18 functions and CONTRIBUTING.md scopes what implementing it involves.

The default probe

millmax_0985: a Mill-Max 0985 receptacle with an 0900 spring pin, on a 2.54 mm pitch. The receptacle is what makes a fixture maintainable: a worn pin pulls out and a new one goes in.

Probe Hole Pad Minimum pitch Force
millmax_0985 (default) 1.37 mm 2.29 mm 2.54 mm 0.75 N
millmax_0906 1.02 mm 1.70 mm 1.91 mm 0.55 N
soldered_1mm 1.02 mm 1.60 mm 2.00 mm 0.70 N

PS022's limit comes from the probe as well, and from the fab rather than from an imagined enclosure: it is the receptacle's own pad radius plus the copper-to-edge minimum the generated design rules state. The plate pinside writes has no wall -- it is the DUT's outline -- so a wall is something a fixture declares with fixture.edge_wall_mm when it really has one.

The probe sets both the spacing limit and the force, so choosing a finer one relaxes the PS021 check and lowers the load PS028 and PS029 measure, without a second edit.

Dimensions are what pinside builds to, so check them against your supplier's drawing before ordering. Each entry records where its numbers came from and whether anyone has done that check; none has yet, and the generated fixture README says so where somebody about to order a board will see it.

Mirroring

fixture.mirror defaults to x, because a bed-of-nails takes the DUT face-down onto upward-pointing pins. Get it wrong and the board is a perfect mirror image of the one you need. Before ordering, print pinside check <dut> -f svg at 1:1 and lay the real board on it.

What KiCad does next

Open the project and run Update PCB from Schematic. The resistors and the controller arrive from KiCad's own libraries with correct pads and nets, and the probes stay exactly where pinside put them: KiCad matches footprints by UUID, and pinside derives those from the config rather than randomising them, so regenerating an unchanged config produces a byte-identical project.

pinside project needs KiCad installed, because a schematic embeds a copy of every symbol it places and the only honest source for those is a KiCad library. check and generate do not.

KiCad versions

Format
Board files check and generate read tested against 20241229 (KiCad 8/9) and the KiCad 10 net record
Files project writes .kicad_pcb 20260206, .kicad_sch 20260306 (KiCad 10)
project output opened and checked in KiCad 10.0.3

The reader is deliberately tolerant: it walks the S-expressions looking for the tags it cares about and ignores everything else, so a file from a version it has never seen still reads, and a newer version that adds tags does not break it. The one format change it handles explicitly is the net record, which was (net <ordinal> "NAME") through KiCad 9 and is (net "NAME") in 10. Both forms are covered by tests.

The writer emits one version and does not negotiate. Opening a generated project in an older KiCad will not work.

Firmware

A fixture is only half a tool: something has to drive those probes and hand what it sees to whoever is running the test. pinside generate writes that firmware from a JSON config, and refuses to write anything until the config agrees with two separate realities.

The microcontroller. Pin roles are checked against the target's function map, so a bus that cannot work is a message rather than a silent dead line:

error: PF021 GPIO9 cannot be i2c0 sda [uext.sda] -- on rp2350b it is i2c0 scl;
       i2c0 sda is available on GPIO0, GPIO4, GPIO8, GPIO12, ...

The board. Every probe in the config must name a real test point, and by default every test point must reach a channel. A config that has drifted from the board fails before it becomes firmware that lies about what it can reach.

The config

{
  "name": "test-fixture",
  "target": { "mcu": "rp2350b", "clock_hz": 150000000 },
  "dut": { "board": "../board.kicad_pcb", "require_all_test_points": true },

  "uart": [{ "name": "dut_uart", "peripheral": 0, "baud": 115200, "stream": true,
             "pins":   { "tx": 0,        "rx": 1,        "cts": 2,       "rts": 3 },
             "probes": { "tx": "DUT_RXD", "rx": "DUT_TXD", "cts": "DUT_RTS", "rts": "DUT_CTS" } }],

  "spi":  [{ "name": "eth_spi", "peripheral": 1, "hz": 8000000, "guard": "esp_en",
             "pins":   { "miso": 12, "cs": 13, "sclk": 14, "mosi": 15 },
             "probes": { "miso": "ETH_MISO", "cs": "ETH_CS",
                         "sclk": "ETH_CLK", "mosi": "ETH_MOSI" } }],

  "gpio": [{ "name": "esp_en", "pin": 24, "probe": "ESP_EN",
             "direction": "open_drain", "active_low": true, "initial": "released" }],

  "adc":  [{ "name": "dut_3v3", "pin": 40, "probe": "+3.3V",
             "divider": 2.0, "nominal_v": 3.3, "tolerance_v": 0.15 }]
}

Three things in there are worth knowing about.

Directions invert. A net called DUT_TXD is an output of the DUT, so it lands on the fixture's rx. Wire it to tx because both are called TXD and nothing works, with no error to explain why. pinside init gets this right when it drafts a config.

Guards. A bus the DUT also masters (an Ethernet SPI, a shared panel bus) is declared with "guard": "esp_en". The firmware then refuses to drive it until that channel is asserted:

-> {"jsonrpc":"2.0","id":3,"method":"spi.transfer","params":{"channel":"eth_spi","hex":"0f00"}}
<- {"jsonrpc":"2.0","id":3,"error":{"code":-32003,"message":"guard not asserted","data":"esp_en"}}

It is an interlock, not a substitute for series resistors, but it stops the ordinary mistake, which is probing a bus while the DUT's own controller is mid-transaction.

Open drain means released, not driven high. An open_drain channel only ever pulls towards its asserted rail; releasing it goes high-Z. That is what keeps a fixture off a reset line the DUT already pulls up.

Presenting a quantity

The channels above all read, or pull a line. An injected channel sources the thing being tested: a resistance where the DUT expects a PT100, a voltage where it expects a sensor, a contact closure where it expects a switch. The circuit comes from a design module -- one directory holding a descriptor, the circuit, a firmware driver and the Python that solves its component values.

"inject": [{
  "name": "dut_rtd",
  "block": "resistance_ladder@1",
  "probes": { "hi": "RTD_HI", "lo": "RTD_LO" },
  "pins": [11, 12, 13, 14, 15, 18, 19, 20, 21, 22],
  "verify": "rtd_sense",
  "request":     { "range_c": [0, 250], "excitation_ua": 1000 },
  "calibration": { "contact_mohm": 118 }
}]

Four things in there are worth knowing about.

An injector has two terminals, not one. probes maps a role to a DUT signal the way a bus does, because a presented quantity has a far end and the fixture has to reach it.

request is design intent; calibration is a measurement. The first travels with the test, the second is a property of one physical fixture. Both go into the config hash, which is the whole argument for keeping calibration here: put a DMM across one closed relay, write the number down, regenerate, and pinside probe refuses the firmware that was built before you knew.

verify pairs the injector with an ADC channel across its own terminals. sim.verify then answers with a measured number rather than the one it was told, which is what turns an unverifiable analogue claim into a reading -- and a relay that has stopped closing into a reported fault rather than a DUT that looks miscalibrated.

The module version is part of the contract. @1 accepts any 1.x; a MAJOR bump means the circuit changed, so the board you already had made is wrong. It appears in the config hash, in sim.channels, and on the fixture board's silkscreen.

Modules do not add RPC methods. sim.channels, sim.set, sim.get, sim.verify and sim.fault are the whole surface for every module there will ever be, which is what keeps openrpc.json stable as the catalogue grows.

Module Presents Needs Accuracy
resistance_ladder 90--320 Ω, as a PT100 10 GPIO, 5 V for the coils ±2.3 °C, or ±1.3 °C with the contact resistance measured

Values travel as integers in a unit small enough to be exact -- milliohm, microvolt, nanoamp, millihertz -- because the firmware carries no floating point. A module may accept a derived unit too: the ladder takes millicelsius and looks the switch word up in a table solved at generation time.

Dimensions and accuracy figures are calculated, not measured. Nobody has put a DMM on this circuit yet, and the generated fixture README says so where somebody about to order a board will see it.

What comes out

CMakeLists.txt              Pico SDK project
include/fixture_config.h    channel tables, counts, identifiers
include/fixture_core.h      the protocol layer
include/fixture_hal.h       what the core needs from hardware
src/fixture_config.c        generated: every channel, its pin, its DUT signal
src/fixture_core.c          JSON-RPC parse, dispatch, format, notifications
src/fixture_hal_rp2350.c    the only file that knows about the SDK
src/main.c
test/{run.sh,test_core.c,mock_hal.c}   host tests, no board required
openrpc.json                the agent-facing contract
README.md                   the channel map, as a table

Only fixture_config.c and a block of test assertions are really generated; the rest is copied from templates that can be read and compiled on their own. That keeps the generated surface small enough to review.

Talking to the fixture

JSON-RPC 2.0 over USB CDC, one request per line. fixture.info and fixture.channels together tell an agent which DUT signal every channel lands on, so it needs no other map:

<- {"jsonrpc":"2.0","method":"fixture.ready","params":{"fixture":"test-fixture",
                                                       "config_hash":"a5dce260c05e"}}
-> {"jsonrpc":"2.0","id":2,"method":"adc.snapshot"}
<- {"jsonrpc":"2.0","id":2,"result":[{"channel":"dut_3v3","probe":"+3.3V",
                                      "millivolts":3298,"raw":2047,"in_range":true}]}

Anything on a bus marked "stream": true is pushed without being asked, so a log line reaches the agent as the DUT emits it:

<- {"jsonrpc":"2.0","method":"uart.data","params":{"channel":"dut_uart",
                                                   "hex":"626f6f74206f6b0d0a","t_ms":4210}}

Methods: fixture.info, fixture.channels, gpio.read, gpio.write, gpio.snapshot, adc.read, adc.snapshot, uart.write, uart.read, uart.configure, i2c.scan, i2c.write, i2c.read, spi.transfer, sim.channels, sim.set, sim.get, sim.verify, sim.fault. Full schemas in the generated openrpc.json.

That contract is checked against the firmware rather than merely emitted: the test suite reads the dispatch table out of fixture_core.c and fails if the two disagree in either direction, so a method the document promises and the firmware does not answer cannot ship.

The config hash

fixture.info reports a digest of the channel map. Compare it against the config before trusting a run: a test rig one revision behind is the most common way a bench lies to you. Rewording a description does not change it; moving a pin does.

You do not have to compare it by eye. pinside.client does it on connect, and refuses.

Talking to a fixture from the host

pip install 'pinside[client]'                 # pyserial; the core stays dependency-free
pinside probe fixture.json                    # is this the right fixture, and is it wired up
pinside probe fixture.json --port /dev/ttyACM0 --json
pinside probe fixture.json --no-detect        # ... and never open a port looking for one
pinside probe fixture.json --watch            # ... then stream what the fixture pushes, as it arrives

pinside probe opens the port, checks the firmware's config hash against the config you handed it, prints every channel with the DUT signal it lands on, and exits non-zero if any monitored rail is out of range. It is the answer to "is the thing on my bench the thing I think it is".

Finding the port. With one serial port, that is the port. With several, each is opened and asked for fixture.info — a fixture is the thing that answers, not the thing with the right USB descriptor — and when two fixtures answer, the config hash picks the one this config belongs to. The cost is real and worth knowing: finding a fixture means writing one line of JSON-RPC to every serial port on the machine, including the printer and the CNC controller. --port names one instead, and --no-detect opens nothing and refuses when there is a choice to make.

The same client is a library:

from pinside.client import connect
from pinside.config import load

config = load("fixture.json")
with connect("/dev/ttyACM0", config) as fixture:  # raises if the hash disagrees
    fixture.gpio_write("dut_reset", True)
    print(fixture.adc_snapshot())
    for message in fixture.poll(seconds=2):
        print(message.channel, message.data)

Two things it does that a hand-rolled client usually does not:

It refuses a fixture that does not match. connect() compares fixture.info's hash against the config before returning, and raises ConfigMismatchError rather than warning. Pass check_hash=False if you mean to, and say why.

It keeps notifications out of the response stream. A streaming UART pushes uart.data between replies. Reading one line per request means eventually returning a log line as the answer to an ADC read; every line is classified before anything waits on it, and pushed messages go to fixture.poll().

Config findings

Code What it means
PF001 Unknown target microcontroller
PF002 The DUT board was not read, so probe names went unchecked
PF003 The output directory is not empty and pinside did not write it
PF004 A template placeholder survived into a generated file
PF005, PF006, PF007 An unknown board or probe, or a chip the board does not carry
PF008 An unrecognised fixture.mirror
PF010, PF011 A channel name C cannot use, or two channels with one name
PF020 A pin the target does not have
PF021 A pin that cannot carry the role asked of it, with the pins that can
PF022 An ADC channel on a pin with no converter
PF023 One pin claimed by two channels
PF024 A pin the carrier board does not bring out
PF025 The board has almost no GPIO left
PF030-PF038 An unrecognised role, guard, parity, direction, pull, or SPI mode
PF040 A probe naming a signal the board does not have
PF041 A test point with no channel
PF042 A divider that would present more than the ADC reference
PF043 An injected channel names a design module that does not exist, or a version that does not match
PF044 An injected channel is missing a probe role, a pin, or a verify channel its module needs
PF046 An injected channel shares a probe with another channel and did not declare it
PF047 An injected channel has no verify channel, so nothing measures what it presents
PF048 A design module refused the range it was asked for
PF049 An injected channel can present more than the fixture's own logic voltage
PF050 A design module reported something about a channel it configured
PF051 A design module presents a quantity pinside cannot generate firmware for yet
PF052 A channel probes a pad the DUT's board labelled ignore
PF053 A direct fixture was asked for something only a controller can do
PF054 More signals to bring out than the connector has ways
PF055 The chosen connector has no footprint in KiCad's libraries
PF056 A declared component height or fixture clearance cannot mean anything

pinside project additionally reports the board findings (PS...) and refuses on any error. It has three of its own, for the things that stop a project being written at all:

Code What it means
PK001 No DUT board, so there are no coordinates to lay a fixture out from
PK002 The output directory is not empty and pinside did not write it
PK003 KiCad's symbol libraries were not found, so a schematic cannot be built
PK004 A design module names a symbol this KiCad install does not ship
PK005 An injected channel's circuit is not drawn yet, so its probes reach only one pin
PK006 No guide-plate hole size: the probe's spring-pin diameter is not recorded

As a library

from pinside import read_board, transform, run

board = transform(read_board("board.kicad_pcb"), mirror="x")
for probe in board.test_points:
    print(probe.ref, probe.signal, probe.fx, probe.fy)

for finding in run(board):
    print(finding.code, finding.severity, finding.summary)

Limits carries the physical numbers; pass your own to run(board, Limits(probe_pitch=1.9)).

Development

ARCHITECTURE.md is the map: what each layer may assume, the invariants that make the output trustworthy, and where a new piece of hardware support goes.

scripts/test.sh            # the test suite
scripts/lint.sh            # ruff and mypy, plus clang-format on the firmware templates
scripts/lint.sh --fix      # apply what can be applied

There is no setup step: scripts/lint.sh installs the ruff and mypy versions pinned in pyproject.toml into .venv-tools/, so CI and your machine run the same ones. mypy checks src/pinside against Python 3.10, the floor this package claims to support.

The test suite builds its own synthetic boards, so it depends on no real project and no KiCad install. One test goes further and compiles the generated firmware, then runs its tests, the only check that the C templates and the generated tables actually agree. It skips if there is no compiler. Another generates a KiCad project and runs KiCad's own ERC and DRC over it; it skips without KiCad, so that path is verified locally rather than in CI.

src/pinside/
    sexpr.py        a tolerant reader for KiCad's S-expressions
    geometry.py     outlines: arcs and rounded rects flattened, segments chained
    board.py        what is read out of a .kicad_pcb
    checks.py       everything that can be wrong with a board (PS...)
    report.py       table, CSV, JSON, SVG
    targets.py      what each microcontroller can do with each pin
    config.py       the fixture config, and everything wrong with one (PF...)
    scaffold.py     drafting a config from a board
    modules.py      carrier boards, and which pins they bring out
    pogo.py         spring-pin probes and the holes they need
    client.py       talking to a flashed fixture over USB CDC
    cli.py          check | init | generate | project | probe
    kicad/          the KiCad project emitter
    firmware/       the emitter, and the C templates it emits

CONTRIBUTING.md has the rules that are not obvious from the code, CHANGELOG.md records what changed, and TASKLIST.md is what is planned next.

Why not the KiCad Python API

It only exists inside a KiCad installation, which rules out CI and any machine that just wants to look at a board file. KiCad's S-expression format is stable enough to read directly, so pinside parses it and stays a dependency-free script. Writing is a different matter: text edits break the UUID cross-references KiCad relies on, which is why pinside only ever reads.

About

Auto create test fixtures from KiCad projects

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages