PI ZERO 2 W · CYTRON MD25HV · PWM/DIR MODE · SINGLE-WHEEL CHAIN DRIVE

PhotoCar master build guide

Bill of materials, the two-bus power architecture, the exact three-wire Pi→MD25HV control link drawn pin by pin, provisioning a bare Pi two different ways, and a wheels-up first-power procedure. Every warning on this page is a failure that already happened once on this car.

PWM = GPIO18 · HEADER PIN 12 DIR = GPIO23 · HEADER PIN 16 GND = HEADER PIN 14 1 kHz PWM — NEVER 20 kHz MOTOR CURRENT NEVER TOUCHES GPIO
00 · CURRENT SOURCE STATUS

Know which artifact you are trusting

Three artifacts describe this car and they do not agree. Read this before you crimp anything or run a deploy, because the disagreement is about which header pins carry throttle and direction.

AUTHORITATIVE — WIRING

This page

The physical build is wired to this pin map: PWM on GPIO18 / pin 12, DIR on GPIO23 / pin 16, shared GND on pin 14, keyfob on GPIO17 / pin 11, status LED on pins 35 / 37 / 39. Where a source file disagrees, the harness in the car is what exists and the software is what has to move.

DEFAULTS MATCH THIS GUIDE

motor-control/

The receiver and provision-photocar.sh are the truth for behaviour — protocol, watchdog, PWM timing, service environment. Their pin defaults now match this guide's harness: PWM_GPIO=18, DIR_GPIO=23, REMOTE_GPIO=17, LEDs on 9,19/11,26. (Earlier payloads defaulted to 12/21/20/9,13 after a pin survey on one board with dead pads — on that board, override via env.)

NO OVERRIDES NEEDED

deploy.ps1

The only deploy script, and correct end-to-end — discovery, host-key pinning, packaging, root provision, reboot ride-out, verification, PWM self-test. The firmware defaults now match this guide's harness (GPIO18/GPIO23), so a bare .\deploy.ps1 provisions the correct pins.

Pin defaults now match this guide. The firmware ships with throttle on GPIO18 (pin 12) and direction on GPIO23 (pin 16) — exactly the harness drawn in section 03 — so plain provisioning is correct with no environment overrides. (Earlier payloads defaulted to GPIO12/GPIO21, a workaround for one board with dead GPIO18/19 pads; on such a board override with PHOTOCAR_PWM_GPIO=12 PHOTOCAR_DIR_GPIO=21.)

The default maps to dtoverlay=pwm,pin=18,func=2 on PWM channel 0 (GPIO18 reaches the PWM block via ALT5) and writes PWM_GPIO=18 / DIR_GPIO=23 into the service unit. Verify with systemctl show photocar-ble.service -p Environment before a wheel touches the ground.

Reading rule for every number on this page. Physical positions are always quoted as pin N and Broadcom numbers as GPIOn, both, never one alone. “Pin 12” in a forum post is a coin flip between the two; this guide never makes you guess. The diagrams are generated from a single pin-position formula by tools/make-photocar-wiring-svgs.py, which refuses to emit a drawing whose wires do not land on the pins they are labelled with.
01 · PARTS, REUSE & BUDGET

What is on the car and what each thing does

Bill of materials
PartQtySpecificationTerminals / role
Raspberry Pi Zero 2 W (or 2 WH)165 × 30 mm; 4× M2.5 on 58 × 23 mm; BCM43430B0 — BLE 4.2 + 2.4 GHz WiFiRuns photocar-ble.service; PWM/DIR out on GPIO18 / GPIO23
Cytron MD25HV motor driver1Single channel, 7–58 V, 25 A, PWM/DIR mode (not I²C); 111.76 × 60.96 mmPower VB+/VB−; motor MA/MB; control GND/5VO/PWM/DIR
12 V wiper-style gear motor1~100 RPM; ~2.4 Ω → ~5.3 A / ~68 W at 12.8 V; Ø9.5 D-shaftTwo armature leads → MA/MB; park-switch taps unused
12 V 10 Ah LiFePO4 battery (A & B)212.8 V nominal / 14.6 V full, 20 A BMS; F2 spade terminalsPOS/NEG → A/B/OFF selector
A/B/OFF selector, E-stop, DPST master1 eaRotary selector; E-stop cutoff; DPST master switchIn series between Main(+) and Switched(+)
Bus bars4Main(+), Main(−), Switched(+), Switched(−)Distribution to MD25HV + buck
5 V DC-DC buck converter1Input 8–30 V; set output 5.1 V, ≥ 3 A (LM2596 / MP1584)INP/INN in → OUTP/OUTN → Pi PWR IN
Onboard LiFePO4 charger + AC inlet114.6 V 10 A LiFePO4 charger; 120 V AC inletCharges the pack(s) in place
Electronics plate + printed board case1 ea180 × 180 × 2 mm PLA, 22 holes; bolt-down case with side port cutoutHouses MD25HV / Pi / buck
2.54 mm housings + signal wireset22–26 AWG; one 1×3 control, one 1×3 LED, keyfob supply + contactPins 12/14/16 · 35/37/39 · 2/6/9/11
RGB LED module1Onboard resistors; common-cathode; only R, G and wiredBoot status on pins 35 / 37 / 39
433 MHz keyfob receiver + relay1Four wires: red/black supply pair, white/yellow isolated relay contactSupply → pins 2 + 6; contact → pins 9 + 11 (GPIO17)
Single centre knobby wheel, chain, sprocketssetOne-stage chain reductionMotor → chain → centre drive wheel

