Skip to content

The fixture board, a host client, and three bugs that shipped in 0.1.0 - #2

Merged
Boernsman merged 9 commits into
mainfrom
fixture-board-and-host-client
Aug 28, 2026
Merged

The fixture board, a host client, and three bugs that shipped in 0.1.0#2
Boernsman merged 9 commits into
mainfrom
fixture-board-and-host-client

Conversation

@Boernsman

@Boernsman Boernsman commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Works through the tasklist, and fixes three bugs found along the way that
were all present in 0.1.0.

Large, so the three commits are meant to be read in order. The first is a
data fix to the example board, the second is .gitignore, and the third is
everything else.

Three bugs that shipped in 0.1.0

pinside init did not work at all. The positional board and the
carrier option --board shared an argparse destination, so every invocation
looked the .kicad_pcb path up in the module catalogue and refused. CI's
end-to-end job runs that exact command. The option is now --carrier, and
main() derives its command list from the parser rather than a hand-kept
set, which is what let a newly added subcommand be swallowed too.

pinside project --board pico2w emitted a schematic KiCad would not
open.
KiCad writes the Pico W as a symbol deriving from the Pico, carrying
properties and no pins; copied into a schematic's lib_symbols that
extends resolves to nothing and every net in the design is isolated.
Derived symbols are flattened against their parent now. The units have to be
renamed too, and missing that gives you Failed to load schematic and no
further explanation, which took a file-level bisect to find.

The example board's netlist could not survive being opened. All sixteen
test points were written with net ordinal 1 under sixteen different names.
Through KiCad 9 a net is identified by its ordinal and the name is a
label, so KiCad read them as one net and dropped fifteen on the first save.
pinside check called it clean, because the reader takes the name off each
pad and never compares ordinals. That is now PS043.

A board with a slot was reported as having an unclosed outline
(PS002, an error) on geometry any fab would cut. Edge.Cuts holds every
edge a board has; the largest ring is the perimeter now and the rest are
cutouts.

New

  • pinside.client and pinside probe, an optional extra
    (pip install 'pinside[client]'). Refuses a fixture whose config hash
    disagrees with the config it was handed, and classifies responses the way
    JSON-RPC 2.0 defines them rather than by id: an echoing port otherwise
    looks like an answer. Found by pointing the client at pyserial's own
    loop://.
  • Baselines. --write-baseline records the findings a board has been
    judged on; --baseline accepts them. Suppression is by code and by
    reference
    , so a new occurrence of an accepted code still fails, which is
    what makes the file safe to commit.
  • --json on generate and project, including the refusal path.
  • Eight checks. PS003, PS004 (cutouts, second outlines), PS013,
    PS014 (probes and holes over a cutout), PS027 (receptacle body fouling
    a component), PS033, PS034 (rails and reset lines the fixture cannot
    reach), PS043, PS044 (net numbering KiCad will not preserve).
  • A GND pour on the generated fixture board, and a generated README that
    turns the plate force into hardware: clamp or thumbscrew, load per
    standoff, and how long the standoffs must be to leave the probes travel.
  • rp2354a, probe provenance, PyPI trusted publishing, coverage at 87%,
    SECURITY.md, issue and PR templates.

How the net checks were decided

PS043 and PS044 claim KiCad loses nets, so I asked KiCad rather than
assuming. kicad-cli pcb export ipcd356 is its own answer to what a board's
netlist is. Five malformed netlists went through it:

Case KiCad's verdict Check
One ordinal, several names keeps the first, rest become N/C PS043 error
Named net on ordinal 0 N/C; net 0 is the no-connection net PS044 error
Net table contradicting a pad that pad becomes N/C PS043 covers it
One name, several ordinals merges them into one net none, agrees with pinside
Pads on undeclared ordinals reads back correctly none, not a defect

Three of the five turned out not to be defects and got no check.
tests/test_kicad.py runs the same boards through ipcd356 and compares,
including the merge case pinside deliberately does not flag, so that
decision is confirmed rather than assumed.

Testing

208 → 221 tests, and the two CI-invisible paths are now covered: the KiCad
tests run inside the kicad/kicad:10.0 container with
PINSIDE_REQUIRE_KICAD=1, which turns a skip into a failure so the job
cannot go green having run nothing, and the generated firmware is
cross-compiled against a pinned Pico SDK.

Every test point was written with net ordinal 1 under a different name.
Through KiCad 9 a net is identified by its ordinal and the name beside it
is only a label, so KiCad read all sixteen as one net, kept the first name
it saw and dropped the other fifteen to no-net on the first save.

`kicad-cli pcb export ipcd356`, which is KiCad's own answer to what a
board's netlist is, reported the board as shipped like this:

    327/DUT_TXD    TP1
    327N/C         TP2      <- and TP3..TP14, TP90, TP91

`pinside check` called it clean throughout, because the reader takes the
name off each pad and never compares the ordinals.

