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 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.
pip install pinsideNo dependencies; Python 3.10+. Add pinside[client] for the one command that needs pyserial:
pip install 'pinside[client]' # ... if you want `pinside probe` as wellIt also runs straight from a checkout, with nothing installed:
PYTHONPATH=src python3 -m pinside check board.kicad_pcbexamples/ 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 neededpinside 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 itpinside 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.
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.
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.
--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 onesA 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'| 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.
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.
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 hereA 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.
pinside project fixture.json --out board/ --guide --guide-hole 0.62A 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.
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-tablealready 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 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.
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.
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.
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.
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.
| 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.
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.
{
"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.
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.
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.
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.
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.
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 arrivespinside 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().
| 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 |
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)).
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 appliedThere 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.
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.