Buy / add

New parts and why each is non-negotiable
PartQtyMinimum specWhy
4S LiFePO4 pack1–212.8 V nominal, 10 Ah, BMS ≥ 20 A continuousMain power. Two packs plus the selector doubles runtime
microSD card116–32 GB, reputable brandRaspberry Pi OS Bookworm — not Trixie
Inline fuse holder + fuses1Mini blade, 2 A to startProtects the buck/Pi branch
Pre-crimped 1-pin Dupont leads20–4022–26 AWG, 2.54 mmEach conductor starts as a 1-pin lead, then loads into a housing
18 AWG red/black wire6–10 ftStranded copperBattery branch to fuse, switch, buck input
Ferrules, heat-shrink, zip ties, M2.5 nylon standoffssetsMatch gaugesClean terminations, strain relief, non-conductive Pi mount
Multimeter1DC volts + continuityNon-optional. Verifies buck output before the Pi is plugged in
Budget: a minimum cart (new 4S LiFePO4 pack, genuine Pi Zero 2 WH, microSD, buck, fuse, switch, Dupont kit, wire, heat-shrink, standoffs) runs roughly $130–210 on Amazon US, assuming the motor driver, charger, enclosure and tools are reused. Confirm a genuine 4S LiFePO4 pack — not LiPo — with a ≥ 20 A BMS, and real Pi Zero 2 W/WH branding.
Optional: a Pi Camera Module (the Zero needs the narrow CSI ribbon), a micro-USB OTG adapter for rescue access, and an ADC or voltage divider if you want real pack-voltage telemetry — see battery telemetry, which does not work without one.
02 · POWER ARCHITECTURE

One source, two loads, one shared ground

Selected battery → Main(+) bus → E-stop → master On/Off (DPST) → Switched(+) bus → the MD25HV power input and a fused branch to the buck converter. Negatives return through the Main(−) and Switched(−) bars. The buck powers the Pi through its PWR IN port, and the control cable establishes the one place the two rails meet: a shared ground.

PhotoCar power chain from the battery packs to the motor and the Pi Battery A or B feeds an A/B/OFF selector, then the main positive bus, then the emergency stop and DPST master switch in series, then the switched bus. That bus feeds two loads: the MD25HV high-current input driving the wiper motor, and a fused 2 amp branch into a 5 volt buck converter set to 5.1 volts which powers the Pi through its PWR IN micro-USB port. System — power chain Red is motor current on screw terminals. Blue is the low-current branch. The buck feeds the Pi's PWR IN port — never the MD25HV 5VO. Battery A / B 12.8 V LiFePO4 20 A BMS A / B / OFF rotary selector E-stop cuts the whole car Master DPST in series after E-stop Switched bus + and − bars switched + rail to both loads Fuse 2 A at the bus tap 5 V buck set to 5.1 V, ≥ 3 A MD25HV VB± in · MA/MB out Wiper motor chain → wheel 40-pin header RASPBERRY PI ZERO 2 W microSD mini HDMI USB PWR IN 5.1 V → PWR IN PWM / DIR / GND MEASURE FIRST Set the buck to 5.1 V and confirm it at the PI END of the cable. Fuse the WIRE, not the driver. 5VO stays empty.
Red is motor current on screw terminals; blue is the low-current branch. Note that 5 V enters the Pi at PWR IN — the port, not the header.
Point-to-point power wiring
FromToMethodNotes
Battery A/B POS/NEGA/B/OFF selectorF2 spadeSelector picks pack A, pack B, or OFF
Selector out (+)Main(+) busBus barSingle main positive distribution
Main(+) busE-stop → On/Off (DPST)InlineE-stop first, then master switch, in series
On/Off outSwitched(+) busBus barEverything downstream is switch-controlled
Switched(+) busMD25HV VB+Screw / ferruleHeavy wire — this branch carries motor current
Switched(+) busFuse → Buck INPScrew / solderBattery voltage in. Not from MD25HV 5VO
Battery NEGMain(−) / Switched(−) busBus barCommon ground
Switched(−) busMD25HV VB− + Buck INNScrew / ferruleMotor + buck return
Buck OUTP/OUTNPi PWR INExisting Pi power methodVerify 5.1 V at the Pi end before plugging in
MD25HV MA/MBWiper motor armature leadsScrew terminalSwap these two to invert direction
Charge inlet (120 V AC)Onboard 14.6 V charger → packInlet + leadsCharges in place. Do not drive while charging

Soldered Y-split harness

Where the switched bus feeds both the MD25HV and the buck, the clean removable answer is a soldered Y-split, not two wires jammed under one screw.

Fuse the wire, not the driver