Give each of the fifteen names its own number and declare them in a net
table, the way a board KiCad wrote does. The same export now reports every
net correctly. Nothing pinside reads changes: the names are identical, so
`check` output, the generated fixture and the config hash are unaffected.

The check that catches this, PS043, comes with the rest of 0.2.0.

Claude-Session: https://claude.ai/code/session_01G1pCpWna66S63N4BMjB7jo
Opening the example board in KiCad drops a .kicad_prl, a .history/ of
automatic snapshots and a .kicad_pro next to it. They churn on every open
whether or not anything was edited.

Not *.kicad_pro in general: that one carries the design rules, the net
classes and the stackup, so it belongs in a repository, and `pinside
project` generates one deliberately as part of the project it writes.
Ignoring it by pattern would make pinside's own output unversionable. Only
the demo board's is ignored, by path, because that board is an input to
pinside rather than a project: a bare .kicad_pcb with no schematic, whose
.kicad_pro is one KiCad invents on first open.

Claude-Session: https://claude.ai/code/session_01G1pCpWna66S63N4BMjB7jo
The tasklist's seven sections, plus what fell out of doing them.

Three bugs that shipped in 0.1.0
--------------------------------

`pinside init` did not work at all. The positional `board` and the carrier
option `--board` shared an argparse destination, so every invocation looked
the .kicad_pcb path up in the module catalogue and refused. CI's end-to-end
job ran that exact command. The option is now `--carrier`, and `main()`
derives its command list from the parser rather than a hand-kept set, which
is what let a newly added subcommand be swallowed too.

`pinside project --board pico2w` emitted a schematic KiCad would not open.
KiCad writes the Pico W as a symbol deriving from the Pico, carrying
properties and no pins; copied into a schematic's lib_symbols that `extends`
resolves to nothing, and every net in the design is isolated. Derived
symbols are flattened against their parent now, units renamed to match --
miss that last part and KiCad says only "Failed to load schematic".

A board with a slot was reported as having an unclosed outline, an error, on
geometry any fab would cut. Edge.Cuts holds every edge a board has; the
largest ring is the perimeter now and the rest are cutouts.

New
---

pinside.client and `pinside probe`, an optional extra (pinside[client]).
A JSON-RPC client that refuses a fixture whose config hash disagrees with
the config it was handed, and classifies responses the way JSON-RPC defines
them rather than by id -- an echoing port otherwise looks like an answer,
found by pointing it at pyserial's own loop://.

Baselines. `--write-baseline` records the findings a board has been judged
on; `--baseline` accepts them. Suppression is by code *and* reference, so a
new occurrence of an accepted code still fails. `--json` on generate and
project, on the refusal path too.

Eight checks: cutouts and second outlines (PS003, PS004), probes and holes
over a cutout (PS013, PS014), a receptacle body fouling a component (PS027),
rails and reset lines the fixture cannot reach (PS033, PS034), and net
numbering KiCad will not preserve (PS043, PS044). The last two were
established by asking KiCad: three other malformed netlists turned out to
round-trip fine and got no check.

A GND pour on the generated fixture board, and a generated README that turns
the plate force into hardware. The rp2354a target. Probe provenance. PyPI
trusted publishing. Coverage at 87%.

CI now runs the KiCad tests inside the KiCad container with
PINSIDE_REQUIRE_KICAD=1, which turns a skip into a failure, and
cross-compiles the generated firmware against a pinned Pico SDK. Both paths
were previously verified only on a developer's machine.

Tests
-----

208 to 221. tests/boards.py had the same net-numbering defect as the example
board; `_wrap` numbers the nets now, since it is the only place that sees a
whole board. Two tests removed footprints by reconstructing a helper's
output and string-replacing it away, which stopped matching once the
renumbering landed and left them asserting against a board they had not
built; there is a `without(text, *refs)` helper for that now.

Claude-Session: https://claude.ai/code/session_01G1pCpWna66S63N4BMjB7jo
The version-consistency test imported tomllib, so it failed to import on
Python 3.10 -- which pinside supports, and which CI runs. A test whose whole
purpose is to notice when something claims a version it does not have was a
poor place to depend on one.

The field needed is a quoted literal on its own line in [project]. Verified
by running the suite on real 3.10, 3.11, 3.12 and 3.13 interpreters rather
than inferring from the syntax.

Claude-Session: https://claude.ai/code/session_01G1pCpWna66S63N4BMjB7jo
…ndable

Inserting TestHardwareNote above TestKiCadAcceptsIt left the existing
decorator attached to the new class. TestHardwareNote is arithmetic over the
probe catalogue and needs nothing; TestKiCadAcceptsIt runs kicad-cli and
needs everything. So on a machine without KiCad its setUpClass raised, and
one unguarded class errored the whole suite -- which is every job in the
test matrix, on a runner that has no KiCad.

