Add PiFinder.bringup: one-command bench validation for a new board - #552
Draft
brickbots wants to merge 3 commits into
Draft
Add PiFinder.bringup: one-command bench validation for a new board#552brickbots wants to merge 3 commits into
brickbots wants to merge 3 commits into
Conversation
keyboard_pi imports libinput and RPi.GPIO at module scope, so the matrix wiring buried in KeyboardPi.__init__ could not be read by anything else -- not by unit tests, and not by the bring-up tool that needs the same tables to scan the keypad on a bare board (docs/ax/bringup/CONTEXT.md). Lift MATRIX_ROWS / MATRIX_COLS / POWER_GPIO / KEYMAP / ALT_KEYMAP / LONG_KEYMAP verbatim into a new PiFinder/keypad.py that imports only KeyboardInterface, and have KeyboardPi read them from there. Pure data move, zero behaviour change: the derived square_keycodes / repeat_keycodes comprehensions still read self.keymap and resolve to the same indices. Adds the new fact the tables could not carry before: the population maps, which say which matrix positions actually carry a switch on each revision. rev3 populates cols 0-3 of every row (17 switches); rev4 moved the directional cluster into col 4 -- all five rows of it, with a centre SQUARE of its own -- and left the rest of the bottom row unpopulated (18 switches, two SQUAREs). These are derived from the keymap rather than hand-listed, so the two tables cannot drift apart silently. The rev4 map is the load-bearing assumption here: it sets bring-up's PASS gate, and it has not yet been confirmed against a physical rev4 board. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three review fixes on the extraction:
* keyboard_pi still computed `i * len(self.cols) + j` inline, so it did not
actually use the helper the new module publishes -- while test_keypad
asserted its keycodes *through* that helper, claiming a coupling the code
did not have. Call it.
* `position(row, col) -> int` returned what the Bring-up glossary calls an
index, and that glossary lists "index" under Avoid for a matrix position
(which is the (row, col) pair). Same word, two meanings, both introduced by
this change. Renamed to `keymap_index`, matching the plan's own docstring
("Matrix position -> keymap index") even though the plan sketched the
shorter name.
* REV4_POPULATED derived its two halves by different rules -- cols 0-3 through
the NA filter, col 4 by a raw comprehension. That is a no-op today (col 4
has no holes) but it is exactly how the two tables start to disagree. Both
halves now go through _populated().
Also makes test_every_populated_position_has_a_real_key honest about its own
strength: the maps are derived from the keymap, so it restates the derivation.
What makes the pair meaningful is test_population_map_sizes stating 17 and 18
independently of how they are computed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A standalone tool a builder runs on a freshly assembled board:
python -m PiFinder.bringup
One process that opens the panel directly, drives the keypad backlight and
buzzer, interrogates the IMU and charger, and scans the keypad matrix,
reporting everything on one live screen. It does not boot the application --
no SharedStateObj, no catalogs, no solver, no camera, no MenuManager -- so it
runs on a card with nothing configured yet.
Six checks in three kinds, and the kind decides whether a check can gate the
verdict (docs/ax/bringup/CONTEXT.md): SCREEN, BACKLIGHT and BUZZER are
witnessed -- the program can only emit, and the builder is the sensor -- while
IMU and CHARGER are probed and SWITCHES is exercised. Only the latter three
gate the verdict and the exit status, and that is enforced rather than
observed: CheckResult refuses to be constructed as a witnessed check carrying
PASS or FAIL, so "the buzzer passed" is unrepresentable.
Pre-flight reports on the card's provisioning separately from the board's
hardware, because a failed pre-flight invalidates the checks that depend on it
and a misprovisioned image diagnosed as a bad board gets a good part
desoldered. It parses config.txt for the PWM pin routing, which matters
because rpi_hardware_pwm only checks that pwmchip0 exists before exporting a
channel: with the single-channel overlay this repo provisions, ch0 exports
successfully and drives no pin, so a silent buzzer looks like a dead part.
Bring-up says "pwm ch0 not routed" and skips BUZZER instead of failing it.
Guardrails: takes main.py's single-instance lock so it refuses to run while
the service holds the panel, PWM and I2C; never acts on the power switch and
goes nowhere near GPIO14; charger access is reads plus the sanctioned one-shot
ADC trigger only (ADR 0006), never the fast-charge config write (ADR 0017);
and cleanup runs in a finally on SIGINT/SIGTERM so the buzzer is never left
driven.
Structure follows sound.py: pure logic separated from thin hardware seams,
every hardware import lazy. A unit test re-imports the module in a subprocess
with RPi.GPIO, board, rpi_hardware_pwm and adafruit_bno055 made unimportable,
which is what keeps it that way -- the dev machine cannot run the tool itself,
so that guard is the only thing standing between this and a module that only
imports on a Pi.
Rotation drives luma's device.rotate, the same mechanism main.py uses for
screen_direction, and composes with the panel's own orientation rather than
replacing it: the SSD1333 constructs with rotate=3, and luma turns clockwise
where PIL turns counter-clockwise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR 3 of 3 in the
PiFinder.bringupsequence. Docs + plan landed in #550; thekeypad extraction this builds on is #551.
What
Bench validation for a freshly assembled board — one command, one screen, every
listed subsystem exercised while the builder watches:
A single process that opens the panel directly, drives the keypad backlight and
buzzer, interrogates the IMU and charger, and scans the keypad matrix. It does
not boot the application: no
SharedStateObj, no catalogs, no solver, nocamera, no
MenuManager. It runs on a card with nothing configured yet.Six checks in three kinds, and the kind decides whether a check can gate the
verdict (
docs/ax/bringup/CONTEXT.md):SCREENBACKLIGHTBUZZERIMUCHARGERREG14part number reads0b111; voltages decodeSWITCHESOnly probed and exercised checks gate the verdict and the exit status — enforced
at construction, not by convention:
CheckResult.__post_init__refuses to builda witnessed check carrying
PASS/FAIL.Guardrails. Takes the same
utils.acquire_single_instance_lock()asmain.pyso it refuses to run while the service holds the panel/PWM/I²C. Neveracts on the power switch — pressing it only lights a cell, and nothing goes near
GPIO14. Charger access is strictly read-only:
read_state()'s one-shot ADCtrigger is the only write (ADR 0006), and
apply_charging_config()is nevercalled (ADR 0017). Cleanup runs in a
finallyon SIGINT/SIGTERM too, so thebuzzer is never left driven.
Confirmed while building this:
pwm-2chanreally is missingPlan item §10.2 was a suspicion; the library source settles half of it.
rpi_hardware_pwm.HardwarePWM.__init__checks only that/sys/class/pwm/pwmchip0exists, then exports the requested channel:So with
pifinder_setup.sh's single-channeldtoverlay=pwm,pin=13,func=4,HardwarePWM(pwm_channel=0)exports successfully and drives no pin — silent,no exception, indistinguishable from a dead buzzer. That is exactly what the
pre-flight check exists to name, and bring-up reports
pwm ch0->gpio12 (buzzer) NOT ROUTEDand skipsBUZZERrather than failing it.Still needs a real card's
/boot/config.txtto confirm, and the setup-script fixis deliberately not in this PR (plan §11 lists it as a separate follow-up).
The rev4 population map (18 switches, from #551) sets the PASS gate. If it is
wrong, PR #551 is where the one-line fix goes. This PR gives you the instrument
to settle it: any closure at a position outside the population map is recorded,
drawn on the grid, and reported in the summary as
unexpected: (4,0) LEFT— soif rev4 does populate the rev3 bottom row too (22 switches), the first sweep says
so instead of silently ignoring it.
The other open items (plan §10) are unchanged: LTC2954 behaviour on a long power
hold, the rev4 backlight duty ceiling (
--backlight-maxretunes it without acode change), and that
--display ssd1333still opens the panel on a board witha dead charger.
Deviations from the plan worth knowing
--revision {rev3,rev4}added. It picks the population map and thedefault panel (rev4→ssd1333, rev3→ssd1351). Still never derived from a probe —
it comes from what the builder declares, which is the point of plan §2.
--volumeand--backlight-maxadded; the latter is how §10.4 getsretuned on hardware without a code change.
SWITCHES(see above). It does notfail the run — the map, not the board, may be what is wrong.
skipped, notfailed(e.g.no
/dev/i2c-1→IMU skipped). It still cannot pass: the run did not verifythat part. This keeps a misprovisioned card from being reported as a bad board,
which
docs/ax/bringup/CONTEXT.mdcalls out as the most expensive confusion.·/→. Bring-up runsover a serial console as often as not, and a
LANG=Cterminal would turn adecorative arrow into a
UnicodeEncodeErroron the very last line of the run.tests/test_keypad.py(Extract the keypad matrix tables into an import-safe PiFinder.keypad #551) and 4–9 intests/test_bringup.pyhere. All nineexist and pass.
gettextinstallmain.pydoes; the audience is the builder, not the observer.--rotatedrives luma'sdevice.rotate, not a PIL rotate of the composedframe. Two reasons, both found in review: luma turns clockwise
(
image.rotate(rotate * -90)) where PIL's positive angles arecounter-clockwise, so a hand-rolled version would turn the opposite way from
the app's
screen_directionfor odd quarter-turns; andDisplaySSD1333constructs with
rotate=3, so it must compose — assigning would discardthe panel's own orientation and make
--rotate 0come up wrong on every rev4board.
Follow-ups this surfaced (deliberately not folded in)
Each is a separate PR; none blocks this one.
pifinder_setup.shneedspwm-2chan— plan §11 already lists it. Confirmagainst a card first.
/boot/config.txtnow has three independent readers.ui/callbacks.py:217(
get_camera_type) andswitch_camera.py:15,40both hardcode thepre-bookworm path with no
/boot/firmwarefallback and no comment stripping.bringup's
read_preflightis the correct version; hoisting it intoutils.pywould fix a live gap in camera-type detection rather than just deduplicate.
displays.pyhas no name→class registry.--display's choices are ahand-copy of
get_display's if-chain; a guard test keeps the copy honest, buta real registry would also kill
get_display's silent"Hardware platform not recognized" fallback.
KEY_NAMEShere is the inverse ofserver.py:125button_dict. Withkeypad.pynow existing for shared keypad facts, that table could live there.config.txt(which plan §7 specifies).
raspi-gpio get 12would answer the actualquestion — is ch0 muxed to GPIO12 — and would still work on a card whose
overlays don't come from a readable
config.txt.main.py:1281,splash.py:32, here) in three phrasings.Considered and declined
Deriving means
NAcarries two meanings ("sends nothing" and "no switchfitted"), which makes the drift test partly a restatement of the derivation.
Kept the derivation because the likelier error is a keymap edit silently
changing which positions count as switches, and
test_population_map_sizesstates 17/18 independently of how they're computed, so the pair still bites.
If a revision ever fits a switch the UI ignores, these become explicit sets.
against a ~20 ms frame — under 1% — and the property it buys (screen and exit
status come from one function and cannot disagree) is worth more.
Verification
No Pi on the dev machine, so everything below is what could honestly be checked
off-hardware — which is the reason the pure logic is separated from the seams in
the first place.
ruff check·ruff format·mypy PiFinder(146 files, clean) ·pytest -m unit(1035 passed, 42 new) ·pytest -m smoke(5 passed).test_imports_and_help_work_with_no_hardware_modulesre-imports the module in asubprocess with
RPi.GPIO,board,rpi_hardware_pwmandadafruit_bno055made unimportable via a
sys.meta_pathblocker, then renders--help. If ahardware import ever creeps up to module scope, that test is what catches it.
three patterns on 128/176/320 headless panels. rev4 shows 18 cells with the
col-4 cluster; rev3 shows 17 with the bottom row; unseen/seen/held read clearly
apart; the title bar inverts to
PASS. Cell sizes come out 10/15/22 px and aparametrised test asserts the grid fits inside
resY/resXon all three.python -m PiFinder.bringup --display headless --timeout 2producesthe pre-flight line, the six-check table and exit status 1.
🤖 Generated with Claude Code