Size the fuse for your harness and expected motor current, not the MD25HV's 25 A ceiling. The driver will happily pass far more current than your wiring, connectors and battery harness are rated for — the fuse exists to protect the copper, not the silicon. Start at 2 A on the Pi branch and validate under load.

Before first power-up, in this order: measure the pack at full charge → verify MD25HV power polarity → verify buck input polarity → set the buck to 5.1 V and confirm it at the Pi end of the cable, not at the buck terminals → confirm MD25HV 5VO is wired to nothing → lift the wheel off the ground. Only then apply power.
03 · CONTROL WIRING — PI TO MD25HV

Three wires, one housing, three consecutive pins

The MD25HV runs in PWM/DIR mode. Three conductors connect it to the Pi: PWM = GPIO18 (pin 12), DIR = GPIO23 (pin 16), and a shared GND (pin 14). Those are three consecutive positions on the outer row, so the Pi side is a single 1×3 housing that cannot seat a column off.

Cytron MD25HV wired to the Raspberry Pi Zero 2 W The Raspberry Pi Zero 2 W is drawn as the real board in landscape with its connectors in place: microSD protruding from the left short edge, mini-HDMI, USB data and PWR IN along the bottom edge, CSI on the right short edge, four mounting holes, and the 40-pin header along the top long edge. All forty pins are numbered in position. Pin 1 is at the microSD end on the inner row; even pins form the outer row nearest the board edge, away from the ports. Three wires run from the MD25HV green control terminal down to the header: PWM to physical pin 12 (GPIO18) in blue, GND to physical pin 14 in black, and DIR to physical pin 16 (GPIO23) in purple. Those three are consecutive positions on the even, outer row and form one 1x3 housing. The 5VO terminal is drawn explicitly empty and the high-current VB+, VB-, MA and MB terminals are marked never to be wired to the Pi. Module 1 — Cytron MD25HV motor driver Three wires, one 1×3 housing, on pins 12 / 14 / 16 — consecutive positions on the OUTER (even) row. CYTRON MD25HV PWM/DIR mode · 7–58 V · 25 A · ignore the manual pot / rocker headers CONTROL (green) PWM GND DIR 5VO × HIGH CURRENT VB+ switched + VB− switched − MA motor MB motor NEVER TO THE PI blue wire — PWM Throttle, PWM0 on GPIO18 · to physical pin 12 black wire — GND Shared ground reference · to physical pin 14 purple wire — DIR Direction, GPIO23 · to physical pin 16 1×3 housing — PWM / GND / DIR microSD left short edge mini HDMI video out USB data OTG only PWR IN 5 V from the buck CSI camera right short edge RASPBERRY PI ZERO 2 W component side up · 65 × 30 mm · header along the TOP long edge outer / EVEN → inner / ODD → 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 PIN 1 PIN 12 · GPIO18 PWM Throttle, PWM0 on GPIO18 PIN 14 · GND GND Shared ground reference PIN 16 · GPIO23 DIR Direction, GPIO23 PINS 12, 14 AND 16 ARE CONSECUTIVE ON THE OUTER ROW One 1×3 housing covers all three, so the plug cannot seat a column off. Count from the OUTER row; counting from the inner row silently lands you on 11 / 13 / 15 instead. THREE THINGS THAT KILL THIS BOARD, THIS PI, OR AN EVENING 5VO looks like a free 5 V supply for the Pi. It is not sized for one, and tying it to the Pi back-feeds the buck converter. Leave it empty. Keep PWM at or below ~1 kHz. Near 20 kHz the MD25HV responds only at 100 % duty, so the car has exactly two speeds: nothing and everything. Only the motor's two ARMATURE leads go to MA/MB. Its connector also carries unused park-switch taps, and landing one there looks like a PWM fault.
Every header position is drawn and numbered, because unnumbered pinouts are how a build lands an entire column off. The inset shows the same board the right way up, so you can tell which physical end pin 1 is at.
Pin 1 is LOWER-left, and the even row is the OUTER row. On this board in this orientation — component side up, header along the top edge, microSD on the left, ports along the bottom — the even pins (2…40) are the row nearest the board edge, away from the ports, and the odd pins (1…39) are the row toward the ports. An earlier revision of this very page drew it the other way round. If you count from the wrong row, 12/14/16 becomes 11/13/15 and nothing works.
Pi header → MD25HV control terminal
Pi pinSignalMD25HVWire
Pin 12GPIO18 (PWM0, ALT5)PWMBlue — motor speed
Pin 14GNDGNDBlack — common reference
Pin 16GPIO23DIRPurple — direction
no connection5VOLeave empty

Connector plan

  • Pi side: one 1×3 housing spanning pins 12 / 14 / 16, loaded in that physical order.
  • MD25HV side: the green control screw terminal at PWM, GND, DIR. Use ferrules; stranded wire in a screw clamp frays and bridges.
  • No loose single-pin jumpers. They walk off the header under vibration, and a PWM wire that lifts mid-drive leaves DIR asserted at an unknown duty.
  • Pi GPIO is 3.3 V and the MD25HV accepts it directly. No level shifter is needed or wanted.
  • Ignore the MD25HV's small potentiometer and rocker headers — those are for standalone manual control and have nothing to do with the Pi.

5VO is a trap, not a convenience

It looks exactly like a free 5 V supply for the Pi and it sits on the same terminal block you are already wiring. It is not sized for a Pi under WiFi load, and tying it to the Pi's 5 V rail back-feeds the buck converter's output. The Pi gets 5 V from the buck, at PWR IN, and from nowhere else.

The MD25HV goes fully open above about 1 kHz

At 20 kHz the driver responds only at 100 % duty — it reads the throttle waveform as a constant high, and partial throttle is dead. The car then has exactly two speeds: nothing, and everything. Keep PWM_FREQUENCY_HZ at or below ~1 kHz and never raise it to chase motor whine.

Measured on this driver · provision-photocar.sh, deploy.ps1 -PwmFreqHz

Only two of the motor connector's wires are motor wires

The wiper gear motor's connector carries the two armature leads plus unused park-switch taps. Only the armature pair goes to MA/MB. Land a park-switch tap on a motor terminal and the car behaves erratically at low throttle, which you will spend an evening blaming on the PWM frequency.

Some pads on some Pi Zero 2 W boards are simply dead

The firmware tree records a survey done by driving each pin as a push-pull output and reading the pad back: on the unit tested, GPIO16, 18 and 19 stayed low even when driven high, while GPIO5/6/9/11/12/13/20/21/23/26 drove fine. That measurement is why the firmware defaults moved off GPIO18/19. This car is wired to GPIO18/19/23 and works — but if a signal ever refuses to drive no matter what the overlay says, re-run the pad test before blaming the wiring: pinctrl set 18 op dh then pinctrl get 18. A dead pad is a Pi swap, not a solder joint.

provision-photocar.sh, photocar-bootled.sh comments

Direction sense. Firmware default is DIR_FORWARD_LEVEL=1 — forward means DIR high. If the motor spins the wrong way, swap the two motor leads at MA/MB or flip DIR_FORWARD_LEVEL. Never fix direction by moving a control wire.
04 · STATUS LED & KEYFOB

What the car tells you, and the one input that can drive it

Both of these land on the inner (odd) row, at opposite ends of the header, so neither can be confused with the control harness on 12/14/16.

Boot status LED

RGB status LED module wired to the Raspberry Pi Zero 2 W The Raspberry Pi Zero 2 W is drawn as the real board in landscape with its connectors in place: microSD protruding from the left short edge, mini-HDMI, USB data and PWR IN along the bottom edge, CSI on the right short edge, four mounting holes, and the 40-pin header along the top long edge. All forty pins are numbered in position. Pin 1 is at the microSD end on the inner row; even pins form the outer row nearest the board edge, away from the ports. Three wires run from the RGB LED module down to the header: red to physical pin 35 (GPIO19), green to physical pin 37 (GPIO26), and the common cathode to physical pin 39, a ground pin. Those are three consecutive positions on the odd, inner row at the CSI end of the board and form one 1x3 housing. The module's blue leg is deliberately left unwired. Module 2 — RGB boot status LED Three wires, one 1×3 housing, on pins 35 / 37 / 39 — consecutive positions on the INNER (odd) row, at the CSI end. RGB LED module onboard resistors · common cathode R G B blue leg — no wire, by design red wire — R Status red, GPIO19 · to physical pin 35 green wire — G Status green, GPIO26 · to physical pin 37 black wire — − Common cathode to ground · to physical pin 39 1×3 housing — R / G / − microSD left short edge mini HDMI video out USB data OTG only PWR IN 5 V from the buck CSI camera right short edge RASPBERRY PI ZERO 2 W component side up · 65 × 30 mm · header along the TOP long edge outer / EVEN → inner / ODD → 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 PIN 1 PIN 35 · GPIO19 R Status red, GPIO19 PIN 37 · GPIO26 G Status green, GPIO26 PIN 39 · GND Common cathode to ground RED BLINKS FROM EARLY KERNEL BOOT, THEN GREEN GOES SOLID Red is driven by the kernel through a gpio-led overlay, so it blinks seconds into boot, long before any service runs. Green solid means photocar-ble.service is up and the car will answer a drive command. Blinking red again later means the service stopped. ONE REBOOT REQUIRED, AND NO EXTRA RESISTORS The gpio-led overlay goes into config.txt at install time and does nothing until the next boot. A dark LED right after install is expected. This module already has series resistors — adding your own gives a dim or dead LED. Common-anode part? Set PHOTOCAR_LED_ACTIVE_HIGH=0.
The LED takes one 1×3 on pins 35 / 37 / 39 — three consecutive INNER-row positions. The blue leg is deliberately unwired.
Blinking red
kernel booting, or service down
Solid green
photocar-ble.service active
Back to blinking red
service stopped or crashed
Why red blinks before Linux exists. The installer adds dtoverlay=gpio-led,gpio=19,label=photocar_red19,trigger=timer to config.txt, handing the pin to the kernel's LED driver at boot. Red therefore starts blinking seconds in, long before any service runs — and the LED needs one reboot after install before it works at all. Afterwards photocar-bootled.service takes over through /sys/class/leds/ and swaps red for green.

The module has onboard resistors — do not add more

Adding series resistors gives a dim or dead LED. A bare RGB LED would need roughly 330 Ω per leg, but this module does not. If your module is common-anode rather than common-cathode the logic inverts: set PHOTOCAR_LED_ACTIVE_HIGH=0 in /etc/systemd/system/photocar-bootled.service and restart it.

A stale gpio-led overlay keeps the kernel owning a pin you moved off

Move the red LED to a different GPIO and the old overlay line stays in config.txt; the kernel keeps claiming the old pin and /sys/class/leds/ still shows a device that can never light. The installer prunes overlays for GPIOs no longer in the red list, and asks for a reboot when it does. The LED env vars are comma-separated lists — for this car's single LED set them to 19 and 26.

install-bootled-on-pi.sh

433 MHz keyfob

433 MHz keyfob receiver wired to the Raspberry Pi Zero 2 W The Raspberry Pi Zero 2 W is drawn as the real board in landscape with its connectors in place: microSD protruding from the left short edge, mini-HDMI, USB data and PWR IN along the bottom edge, CSI on the right short edge, four mounting holes, and the 40-pin header along the top long edge. All forty pins are numbered in position. Pin 1 is at the microSD end on the inner row; even pins form the outer row nearest the board edge, away from the ports. The receiver has four wires. Red and black are the module's own supply and go to physical pin 2 (5 V) and physical pin 6 (ground). White and yellow are the two sides of the relay's isolated dry contact: one goes to physical pin 9, a ground pin, and the other to physical pin 11 (GPIO17). Because a dry contact is a symmetric switch, white and yellow are interchangeable between those two pins. Closing the relay shorts GPIO17 to ground, which the firmware reads as go, using the Pi's internal pull-up. Module 3 — 433 MHz keyfob receiver FOUR wires: red / black are the module's own supply, white / yellow are the relay's isolated dry contact. 433 MHz receiver + relay the contact is isolated — no module voltage ever reaches a GPIO antenna SUPPLY — powers the coil V+ V− DRY CONTACT — interchangeable contact A contact B red wire — V+ Module supply, 5 V · to physical pin 2 black wire — V− Module supply ground · to physical pin 6 white wire — contact A Relay contact to ground · to physical pin 9 yellow wire — contact B Relay contact to GPIO17 · to physical pin 11 supply pair + contact pair microSD left short edge mini HDMI video out USB data OTG only PWR IN 5 V from the buck CSI camera right short edge RASPBERRY PI ZERO 2 W component side up · 65 × 30 mm · header along the TOP long edge outer / EVEN → inner / ODD → 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 PIN 1 PIN 2 · 5V V+ Module supply, 5 V PIN 6 · GND V− Module supply ground PIN 9 · GND contact A Relay contact to ground PIN 11 · GPIO17 contact B Relay contact to GPIO17 WHITE AND YELLOW ARE INTERCHANGEABLE; RED AND BLACK ARE NOT A relay dry contact is just an isolated switch, so either contact wire can take pin 9 and the other pin 11 — there is no wrong way round. The supply pair IS polarised. Check the module's rated voltage first: if it is a 12 V module, feed red/black from the switched bus instead of the header, and bring only the contact pair to pins 9 and 11. THIS IS THE ONE INPUT THAT CAN DRIVE THE CAR BY ITSELF A floating input reads as HELD. The pin is active-LOW, so if the internal pull-up fails to apply, an unwired pin sits low and the firmware reads a permanent press. That is a real bug this car had: it drove itself at 70 % throttle on boot with nothing plugged into the header. In toggle mode a phantom press at startup LATCHES the motor on and the level never changes again to switch it off. The 1.2 s watchdog does NOT save you, because the fob loop feeds the watchdog itself. Confirm the pin reads inactive at idle before you fit a wheel.
Four wires, and they are not equivalent: red/black power the module, white/yellow are the two sides of an isolated relay contact. Nothing here touches the control harness on 12 / 14 / 16.

The receiver has four wires, in two electrically separate pairs. Red and black are the module's own supply — they power the receiver and the relay coil, and go to pin 2 (5 V) and pin 6 (GND). White and yellow are the two sides of the relay's isolated dry contact: one goes to pin 9 (GND), the other to pin 11 (GPIO17). Because a dry contact is just a symmetric switch, white and yellow are interchangeable — there is no wrong way round. The GPIO is an input with the Pi's internal pull-up, so an open contact reads high and a closed relay pulls it low. Active LOW means “go”, and no module voltage ever reaches the GPIO.

Check the module's rated supply voltage before wiring red and black. The diagram feeds them from the header's 5 V pin, which is right for a 5 V module. If yours is a 12 V module, feed red/black from the switched bus instead and bring only the white/yellow contact pair to pins 9 and 11. The contact pair is isolated either way, so that change is safe for the Pi.
Keyfob behaviour
VariableDefaultEffect
REMOTE_ENABLED1Set 0 to disable the fob loop entirely
REMOTE_TOGGLE11 = press toggles drive on/off; 0 = drives only while held
REMOTE_THROTTLE / _DIR70 / forwardThrottle percent and direction the fob commands
REMOTE_ACTIVE_LOW1LOW = pressed. Matches a NO contact to ground
REMOTE_POLL_MS / _DEBOUNCE_TICKS120 / 2Poll rate, and ticks required to start motion

The car drove itself on boot with nothing connected

The keyfob pin's internal pull-up was applied with a swallowed error, so a failure to apply it was invisible. Without the pull-up an unwired input floats low, an active-low read calls that “held”, and toggle mode started the motor by itself on every boot — at REMOTE_THROTTLE, with nothing plugged in. The pull-up is now verified after being applied and the service shouts if it did not take. If you rewire or disable the fob, confirm the pin reads inactive at idle before a wheel touches the ground.

bleReceiver.ts keyfob setup

A phantom press latches the motor on, and it never unlatches

In toggle mode an input that is already active when the service starts — a held fob, a stuck relay, or a floating pin — counts as a press, latches the motor on, and the level then never changes again to turn it off. The loop now seeds from the very first sample and requires a genuine release→press edge. The 1.2 s watchdog does not save you here, because the fob loop feeds the watchdog itself.

bleReceiver.ts keyfob loop

Debounce is deliberately asymmetric — do not "fix" it

Transitions that stop the motor act on the first tick, so a short fob pulse can always stop the car. Transitions that start motion need the full REMOTE_DEBOUNCE_TICKS, so noise cannot start it. Making it symmetric in either direction makes the car less safe.

Do not judge a pull-up by pinctrl's pull column

Some pinctrl builds always render the pull column as -- even when the pull did apply, so asserting on that text warns on every boot. Judge by the resulting level: with the pull-up on and no contact closed, the pin must read inactive.

05 · SOFTWARE & PROVISIONING

Two paths to the same provisioned Pi

The Pi runs motor-control (TypeScript on Node) as the systemd service photocar-ble.service, installed by scripts/provision-photocar.sh. It takes drive commands over Bluetooth LE from the tablet app and over a plain TCP port on WiFi. Both paths below end at the same provisioner, which is idempotent and safe to re-run.

Step 0 — flash the right OS

photorcar.local — the hostname typo that never resolved

An r transposition lived in the docs and the deploy default for a while, and it fails as a name-resolution error that reads exactly like a dead Pi or a broken network. The hostname is photocar. The deploy scanner deliberately matches the loose regex photo.?car so it still finds a Pi flashed either way.

deploy.ps1

No wireless country = a radio that boots rfkill-blocked

Skip the country field in the Imager and the Pi can come up with the radio soft-blocked: no network, and no way in except BLE. The provisioner sets it defensively with raspi-config nonint do_wifi_country, but that only helps once you can already reach the Pi.

Step 0.5 — SSH in

# from any machine on the same WiFi network
ssh dealermade@photocar.local

Way 1 — from the PC: deploy.ps1

The only deploy script. It finds the Pi, pins its SSH host key, packages motor-control, uploads, provisions as root, rides out the reboot, verifies the service, and runs the PWM self-test.

$env:PHOTOCAR_PI_PASSWORD = "<pi password>"
.\deploy.ps1

.\deploy.ps1 -Scan                  # sweep the /24 and find the Pi by hostname
.\deploy.ps1 -HostName 192.168.1.66 # target an IP directly
.\deploy.ps1 -DryRunMotor           # provision with PHOTOCAR_REAL_MOTOR=0
.\deploy.ps1 -Audio off             # frees the onboard-audio PWM claim
deploy.ps1 parameters
ParameterDefaultMeaning
-HostNamephotocar.localTarget host or IP
-UserdealermadeSSH user; also the desktop user the provisioner expects to exist
-Password$env:PHOTOCAR_PI_PASSWORD, else passwordSet the env var so the credential never lives in the repo
-Scanoff64-runspace parallel TCP:22 sweep of the /24, matching hostname against photo.?car
-Audiokeepkeep / on / off — the onboard-audio dtparam
-PwmFreqHz1000MD25HV throttle PWM frequency — do not raise
-DryRunMotoroffService runs but never drives GPIO
-RebootWaitSec180How long to wait for the Pi after the provisioner reboots it

Requires PuTTY's plink.exe and pscp.exe at C:\Program Files\PuTTY\. It also refreshes PhotoCarTablet/pages-guide/photocar-motor.tar.gz, so pushing the tablet repo republishes the payload for Way 2.

Way 2 — on the car itself, over WiFi, no PC

curl -fsSL https://photocar.userinterfaces.shop/setup.sh -o /tmp/photocar-setup.sh
sudo bash /tmp/photocar-setup.sh

# or, as a one-liner
curl -fsSL https://photocar.userinterfaces.shop/setup.sh | sudo bash

Verifying either way

sudo bash /home/dealermade/photocar-motor/scripts/provision-photocar.sh selftest
systemctl is-active photocar-ble.service
systemctl show photocar-ble.service -p Environment | tr ' ' '\n' | grep -E 'PWM_GPIO|DIR_GPIO'
ss -ltn | grep 7777
journalctl -u photocar-ble.service -f

The PWM self-test reports a false negative against a live service

The test writes period/duty/enable on the very channel the receiver owns. Against a running receiver the period write fails with EINVAL — you cannot resize an enabled channel — and it reports failure on perfectly good hardware. The script now stops the service for the duration and restarts it after. It also once failed for a second reason: kernels differ on where they report channel state in /sys/kernel/debug/pwm, and matching only the “actual configuration” line made a working channel report <unreadable>.

provision-photocar.sh selftest()

Never stack two PWM overlays

If a dtoverlay=pwm,pin= line already exists for a different pin, a second one makes two overlays contend for the same channel and throttle lands on the wrong pin — which looks exactly like dead hardware. Check with grep dtoverlay=pwm /boot/firmware/config.txt: there must be exactly one line, and it must read pin=18,func=2.

The reboot makes the SSH command exit non-zero — that is expected

When the boot config changed, the provisioner reboots after five seconds, dropping the connection so the remote command returns an error. deploy.ps1 handles it: it polls port 22 on both the IP and the hostname until -RebootWaitSec, re-scans if -Scan was given, waits for services to settle, and re-reads the host key. Do not read that exit code as a failure.

Pin the ed25519 host key, and strip CRLF

Two deploy-killers in one place. plink/pscp negotiate ed25519 first, so a pinned RSA fingerprint produces “host key not in configured list” and the deploy dies before it starts. And a Windows working copy can pick up CRLF line endings, which makes bash fail on the shebang or on a trailing \r in every command — the deploy runs sed -i 's/\r$//' scripts/*.sh on the Pi first. Do the same if you ever copy scripts across by hand.

Service environment

photocar-ble.service environment
VariableValueMeaning
PWM_GPIO18 (default)Throttle PWM on GPIO18 / PWM0 / pin 12. Firmware default since the pin-default fix
DIR_GPIO23 (default)Direction on GPIO23 / pin 16. Firmware default since the pin-default fix
REMOTE_GPIO17 (default)Keyfob relay input on GPIO17 / pin 11. Firmware default since the pin-default fix
PWM_FREQUENCY_HZ10001 kHz hardware PWM. Keep ≤ ~1 kHz
DIR_FORWARD_LEVEL1Forward = DIR high
MAX_THROTTLE100Commands are clamped to 0–MAX_THROTTLE
WATCHDOG_TIMEOUT_MS1200Motor stops if no drive command arrives within 1.2 s
DIRECTION_CHANGE_PAUSE_MS20Ramps to 0, sets DIR, pauses, then ramps up
RAMP_STEP_PERCENT / _DELAY_MS100 / 0100 % step, no delay = no ramp. Lower the step for a soft start
WIFI_CONTROL_PORT / _HOST7777 / 0.0.0.0TCP control listener
BLUETOOTH_ENABLED1BLE GATT control enabled
PHOTOCAR_REAL_MOTOR11 = drive real GPIO/PWM; 0 = dry run
ALLOW_SHUTDOWN1Permits the app's power-off command to halt the Pi
BATTERY_VOLTAGE_FILEunsetPath to a file holding pack volts — see below

The control protocol

BLE and the TCP port speak the same JSON. On TCP it is newline-delimited both ways on port 7777; on connect the server sends a hello frame with full status, then a status frame after every command and on every state change.

{"cmd":"drive","dir":"forward","throttle":50,"seq":1}
{"cmd":"stop","seq":2}
{"cmd":"ping","seq":3}
{"cmd":"wifiScan"}
{"cmd":"wifi","ssid":"MyNetwork","psk":"secret"}
{"cmd":"shutdown"}
BLE GATT
ItemValue
Advertised namePhotoCar
Service UUID7f61c8d0-31a6-49f4-8c20-0b0f3159e5c1
Control characteristic (write)7f61c8d1-31a6-49f4-8c20-0b0f3159e5c1
Status characteristic (read / notify)7f61c8d2-31a6-49f4-8c20-0b0f3159e5c1
Pairing“Just works”, NoInputNoOutput, auto-accepted and marked trusted
The TCP port has no authentication. Anything that can reach port 7777 can drive the car. It binds 0.0.0.0 by design so the tablet works over WiFi. Do not put this car on a network you do not control, and never port-forward 7777.
Failing safe. Motion is kept alive only by fresh drive commands — a ping from a controller whose drive loop has died will not sustain throttle. After 1.2 s with no drive command the motor stops and the state becomes watchdog_stop; a ping clears it to stopped. A TCP client that owned motion and disconnects has a stop injected on its behalf. An unparseable or unknown command triggers an immediate stop and the fault state — a controller sending garbage is not one you should trust to send a stop.

The Pi Zero 2 W's Bluetooth rejects BlueZ's managed advertising

The BCM43430B0 controller is HCI 4.2. BlueZ's D-Bus LEAdvertisingManager1 forces Bluetooth 5 extended advertising, which this firmware rejects with “Invalid Parameters (0x0d)”, so the advertisement can never register that way. The receiver instead advertises through the kernel management API with btmgmt add-adv using legacy advertising — which works here and is kernel-managed, so connections route to the GATT app and advertising auto-restarts after a disconnect.

bleReceiver.ts advertising

BLE silently truncated the status payload at 512 bytes

A BLE ATT attribute value cannot exceed 512 bytes. The full status — logs plus scanned networks — runs to several KB, so BLE clients got a truncated, unparseable JSON fragment and never saw wifiState, wifiIp or battery. The status is now shrunk to fit: networks and logs drop first, then the newest logs are added back while they fit.

Forcing a WiFi scan rfkill-blocked the radio and dropped the Pi off the network

The BCM43430 shares one radio with Bluetooth. Triggering an active scan wedged it hard enough to rfkill-block the interface mid-session. The scan path now uses iw dev <iface> scan dump, which returns the kernel's cached table and never initiates a scan. Do not replace it with iw scan or nmcli device wifi rescan. A more severe firmware wedge can deregister wlan0 entirely — the failure where nmcli con up only sees loopback — which no amount of rfkill unblocking fixes; the 18 s watchdog escalates to a brcmfmac driver reload.

bleReceiver.ts WiFi scan and watchdog

The touchscreen kiosk is deliberately never autostarted

The kiosk UI has a latch mode that pins activeDrive on and re-sends that drive command about ten times a second — feeding the receiver's watchdog and holding the motor at full throttle with no operator input. On a car with no touchscreen there is also no way to clear the latch. dist/kioskUi.js stays in the tree for manual debugging, but the provisioner removes any autostart entry a previous install left and kills a running instance on every provision. Do not re-add it.

provision-photocar.sh step 6

Battery telemetry

Pack voltage is not read from an ADC driver. The service reads plain files named by BATTERY_VOLTAGE_FILE and BATTERY_PERCENT_FILE and parses each as a number. Until you wire an ADC or divider and have something write those files, the app shows:

Car battery voltage is not wired to the Pi yet.
Add an ADC/voltage divider and set BATTERY_VOLTAGE_FILE to show pack voltage.

Pi-side health is real: vcgencmd get_throttled and measure_temp feed under-voltage, frequency-cap and thermal flags into the same status payload, and a CPU temperature at or above 80 °C raises a note. An under-voltage flag on this car almost always means the buck is sagging or set below 5.1 V, not that the pack is flat.

06 · ASSEMBLY

Bench first, harness second, car last

07 · FIRST POWER-UP

Prove one subsystem at a time

08 · SYMPTOM INDEX

It is doing something impossible — start here

Every entry is a failure that already happened on this car. Find the symptom, then read the linked section before you re-measure anything.

Symptom → most likely cause
SymptomMost likely causeSection
Car does not move at all, service healthyProvisioned from an old payload whose defaults were GPIO12/21 — re-provision with a current payload (defaults are GPIO18/23)00
Only full speed or nothing — no partial throttlePWM frequency raised above ~1 kHz; the MD25HV then reads 100 % duty03
Erratic behaviour at low throttleA park-switch tap landed on MA/MB instead of the armature pair03
Nothing works and the count looks rightCounted from the inner row — 12/14/16 are on the outer row03
One signal will not drive no matter whatDead pad on that Pi. Re-run the push-pull pad test before blaming wiring03
Motor runs on boot with nothing connectedKeyfob pull-up did not apply; a floating active-low input reads as held04
Motor latched on and the fob will not stop itPhantom press at service start. The watchdog cannot save you — the fob loop feeds it04
LED never lights, or only green lightsNo reboot after the gpio-led overlay, or a stale overlay still owns the old pin04
LED dim or deadExternal resistors added to a module that already has them04
Self-test fails on known-good hardwareRun against a live receiver — EINVAL resizing an enabled channel05
Throttle appears on the wrong pinTwo stacked dtoverlay=pwm lines contending for one channel05
SSH host says “not in configured list”RSA fingerprint pinned; plink negotiates ed25519 first05
Provisioner fails on the shebang or every lineCRLF line endings from a Windows working copy05
Deploy “fails” right after provisioningThe expected reboot dropping SSH — the script rides it out05
Host unreachable, looks like a dead Piphotorcar.local typo, or no wireless country set so the radio is blocked05
Pi drops off WiFi mid-sessionA forced scan wedging the shared radio, or a firmware wedge deregistering wlan005
Tablet shows no wifi/battery state over BLEStatus exceeded the 512-byte ATT cap and arrived truncated05
BLE never advertisesBlueZ extended advertising rejected 0x0d by the BCM43430B005
Motor runs at full throttle with no inputThe kiosk UI was autostarted; its latch mode feeds the watchdog05
Battery shows “not configured”No ADC wired; BATTERY_VOLTAGE_FILE unset. Working as designed05
Pi reports under-voltageBuck sagging or set below 5.1 V — usually not a flat pack05
Source notes. Behavioural facts — PWM timing, service environment, protocol, watchdog, provisioning sequence and every gotcha — are read from the live firmware in motor-control/, primarily scripts/provision-photocar.sh, src/MotorController.ts, src/bleReceiver.ts and deploy.ps1. Physical pin assignments are the built harness, which currently disagrees with the firmware's shipped defaults — see section 00. The wiring diagrams are generated by tools/make-photocar-wiring-svgs.py, which asserts that all forty pins are numbered and that every wire terminates on the pin it claims.