Repository navigation
test(hardware): soak test panel — panel-mount controls on a printed or laser-cut faceplate, harnessed to two PanelGroups #291
Description
Goal
A permanent, reusable fixture for the #290 soak and for every later release gate: one faceplate carrying one instance of each control class per electrical path (direct GPIO, MCP23017, ADS1115, I²C mux), wired to two PanelGroup_Base Rev 2 boards (#209) through their standard connectors only.
Why not a breadboard
The AxisBench rig showed all of its analog wander (500–800 counts on a wire jiggle) came from breadboard ground contact resistance. A 2–3 hour soak on push-in contacts would measure the contacts, not the firmware. Soldered, strain-relieved wiring removes that — and is how the real panels are built, so this doubles as a harness rehearsal.
Construction
- Panel-mount parts across several small faceplates, 3D-printed or laser-cut; wiring soldered point-to-point.
- Module boards already on hand (MCP23017, ADS1115, TCA9548A, DRV8833) on standoffs behind the panels.
- Harness ends crimped to plug into the base board's own sockets: 2×7 →
J11(10 GPIO), JST-XH 8 →J_I2C1/J_I2C2, JST-XH 7 →J_SR, Mini-Fit Jr 2×1 →J_BL1/J_BL2. The panel unplugs cleanly. - Both
J_I2Cconnectors keep their Rev 1 pinout, interrupts included.J11carries 10 GPIO on both revisions; on 0.2.0 positions 7 and 11 arePB2andPC14(PB5 moved to ShiftBus, PA8 toJ_I2C2INT_A). - External pull-ups on every input — the firmware sets plain
INPUTon GPIO andGPPU=0on the MCP23017 (PinRef.cpp:205/209). 10 k per line, soldered inline under heat-shrink or on a small terminal strip per panel near the harness exit. - Analog RC (1 k series + 100 nF) at the connector end, as close to the MCU as the harness allows.
- Put the board A
J11LED on a timer-capable pin (PA0–PA3, PB0, PB1). Costs nothing and lets that station be reflashed asLEDorDimmerwithout rewiring. - Grounds: each panel returns to its own board's GND pins; don't join the two boards' analog grounds across panels (the bus already commons them).
- MCP23017 GPA7/GPB7 are output-only (silicon erratum) → 14 inputs per chip.
- DRV8833 module: fit the EEP→VCC jumper (backwards silk — nSLEEP floats otherwise). VM = 5 V from a bench supply or a bus-harness tap;
J11carries only 3V3 andJ_BLis 12 V (over the 10.8 V max). - Parts come from what is already in InvenTree; no new sourcing.
Faceplate — several small panels, not two big ones. Build to the cockpit panel standard
(docs/hardware/mechanical-standards.md): 146.05 mm wide, height a whole number of 9.525 mm
Dzus units. Mounting is the cockpit's own wood-frame method — the Dzus grid is
used here for its dimensions, so panels interchange and a later one bolts on without re-cutting
anything.
It mounts in a printed 20-unit frame (20 mounting positions = 190.50 mm of rail), which
doubles as the physical validation piece for the panel-blank generator (#334).
That frame is the binding constraint, and it rules out one panel per harness leg. #334 sets a
floor of N ≥ 4 (N = 3 collapses to one stud per side), so six panels need 24 units however
tightly they are drawn. Four panels, grouped by board rather than by connector, fill the frame
exactly:
| Panel | Units | Carries | Legs |
|---|---|---|---|
| P1 — board A | 6 | pot · ladder rotary · encoder · momentary · LED — then encoder · 2-pos toggle · 5-pos rotary · LED · pot | J11 + J_I2C1 + J_I2C2 |
| P2 — gauges | 6 | 3× X27 — GPIO, MCP port, '595 |
J11 + J_I2C1 + J_SR |
| P3 — displays | 4 | 3× SSD1306 — 2 muxed, 1 bare-bus | J_I2C2 + J_I2C1 |
| P4 — ShiftBus | 4 | encoder · ON-OFF-ON · 4 LEDs | J_SR |
20 units · 190.50 mm · 22 cutouts · 19 face stations. Board A is one plate and board B is
three, so there is one harness bundle per node instead of one per connector leg. A panel may
therefore carry more than one tail.
Four stations sit behind the plates with no cutout: both 12 V LED strings (the Dimmer sync
pair), the '165 home microswitch (behind the X27 it homes, on P2), and the TCA9548A. The
modules and the '165/'595 perfboard mount on standoffs behind their panel.
Stud geometry per mechanical-standards.md: columns at 4.7625 and 141.2875 mm
(span 136.525 = width − pitch), rows at 1.5 and N − 1.5 units. Every row in the
stack lands at (k + 0.5) × 9.525 mm.
Fastener: M5 socket cap — Ø5.6 clearance hole, Ø8.5 head (#334), not M6 and not the Ø6.6
Dzus-stud hole. The 4U panels decide it: their rows sit at 1.5 and 2.5 units, one pitch apart,
and an M6 head is Ø10 against a 9.525 mm pitch — it overlaps by 0.475 mm. M6 also overhangs the
side edge by 0.237 mm, since the column inset of 4.7625 is under a 5 mm head radius. M5 clears by
1.025 mm. The 6U panels have three pitches between rows and would tolerate M6, but the stack uses
one fastener throughout.
Cutout diameters — established 2026-10-25. Supplier catalogues (DAIER) plus a verified test
cut now give a first-cut number for every opening.
| Station | Cutout | Locating feature | Basis |
|---|---|---|---|
| R16-503 pushbutton | ⌀15.8 | none — round, rotation irrelevant | cut and fitted |
| SR26 rotary (12-pos) | ⌀9.75 | ⌀3.2 at 9.5 | cut and fitted |
| Toggle ×2 (MTS-10x-L1) | ⌀12 | ⌀3.2 at 9.5 | DAIER catalogue |
| SR16 rotary (8-pos) | ⌀9.0 | slot 2.1 × 1.0 at 7.5 | DAIER catalogue |
| X27 gauge | ⌀4.6 shaft | offset 6.0 from body centre, not concentric | Juken datasheet |
| SSD1306 | 38 × 12 rear clearance | — | module spec |
| Pot / encoder ×5 | M7×0.75 — cut a 7.0 / 7.2 / 7.4 ladder | vertical key 1.2 × 2.6 | WH148 drawing |
| 5 mm LED holder | cut a 7.8 / 8.0 / 8.2 ladder | none | vendor spread |
Pots and encoders share one bushing and key, so a single geometry covers five stations. SR26 and
both toggles share a 9.5 mm key offset; SR16 shares nothing.
Corrections to earlier assumptions in this ticket:
- The SR26's bushing is M9.3×0.75, not the M10×0.75 printed on the supplier quotation —
M9.3 is what makes the verified ⌀9.75 hole fit. - No >12-position rotary exists in stock or in the Alpha SR26 reference (SR26 is 12-pos at
30°, SR16 is 8-pos at 45°). The SoakRig sketch declaresAnalogMultiPos(..., 16). Either the
station drops to 12 and the constant changes, or a part gets bought. Does not block the P1 cut. - The 12 mm toggle figure came from
Toggle12.FCStd, which was a test file that never existed
in this repo — ⌀12.6 is withdrawn.
Faceplate is three layers in practice: BackPlate pass-through → Diffuser (bushing passes, nut
bears on its front face) → nut → FacePlate (clears the nut, so it sits recessed). Diameters are
not monotonic across the layers, and whether a part seats on the BackPlate or forward on the
Diffuser is a per-part decision driven by bushing length — the SR16's 4.6 mm bushing likely needs
the forward seat.
Still open: encoder key/body, toggle body depth, R16-503 clamp range and terminal span,
SSD1306 glass window, LED holder hole, perfboard outline, key projection heights, nut
across-corners and height. All in stock — a coupon settles most of them.
Coverage list — 23 stations
Every class at least once and every PinRef backend at least once, with extra instances only
where the backend genuinely changes behaviour (three X27s, three encoders, three LEDs) — not the
full class × backend cross-product. One deliberate duplicate: the second Dimmer, as a sync check.
Firmware modes are reflashes rather than extra hardware. Where a mode cannot coexist with the
main build it goes in an alternate build, listed below.
HID axis is out of scope for this rig. HID axes are controlIds 0x0010–0x001F, routed to
SimGateway's axis setters, so binding a pot to CTRL_ROLL instead of a DCSIN_* id is a
controlId swap on an existing station — available later as a reflash, not a station here.
No guarded switch is fitted. SwitchWithCover2Pos (#293) takes a single pin and fakes the
cover in firmware — open, 200 ms, then the switch; reversed for off — so it is electrically the
same one input as Switch2Pos. A physical guard would add a part and panel space without adding
anything testable, since the class never reads a cover contact. The MCP 2-pos toggle carries both
classes, one reflash apart.
The #293 check uses a synthetic pairing, because the A-4E-C has no control of this shape.
FirmwarePlan/11-open-issues.md records the class as used 0× by this aircraft: the only
cover in the jet, AFCS_1N2_COVER, guards AFCS_1N2, which is a 3-position switch
(mask 0x0018) that the mod does not gate on the cover, and the cover carries its own
DCSIN_AFCS_1N2_COVER. A guarded 3-position switch therefore needs no special class at all —
Switch3Pos on the switch plus an optional Switch2Pos on the cover, both already on this rig.
SwitchWithCover2Pos is kept purely for DCS-BIOS parity. Its constructor takes
(switchId, coverId, PinRef, reverse), and both are ordinary control ids — nothing requires the
second to be a real cover. Since no 2-position control in the A-4E has a paired cover
(EMERG_GEAR_REL has none), the check pairs ARM_STATION1 as the cover and ARM_STATION2 as
the switch. Both are plain single-bit station-select switches, sit next to each other on the
armament panel, and share broadcast word 0x8500 (masks 0x0020 and 0x0040) — so the 200 ms
ordering is visible both on screen and as two bit changes in one word. Throw the toggle: station 1
flips, 200 ms later station 2 follows; off reverses the order. The pairing is not physically
meaningful in the aircraft; it verifies the class emits its two commands in the right order with
the right gap, which is all the gate asks.
Board A — 0.1.0 (board already assembled)
| Path | Components | Classes exercised |
|---|---|---|
J11 GPIO (6 of 10 pins) |
pot · rotary switch + resistor ladder · encoder (2 pins) · momentary | AnalogInput (DCS-BIOS and HID axis), AnalogMultiPos, RotaryEncoder / RotaryAcceleratedEncoder, ActionButton |
J11 GPIO out |
1 LED | LED |
J_BL1 |
12 V LED string | Dimmer (#288) |
J_I2C1 MCP23017 |
encoder · 2-pos toggle · 5-pos rotary · 1 LED | RotaryEncoder on MCP interrupt, Switch2Pos and SwitchWithCover2Pos (#293) on the same toggle, SwitchMultiPos, LED on MCP |
J_I2C2 ADS1115 |
pot | AnalogInput on the ADS path (also observes #269) |
Board B — 0.2.0 (board on order)
| Path | Components | Classes exercised |
|---|---|---|
J11 GPIO (4 pins) |
X27 + DRV8833 | NeedleGauge on direct GPIO |
J_I2C1 MCP23017 (0x20) |
X27 + DRV8833, 4 coil pins on one port · 1× SSD1306 direct @0x3c | NeedleGauge on the MCP batched-port path (moved from #137), DrumDisplay on a bare bus |
J_SR '165 |
encoder · ON-OFF-ON · home microswitch | RotaryEncoder on the ISR-sampled path, Switch3Pos, HomeMode::SENSOR |
J_BL1 |
12 V LED string | Dimmer (#288) — second instance, on purpose |
J_SR '595 |
LEDs · X27.589 + DRV8833 | LED on ShiftBus, NeedleGauge on the ShiftBus path |
J_I2C2 |
TCA9548A (0x70) + 2× SSD1306 behind it | DrumDisplay via I2cMux |
IntegerOutput needs no hardware — its callback logs CANOPY_POS to DiagSerial.
I/O budget. J11 is 2×7: 10 GPIO (PB0 PA0 PB1 PA1 PB2 PA2 PA5 PA3 PC14 PA4) plus
2× +3V3 and 2× GND. Eight are ADC-capable and six of those are also timer-capable. Board A
needs 6 pins, board B needs 4 — comfortable on both. Interrupt lines are shared per pin
number, so an interrupt-driven encoder must not take both halves of PA0/PB0, PA1/PB1 or PA2/PB2.
Alternate firmware builds
Two variants cannot coexist with the main build because they occupy a station that is already
bound. Each board gets one alternate sketch; the variants on a board are independent stations, so
one alternate carries all of that board's changes.
| Build | Station | Variant |
|---|---|---|
| board A alt | MCP 2-pos toggle | SwitchWithCover2Pos (#293) — same pin as Switch2Pos |
| board A alt | J11 momentary |
Switch2Pos — the base class on the GPIO backend |
| board B alt | GPIO X27 | StepPattern::FULL_4STATE — same motor as SWITEC_6STATE |
The momentary carries two classes because they are siblings, not a hierarchy:
ActionButton : InputBase emits one EVT_ACTION per debounced press edge, while
Switch2Pos : InputBase sends absolute 0/1 from the level. (SwitchWithCover2Pos is the one
that derives from Switch2Pos.) Binding Switch2Pos to a spring-return part is a deliberate
rig compromise — its header says the class wants a part that latches — so record that result as
class path proven, part not representative. Switch2Pos itself was hardware-verified in
Phase 4, so nothing unproven rides on it.
Plain RotaryEncoder in Rel mode is deliberately skipped: RotaryAcceleratedEncoder is a
RotaryEncoder with the momentum filter on, so the Rel dispatch path is already exercised by the
'165 encoder. Only Rel-without-the-filter is uncovered, which is marginal.
DCS bindings — chosen for reach, not realism
This is a test instrument, not a replica panel, so each station takes whichever sim control is
easiest to drive and confirm. That lands most of the list on lighting: those switches work cold
and parked and read back from the cockpit or an F2 external view, so the smoke test needs no
flying.
| Station | Binding | Confirm by |
|---|---|---|
J11 pot |
LIGHT_INT_INSTR (0xffff) |
instrument lights brighten |
J11 LED |
caution-ladder bit | master test |
J11 ladder rotary |
TACAN_CHAN_MAJ (0x0780, 16 states) or MCL_CHAN_SEL (0x03e0, 32) |
switch animates; readout needs power |
J11 encoder |
LIGHT_INT_BRIGHT (0x0c00, 4 states) in DIR mode |
console intensity steps |
J11 momentary |
MASTER_TEST |
caution ladder lights |
J_BL1 LED string (A) |
LIGHT_INT_INSTR (0xffff) |
instrument lights |
J_BL1 LED string (B) |
LIGHT_INT_INSTR — same value as A |
both strings must track |
| MCP 2-pos toggle | LIGHT_EXT_ANTICOLL (1 bit); reflashed as #293 → cover ARM_STATION1 + switch ARM_STATION2 |
F2 / station 1 then station 2 on the armament panel |
| MCP 5-pos rotary | NAV_SEL (0x0038) or DOPPLER_SEL (0x0007) — 8 states, 5 used |
switch animates in the cockpit |
| MCP LED | caution-ladder bit | master test |
| MCP encoder | ARC51_FREQ_1MHZ (0x3c00) |
radio readout |
| ADS pot | LIGHT_INT_FLOOD_WHT (0xffff) |
floodlight |
'165 encoder |
DEST_LAT_KNB |
doppler readout |
'165 home microswitch |
none — hardware homing, mounts behind the X27 it homes | needle parks |
'165 ON-OFF-ON |
LIGHT_EXT_NAV (0x00c0, 3 states) |
F2 |
'595 LEDs |
caution-ladder bits | master test |
| X27 (GPIO) | CANOPY_POS (0xffff) |
watch the canopy |
| X27 (MCP) | EXT_HOOK (0xffff) |
watch the hook |
X27 ('595) |
D_FLAPS_IND (0x840e, 0xffff) |
watch the flaps |
OLED × 2 (muxed, J_I2C2) |
CANOPY_POS, EXT_HOOK |
numbers track the animation |
OLED × 1 (direct, J_I2C1) |
LIGHT_INT_INSTR — sync-pair target |
number vs the two strings |
One press covers every LED station. The caution ladder is a single word at 0x845c with a
mask per lamp (D_SPOILER_CAUTION, D_SPDBRK_CAUTION, D_UTILHYD_CAUTION, D_CONTHYD_CAUTION,
D_FUELBOOST_CAUTION, D_FUELTRANS_CAUTION, D_RADAR_WARN, GEAR_LIGHT), so MASTER_TEST
lights the GPIO, MCP and '595 LEDs together — and exercises multi-mask fan-out from one
broadcast, which nothing else on the rig does. Verify on the first run that the mod's master test
actually drives the ladder; that is assumed here, not confirmed.
The direct OLED sits on J_I2C1, not J_I2C2. hardware-standards.md forbids a same-address device on a trunk that also carries that address behind a mux — a bare 0x3C OLED beside the TCA9548A is shadowed whenever a channel is open. J_I2C1 carries only the MCP23017 at 0x20, so the bare-bus DrumDisplay path is proven there instead. Panel P4 already has a J_I2C1 tail for the MCP-driven X27, so this adds no harness leg.
One Dimmer per board, both on the same value — this is a sync check. The two strings are
the only duplicated class on the rig. Board A's pot drives LIGHT_INT_INSTR; both nodes receive
that broadcast independently and must light to the same brightness. Divergence between them is a
decode or timing fault that no single-node station can show. The direct OLED displays the same
value as a number, so the expected duty is readable next to the two actual brightnesses. One zone
per board only — J_BL2 stays unused.
Only four continuous (0xffff) targets are reachable, and all four are allocated:
LIGHT_INT_INSTR, LIGHT_INT_CONSOLE, LIGHT_INT_FLOOD_WHT, GUNSIGHT_KNB. The two remaining
encoders therefore bind to the radio and doppler, which need the set powered — not ramp-testable,
but the readout is on-screen.
Wide selectors are plentiful — fifteen outputs carry a mask of 3 bits or more, so both
multi-position stations get a target that matches the class rather than one that merely fits:
5-bit / 32-state (ARC51_FREQ_10MHZ, ARC51_FREQ_50KHZ, ARC51_FREQ_PRE, MCL_CHAN_SEL),
4-bit / 16-state (ARC51_FREQ_1MHZ, AWRS_QUANT, TACAN_CHAN_MAJ, TACAN_CHAN_MIN) and 3-bit /
8-state (ARM_FUNC_SEL, ARM_EMERG_SEL, AWRS_MODE, DOPPLER_SEL, NAV_SEL, RADAR_MODE,
SHRIKE_SEL_KNB). The resistor ladder is the >12-position case per the input-class heuristic,
so it takes a 16- or 32-state channel selector; the 5-position rotary takes an 8-state mode
selector. These selectors animate in the cockpit when switched, so the input is confirmable even
before the set is powered — only their drum readouts need power.
MASTER_TEST class check: confirm the mod models it as a toggle before using ActionButton
(momentary → toggle). A held push-to-test is a plain Switch2Pos, and the ActionButton item in
the checklist would then need a different control.
Three X27s, one per backend — each answers a different question. The MCP gauge is the
only configuration that ships today (APN-153's DRIFT needle: MCP23017 Port B GPB0–3 → DRV8833,
chosen because that needle is slow — hardware-standards.md puts slow needles at ≈163 °/s on
I²C-class). That is the one to put under the soak. The '595 gauge proves the fast path
— benched at 1,137 steps/s in #197 gate 2 and named in the standard for gauges above
≈150 °/s, but no board drives coils from a shift register yet, so this is where it gets
proven before a board commits copper. The direct GPIO gauge has no shipping consumer at all
(the only other DRV8833 board, Armament_MCU, is deprecated); it is kept as a diagnostic baseline
that isolates StepperMotor from an expander fault, and because board B's J11 is otherwise
idle. It is the first station to cut if those pins are ever needed.
Scope — what this rig must prove, and what it need not
#290 asks for "the single live check for every in-scope class", so the list is bounded by what
ships in 1.0.0:
- Required because they are milestoned v0.1.0:
AngleSensorInput(feat(firmware): AngleSensorInput — angle sensor on a PinRef, AnalogInput subclass #294) and
SwitchWithCover2Pos(feat(firmware): SwitchWithCover2Pos — DCS-BIOS parity on a Switch2Pos hook #293). - Required because test(e2e): validate DrumDisplay + NeedleGauge end-to-end over live DCS-BIOS #137 handed it over: the X27 on the MCP23017.
- Frozen but otherwise unexercised. docs(firmware): v1.0 API + wire-format freeze review and compatibility policy #295 freezes the API at 1.0.0, and two enum members would
ship unproven.StepPattern::FULL_4STATEcosts no hardware — reflash board B's GPIO X27 leg;
that proves the 4-state table and the sequencer, not the geared-motor profile, and the result
should say so.HomeMode::SENSORcosts one microswitch on a spare'165input.
HomeMode::ABSOLUTEis a comment rather than an enum member, so there is nothing to freeze.
AngleSensorInput (#294) is not a faceplate station. Both an AS5600 and an MT6701 are on
hand, but neither is a panel-mount part: the magnet has to sit in a knob, concentric with the axis
of rotation, with the sensor held behind it at a controlled airgap. That is a bespoke knob plus a
bushing plus a sensor mount — a mechanical design task, unlike every other station here, which is
a COTS part in a hole.
So it moves to its own small fixture: sensor, magnet, shaft, knob, no panel. #290 asks for a
live check of every in-scope class, not for every check to happen on this rig, so the gate is
still met. Sequence it as bench-the-sensor-loose → measure the real airgap and concentricity
tolerance → then model the jig; a jig built on datasheet figures gives a bad reading that cannot
be attributed between mount, sensor and firmware. GUNSIGHT_KNB is a real A-4 control, so the
knob is the prototype for that panel rather than throwaway work.
Reserved, not populated. A 9 g servo (ServoMotor, #132) and a 28BYJ-48 + ULN2003 are not
built for 1.0.0: #132 is unmilestoned and unwritten, and StepPattern has no unipolar member, so
neither would be testing anything that exists. Keep one timer-capable J11 pin and some '595
outputs free, and add a panel when those tickets land — which is the point of building to the
Dzus width in the first place.
Firmware
Split Firmware/Examples/E2E_DCS_Test ("one live instance of every control class", PR #130) into two node sketches (distinct NODE_IDs), one per board.
Added 2026-10-27, carried from #375: the SoakRig sketches (PR #339) must build on the bootloader layout. That means Firmware/shared/stm32.ini (app at 0x08001000, identity block), so the #290 soak runs the same layout as production. Board B (C8) came out ~2.3 KB over once the 4 KB bootloader was reserved; the decision (2026-10-26) is -flto rather than moving to the CB. Bench-check ShiftBus ISR timing (SHIFTBUS_ISR_HZ) after turning LTO on.
What SoakRig measures (for the #290 soak — added 2026-10-27)
The #389 bench proved the bridge side with emulated nodes: export words reaching CAN, 0 drops up to 4× the real sim rate. Emulated nodes only send frames; their loop never waits on anything. This rig's nodes do — I²C reads/writes, ShiftBus, real switches, outputs — so it measures the node side:
- Per node, logged every ~10 s (never per event):
rxOverrunCount(),rxDropCount(),txDropCount(), and the longestloop()time since the last line. Per-event logging is out: blocking DiagSerial output dropped 81 % of the bridge's inputs in fix(panelbridge): sim outputs never reach CAN — overlapping DCS-BIOS export listeners #389. - The node RX ring is 8 frames (
CANProtocol.cpp:44, sized in fix(firmware): SYNC_REQ bursts overflow the bridge — sequential, per-node resync + bigger RX ring #387 when sim data barely reached CAN). Since fix(firmware)!: sim output reaches CAN, inputs win the bus and are never lost silently (#389) #392 every node hears the whole sim stream (~1,650 frames/s at the real rate, one CAN ID), so 8 frames last ~5 ms; a full SSD1306 refresh over I²C at 400 kHz is ~23 ms.rxDropCount()against the longestloop()tells whether 8 is enough or a bigger node ring is worth the RAM. Pass rule (test(firmware): v1.0 stability gate — multi-node live-DCS soak + API/wire freeze review #290): inputs must stay responsive (txDropCount()0, short loops); node RX drops are recorded, not failed, as long as outputs catch up within the DCS-BIOS resend window. A drum animating does still send a full OLED frame per 16 ms gate (DrumDisplay.cpp:432); idle and absent displays cost nothing (render-skip,I2cHealth). - Outputs follow the stream (X27s, OLEDs, LEDs, ShiftBus
'595) while I²C, ShiftBus and switch inputs are active — the loop-blocking case the emulated nodes couldn't show. - Slower real loops also send less often (lower input load) — expected to help, confirmed only by the run.
ShiftBus section (via J_SR, board B)
A small perfboard sub-panel on the XH-7 J_SR leg: one 74HC165 carrying an encoder, a 2-position toggle and the stepper home microswitch (exercises the ISR-sampled encoder path, SHIFTBUS_ISR_HZ), and one 74HC595 driving a few LEDs. Per the ShiftBus rules: bussed 10 k bias on all eight '165 inputs, 33 Ω series on SCK / LOAD / LATCH, LEDs ≤ 4 mA direct with series R.
Done when
Both boards pass a short live-DCS smoke test (every control moves its sim counterpart; every output follows the sim), ready for the #290 soak.
This smoke test is the single live check for the v1 classes — the other tickets defer to it instead of running their own sessions:
- feat(firmware): RotaryAcceleratedEncoder — DCS-BIOS-parity subclass of RotaryEncoder #287 — a fast spin on
DEST_LAT_KNBsendsfastStep; a reverse blip mid-spin is swallowed. - feat(firmware): AnalogOutput family — abstract base + Dimmer (backlight PWM) + IntegerOutput #288 —
LIGHTS_INSTRUMENTSdims the board A zone, driven by the same board'sLIGHT_INT_INSTRpot (the loop still runs out over CAN to DCS and back); the IntegerOutput callback logsCANOPY_POS. - ActionButton (feat(firmware): ActionButton — momentary push-button input #116) — one sim toggle per press, nothing on release.
- feat(firmware): SwitchWithCover2Pos — DCS-BIOS parity on a Switch2Pos hook #293 — the MCP 2-pos toggle, reflashed as
SwitchWithCover2Pos(ARM_STATION2, ARM_STATION1, pin), flips armament station 1 then station 2 (≥ 200 ms apart); off reverses the order. Synthetic pairing — see the note above. - feat(firmware): AngleSensorInput — angle sensor on a PinRef, AnalogInput subclass #294 — moved off this rig, see below. The angle-sensor knob drives
GUNSIGHT_KNBacross its travel, including a pass through the sensor's 0°/360° point. Still required by test(firmware): v1.0 stability gate — multi-node live-DCS soak + API/wire freeze review #290; it just happens on its own fixture. - X27 on the MCP23017 (board B) drives correctly — moved here from test(e2e): validate DrumDisplay + NeedleGauge end-to-end over live DCS-BIOS #137.
- refactor(firmware): move node heartbeat, HEALTH and master watchdog into CANProtocol #340 node status service (PR refactor(firmware): move node heartbeat, HEALTH and master watchdog into CANProtocol (#340) #342) on the real bridge ↔ two-node bus — validate the full bus traffic during the soak:
HB_nevery 500 ms andHEALTH_nevery 1 s from every node, the bridge'sHB_0, no false offline, no master-loss WARNING flicker, TEC/REC at 0; plus the two-board CANProtocoldual_*and PanelBridgedual_integration/node_trackingruns. Single-board + CANable2 runs already passed (see PR refactor(firmware): move node heartbeat, HEALTH and master watchdog into CANProtocol (#340) #342). A CANable2 +Tests/CANProtocolenvtest_node_status_buscan log the bus alongside.
Build standard — proof of concept, not a reference design
Dev modules plus perfboard. Modules for every chip family that has one — MCP23017,
ADS1115, TCA9548A, DRV8833. The shift registers are the exception: 74HC165 / 74HC595 in DIP,
soldered to the perfboard. Passives that need a home — pull-ups, the analog RC, the resistor
ladder — go on the same perfboard. No custom PCB, no SSOP parts anywhere on this rig.
This rig proves the firmware classes work against real controls. It is not an electrical
reference design, so component placement, trace length and grounding only need to be good
enough not to be the thing under test.
The one thing that is not negotiable is soldered connections — that is the whole reason this
exists rather than a breadboard. The AxisBench analog wander traced entirely to push-in contact
resistance, and a multi-hour soak on those would measure the contacts rather than the firmware.
Solder and strain-relieve; beyond that, tidy is enough.
Board pairing and revision independence
The A/B split is organisational, not directional. The tables below group inputs on A and
outputs on B, but there is no need to keep that — inputs and outputs can be mixed across both
boards, and arguably should be. A real panel carries switches and indicators on the same node,
so a mixed rig exercises a PanelGroup handling both directions at once, including a round trip
within one node rather than only across the pair. Split by whatever suits the faceplate layout
and connector capacity.
Only two constraints are real:
- The ShiftBus section needs a 0.2.0 board —
J_SRdoes not exist on 0.1.0. - Don't hang an output off J11 position 11 of a 0.2.0 board. There it is
PC14, an OSC32
pin with roughly 3 mA of drive — fine for an input, wrong for an LED or a coil. Position 7 is
PB2, which is BOOT1 but is sampled only when BOOT0 is high, and BOOT0 is tied low, so it is an
ordinary GPIO. On 0.1.0 the two positions arePB5/PA8and both drive normally.
Everything else sits on either revision. Every panel on this rig is buildable now — plates, controls, modules and the '165/'595
perfboard. What is pending is the final hookup: board B's panels have nothing to plug into
until the Rev 2 boards are assembled. Every station has firmware — #293 and #294 both merged
2026-10-01. Board A can additionally be driven now, on the
already-assembled 0.1.0 board.
The panel is a long-lived instrument and must keep plugging into future revisions. It
depends on connector contracts, not on board pin assignments:
J_I2C1/J_I2C2,J_BL,J_SRare interface classes — pin 6 isINT_Awhatever board is
on the other end. These legs are revision-proof as wired.J11is not a class — it is a raw MCU breakout, so its pin meanings move with the silicon:
positions 7 and 11 arePB5/PA8on 0.1.0 andPB2/PC14on 0.2.0.
Positions 7 and 11 are therefore the only revision-volatile part of the rig. That is a
re-pin, not a redesign — crimped contacts pull and re-seat in a minute — so there is no
reason to leave them unused. Just know they are the ones to check against a new board.
Construction consequence: that only holds for crimped legs. J_I2C, J_BL and J_SR are
crimped JST-XH / Mini-Fit and re-pin per contact. J11 is specified as IDC ribbon above, which
is mass-terminated and cannot be re-pinned — a revision that moves those pins means
re-terminating or building a new tail. For an instrument meant to outlive several board
revisions, crimped discrete wires on J11 are worth the extra build time. Decide at build.
Either way the rig plugs into 0.1.0, 0.2.0 and later revisions that keep the connector
standards, and the A/B revision split is a convenience rather than a constraint.
Parallel work — what is gated
Everything mechanical and all panel wiring can proceed now; all controls, modules and displays
are in stock. Only the harness-to-board hookup is gated — see above. Two part lines are on LCSC WM2610010130:
RES-1K-1PCT-0805— the analog RC (1 k + 100 nF). Fit those filters last.CONN-XHP-7P— theJ_SRharness leg. The only Rev 2-specific connector.
Dev modules only on this rig — no SSOP parts. The bare IC-MCP23017-SSOP /
IC-TCA9548A-SSOP in InvenTree are for fabricated boards and are not what this panel uses.
Rig inventory — all on hand, mostly untracked
Bought outside LCSC, so none of it appears in any InvenTree shortfall:
| Part | Qty | Acquired | Needed by this rig |
|---|---|---|---|
| 74HC165 DIP-16 | 20 | 2026-06-30 | ShiftBus perfboard |
| 74HC595 DIP-16 + 20 sockets | 20 | 2026-06-30 | ShiftBus perfboard — socketed, not soldered direct |
| MCP23017 module | — | Jun 2026 | one per board |
| ADS1115 module | 2 | 2026-05-22 | 1 (board A) |
| TCA9548A breakout | 6 | 2026-06-19 | 1 (board B) |
| SSD1306 0.91″ OLED | 10 | 2026-06-19 | 3 (board B) — tracked as OLED-0.91-I2C |
| DRV8833 module | 10 | — | 3 — tracked, no IPN |
| Stepper X27.589 | 10 | — | 3 — tracked as STEP-X27-589; STEP-X27-168 is at zero, and the two differ only in shaft direction |
| ULN2003 + 28BYJ-48 | 6 sets | 2026-08-15 | reserved — not built for 1.0.0 |
| SN65HVD230 module | 3 | 2026-05-17 | not needed on the panel |
Not in the table: an AS5600 and an MT6701 are on hand but untracked, and neither is used on
the faceplate — see the angle-sensor note above. The stepper home microswitch is also absent from
the table; any microswitch or reed switch does.
Every quantity is covered several times over. The tracked IC-74HC165-SOIC (50) and
IC-TCA9548A-SSOP (10) are the SMD parts for fabricated boards — not what this rig uses;
there is no 74HC595 entry in InvenTree at all.
Not in the table: the stepper home microswitch for HomeMode::SENSOR. Any microswitch or
reed switch does; confirm one is on hand before the ShiftBus panel is built.
Reserved: the '595 leg can later drive a ULN2003 → 28BYJ-48, exercising the unipolar-stepper
path ('595 → ULN2003 → coils) on the same ShiftBus section. Out of scope until StepPattern
gains a unipolar member — see Reserved, not populated above.
Nothing will ever flag these short — the same blind spot that let the XH-7 housings reach zero
unnoticed. All of it is physically in hand, so no part of this rig is gated on procurement
except the two LCSC lines below.
Activity
docs(firmware): record the RotaryAcceleratedEncoder hardware run — 5/…
feat(firmware): RotaryAcceleratedEncoder — DCS-BIOS-parity subclass o…
test(firmware): run the AnalogOutput family on hardware — 9/9 envs pass
feat(firmware): AnalogOutput family — abstract base, Dimmer and Integ…
feat(firmware): AngleSensorInput — absolute angle sensor on a PinRef (#…
docs(apn-153): close out the #190 step-rate gate — MCP backend stays (#…
feat(firmware): SwitchWithCover2Pos — cover and switch from one pin (#…
feat(firmware): DrumDisplay takes a bus or a mux (#318)
test(firmware): two-chip ShiftBus chains and the R16-503 on SoakRig
test(firmware): adapt SoakRig to the #295 freeze API changes
docs(firmware): refresh SoakRig flash figures after the #295 freeze m…
ci(firmware): regenerate the impact map after merging main
docs(firmware): record board B's shrinking C8 headroom
test(firmware): put SoakRig on the bootloader layout, -flto on board B
docs(firmware): refresh SoakRig sizes after merging main