Skip to content

Add PiFinder.bringup: one-command bench validation for a new board - #552

Draft
brickbots wants to merge 3 commits into
mainfrom
bringup-tool
Draft

Add PiFinder.bringup: one-command bench validation for a new board#552
brickbots wants to merge 3 commits into
mainfrom
bringup-tool

Conversation

@brickbots

Copy link
Copy Markdown
Owner

PR 3 of 3 in the PiFinder.bringup sequence. Docs + plan landed in #550; the
keypad extraction this builds on is #551.

Stacked on #551. This branch is cut from bringup-keypad-module, so until
that merges the diff here also shows keypad.py. Review #551 first.

What

Bench validation for a freshly assembled board — one command, one screen, every
listed subsystem exercised while the builder watches:

python -m PiFinder.bringup

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, no
camera, 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):

Check Kind What proves it
SCREEN witnessed 3 test patterns, then the live dashboard
BACKLIGHT witnessed Continuous 0→12%→0 ramp on PWM ch1
BUZZER witnessed Startup earcon + one per keypress (~18 over a sweep)
IMU probed BNO055 answers its chip id; quaternion moves on tilt
CHARGER probed BQ25895 REG14 part number reads 0b111; voltages decode
SWITCHES exercised Every populated matrix position observed closing

Only probed and exercised checks gate the verdict and the exit status — enforced
at construction, not by convention: CheckResult.__post_init__ refuses to build
a witnessed check carrying PASS/FAIL.

$ python -m PiFinder.bringup
PRE-FLIGHT  i2c-1 ok | pwm ch1->gpio13 (backlight) ok | pwm ch0->gpio12 (buzzer) NOT ROUTED
SCREEN     emitted   3 patterns + dashboard                 (witnessed)
BACKLIGHT  emitted   ramp 0-12% on pwm ch1                  (witnessed)
BUZZER     skipped   pwm ch0 not routed to gpio12           (witnessed)
IMU        PASS      BNO055 id ok, cal 2, q live            (probed)
CHARGER    PASS      BQ25895 pn=0b111, 4.02V, PG            (probed)
SWITCHES   FAIL      17/18 - (1,3) PLUS never closed        (exercised)
VERDICT    FAIL
$ echo $?
1

Guardrails. Takes the same utils.acquire_single_instance_lock() as
main.py so it refuses to run while the service holds the panel/PWM/I²C. Never
acts 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 ADC
trigger is the only write (ADR 0006), and apply_charging_config() is never
called (ADR 0017). Cleanup runs in a finally on SIGINT/SIGTERM too, so the
buzzer is never left driven.

Confirmed while building this: pwm-2chan really is missing

Plan item §10.2 was a suspicion; the library source settles half of it.
rpi_hardware_pwm.HardwarePWM.__init__ checks only that
/sys/class/pwm/pwmchip0 exists, then exports the requested channel:

if not self.is_overlay_loaded():   # os.path.isdir("/sys/class/pwm/pwmchip0")
    raise HardwarePWMException("Need to add 'dtoverlay=pwm-2chan' ...")
...
if not self.does_pwmX_exists():
    self.create_pwmX()

So with pifinder_setup.sh's single-channel dtoverlay=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 ROUTED and skips BUZZER rather than failing it.

Still needs a real card's /boot/config.txt to confirm, and the setup-script fix
is deliberately not in this PR (plan §11 lists it as a separate follow-up).

⚠️ Still needs confirming on a real rev4 board

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 — so
if 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-max retunes it without a
code change), and that --display ssd1333 still opens the panel on a board with
a dead charger.

Deviations from the plan worth knowing

  • --revision {rev3,rev4} added. It picks the population map and the
    default 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.
  • --volume and --backlight-max added; the latter is how §10.4 gets
    retuned on hardware without a code change.
  • Unexpected-closure reporting added to SWITCHES (see above). It does not
    fail the run — the map, not the board, may be what is wrong.
  • A gating check invalidated by pre-flight is skipped, not failed (e.g.
    no /dev/i2c-1IMU skipped). It still cannot pass: the run did not verify
    that part. This keeps a misprovisioned card from being reported as a bad board,
    which docs/ax/bringup/CONTEXT.md calls out as the most expensive confusion.
  • Summary output is ASCII only rather than the plan's ·/. Bring-up runs
    over a serial console as often as not, and a LANG=C terminal would turn a
    decorative arrow into a UnicodeEncodeError on the very last line of the run.
  • Test placement: plan §9 items 1–3 are keypad facts, so they live in
    tests/test_keypad.py (Extract the keypad matrix tables into an import-safe PiFinder.keypad #551) and 4–9 in tests/test_bringup.py here. All nine
    exist and pass.
  • Not translated, on purpose. The module never performs the gettext install
    main.py does; the audience is the builder, not the observer.
  • --rotate drives luma's device.rotate, not a PIL rotate of the composed
    frame. Two reasons, both found in review: luma turns clockwise
    (image.rotate(rotate * -90)) where PIL's positive angles are
    counter-clockwise, so a hand-rolled version would turn the opposite way from
    the app's screen_direction for odd quarter-turns; and DisplaySSD1333
    constructs with rotate=3, so it must compose — assigning would discard
    the panel's own orientation and make --rotate 0 come up wrong on every rev4
    board.

Follow-ups this surfaced (deliberately not folded in)

Each is a separate PR; none blocks this one.

  1. pifinder_setup.sh needs pwm-2chan — plan §11 already lists it. Confirm
    against a card first.
  2. /boot/config.txt now has three independent readers. ui/callbacks.py:217
    (get_camera_type) and switch_camera.py:15,40 both hardcode the
    pre-bookworm path with no /boot/firmware fallback and no comment stripping.
    bringup's read_preflight is the correct version; hoisting it into utils.py
    would fix a live gap in camera-type detection rather than just deduplicate.
  3. displays.py has no name→class registry. --display's choices are a
    hand-copy of get_display's if-chain; a guard test keeps the copy honest, but
    a real registry would also kill get_display's silent
    "Hardware platform not recognized" fallback.
  4. KEY_NAMES here is the inverse of server.py:125 button_dict. With
    keypad.py now existing for shared keypad facts, that table could live there.
  5. Pre-flight validates the recipe, not the result. It parses config.txt
    (which plan §7 specifies). raspi-gpio get 12 would answer the actual
    question — is ch0 muxed to GPIO12 — and would still work on a card whose
    overlays don't come from a readable config.txt.
  6. The revision→panel rule now exists in three places (main.py:1281,
    splash.py:32, here) in three phrasings.

Considered and declined

  • Hand-listing the population maps instead of deriving them from the keymap.
    Deriving means NA carries two meanings ("sends nothing" and "no switch
    fitted"), 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_sizes
    states 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.
  • Recomputing the PASS banner only on state change. Measured at 11–13 µs
    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.
  • A descriptor table for the six checks. The right move at seven; not at six.

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).
  • Import-safety is a test, not a one-off check:
    test_imports_and_help_work_with_no_hardware_modules re-imports the module in a
    subprocess with RPi.GPIO, board, rpi_hardware_pwm and adafruit_bno055
    made unimportable via a sys.meta_path blocker, then renders --help. If a
    hardware import ever creeps up to module scope, that test is what catches it.
  • Layout was looked at, not just asserted: rendered the dashboard and all
    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 a
    parametrised test asserts the grid fits inside resY/resX on all three.
  • End-to-end python -m PiFinder.bringup --display headless --timeout 2 produces
    the pre-flight line, the six-check table and exit status 1.

🤖 Generated with Claude Code

brickbots and others added 3 commits July 26, 2026 14:03
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>
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