It passed locally throughout, because this machine has KiCad and the guard
was never consulted. That is the shape of the problem: anyone likely to
touch the KiCad emitter has KiCad installed, so the configuration CI
actually runs is the one nobody exercises.

So `scripts/test.sh --no-kicad` sets PINSIDE_NO_KICAD=1, which forces
KICAD_CLI and HAVE_SYMBOLS to their absent values. It is the mirror of
PINSIDE_REQUIRE_KICAD, which the container job uses to turn a skip into a
failure. Between them both configurations are reachable from either machine.

Also extends the documented-codes test to the PK family. It scanned PS and
PF only, so PK001 through PK003 -- project generation's own errors, the ones
that stop anything being written -- had gone a release undocumented and
unnoticed by the test that exists to notice exactly that.

Verified on real 3.10, 3.11, 3.12 and 3.13 interpreters, in both the
with-KiCad and without-KiCad configurations.

Claude-Session: https://claude.ai/code/session_01G1pCpWna66S63N4BMjB7jo
… root

Both new CI jobs failed on their first run, which is what they were for.

The firmware never linked against a real Pico SDK. main.c calls
set_sys_clock_khz, a static inline in hardware/clocks.h, and included only
pico/stdlib.h, which does not pull that in. Under C11 an undeclared function
is a warning, so all 102 translation units compiled and the link failed with
nothing but a symbol name. The host tests could not have caught it: they
build fixture_core.c against the mock HAL and never compile main.c.

So: include the header, link hardware_clocks, and build the generated
project with -Werror=implicit-function-declaration, which turns the next
missing header into a compile error naming the function instead of a bare
undefined reference. Verified by cross-compiling the demo fixture against a
real pico-sdk 2.1.1 with arm-none-eabi-gcc: it links, produces a 107 KB
.uf2, and removing the include again fails at compile with the function
named.

The KiCad container job died in actions/checkout with EACCES. The image
declares USER kicad while the runner mounts its own /__w owned by root, so
checkout cannot write /__w/_temp/_runner_file_commands. Running the
container with --user root fixes it. The safe.directory step stays: git
still refuses a tree it considers someone else's.

This one is not verified locally -- there is no docker on this machine -- so
it rests on the error message and the standard fix for it.

Claude-Session: https://claude.ai/code/session_01G1pCpWna66S63N4BMjB7jo
The entry was meant to land with the fix itself; the edit failed its anchor
match and the commit went out without it.

Claude-Session: https://claude.ai/code/session_01G1pCpWna66S63N4BMjB7jo
…e missing

--user root got the container as far as running the suite -- 221 tests with
KiCad present, 3 skipped rather than 36 -- and then two ERC assertions
failed on 44 violations, every one of them "the current configuration does
not include the symbol library 'MCU_Module'".

A KiCad install is two things: libraries on disk, and a profile mapping
library nicknames to them. The profile is created on the first GUI launch
from KiCad's own templates, and kicad-cli never creates one. So in a fresh
container nothing resolves `MCU_Module:` or `power:`, and ERC reports one
warning per reference: a statement about the environment, not about the
schematic pinside generated.

Seeding the two tables is what a first launch does. There is no docker on
this machine, so rather than guess, both halves were reproduced on a
workstation by pointing KICAD_CONFIG_HOME at an empty directory: 44
violations without the tables, 0 with them, matching CI's number exactly.

The assertion now names this case too. Forty warnings that all mean "no
profile" should not cost the next person an afternoon to interpret, and a
seeding step that silently lands in the wrong directory would otherwise fail
exactly as opaquely as it did this time.

Claude-Session: https://claude.ai/code/session_01G1pCpWna66S63N4BMjB7jo
`find /usr/share/kicad -name sym-lib-table | head -1` returned
template/Edgeberry_Cartridge/sym-lib-table -- a project template KiCad ships,
which has a sym-lib-table and no fp-lib-table beside it -- so the step died
on the copy. Traversal order decided which one it found, and locally it
happened to pick the right one, which is why the command looked fine.

The defaults live directly in the template directory. Requiring both files
in one directory, at a known path, removes the ambiguity.

And a check on the result: a wrong table copies exactly as successfully as a
right one, so the step now confirms the seeded file names MCU_Module, power
and Device, the libraries the generated schematic actually references.
Without that, a mis-seed shows up forty warnings later in a test, which is
where this started.

Verified locally by running the step's own shell against the macOS layout,
which carries the same nested Edgeberry_Cartridge template: it selects the
top-level directory, passes its checks, and the ERC that follows reports 0
violations. Pointed at the nested template alone it exits non-zero saying
so. The step was also extracted from the workflow and parsed with sh -n.

Claude-Session: https://claude.ai/code/session_01G1pCpWna66S63N4BMjB7jo
@Boernsman
Boernsman merged commit 37663d4 into main Aug 28, 2026
9 checks passed
@Boernsman
Boernsman deleted the fixture-board-and-host-client branch August 28, 2026 20:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant