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 12DIR = GPIO23 · HEADER PIN 16GND = HEADER PIN 141 kHz PWM — NEVER 20 kHzMOTOR CURRENT NEVER TOUCHES GPIO
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
Part
Qty
Specification
Terminals / role
Raspberry Pi Zero 2 W (or 2 WH)
1
65 × 30 mm; 4× M2.5 on 58 × 23 mm; BCM43430B0 — BLE 4.2 + 2.4 GHz WiFi
Runs photocar-ble.service; PWM/DIR out on GPIO18 / GPIO23
Cytron MD25HV motor driver
1
Single channel, 7–58 V, 25 A, PWM/DIR mode (not I²C); 111.76 × 60.96 mm
Power VB+/VB−; motor MA/MB; control GND/5VO/PWM/DIR
12 V wiper-style gear motor
1
~100 RPM; ~2.4 Ω → ~5.3 A / ~68 W at 12.8 V; Ø9.5 D-shaft
Two armature leads → MA/MB; park-switch taps unused
12 V 10 Ah LiFePO4 battery (A & B)
2
12.8 V nominal / 14.6 V full, 20 A BMS; F2 spade terminals
Main power. Two packs plus the selector doubles runtime
microSD card
1
16–32 GB, reputable brand
Raspberry Pi OS Bookworm — not Trixie
Inline fuse holder + fuses
1
Mini blade, 2 A to start
Protects the buck/Pi branch
Pre-crimped 1-pin Dupont leads
20–40
22–26 AWG, 2.54 mm
Each conductor starts as a 1-pin lead, then loads into a housing
18 AWG red/black wire
6–10 ft
Stranded copper
Battery branch to fuse, switch, buck input
Ferrules, heat-shrink, zip ties, M2.5 nylon standoffs
sets
Match gauges
Clean terminations, strain relief, non-conductive Pi mount
Multimeter
1
DC volts + continuity
Non-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.
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.
6Loads: MD25HV VB± + 5 V buckheavy wire to the driver, fused 2 A to the buck
5Switched(+) / Switched(−) buseverything downstream is switch-controlled
4Master On/Off — DPSTin series after the E-stop
3Emergency stopcuts the whole car, not just the motor
2Main(+) / Main(−) bussingle distribution point
1Battery A / B via A/B/OFF selectorF2 spade terminals, 12.8 V LiFePO4
Point-to-point power wiring
From
To
Method
Notes
Battery A/B POS/NEG
A/B/OFF selector
F2 spade
Selector picks pack A, pack B, or OFF
Selector out (+)
Main(+) bus
Bus bar
Single main positive distribution
Main(+) bus
E-stop → On/Off (DPST)
Inline
E-stop first, then master switch, in series
On/Off out
Switched(+) bus
Bus bar
Everything downstream is switch-controlled
Switched(+) bus
MD25HV VB+
Screw / ferrule
Heavy wire — this branch carries motor current
Switched(+) bus
Fuse → Buck INP
Screw / solder
Battery voltage in. Not from MD25HV 5VO
Battery NEG
Main(−) / Switched(−) bus
Bus bar
Common ground
Switched(−) bus
MD25HV VB− + Buck INN
Screw / ferrule
Motor + buck return
Buck OUTP/OUTN
Pi PWR IN
Existing Pi power method
Verify 5.1 V at the Pi end before plugging in
MD25HV MA/MB
Wiper motor armature leads
Screw terminal
Swap these two to invert direction
Charge inlet (120 V AC)
Onboard 14.6 V charger → pack
Inlet + leads
Charges 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.
Cut one trunk wire and two branch wires. Use heavier wire on the MD25HV branch — that one carries motor current; the buck branch carries under an amp.
Slide adhesive-lined heat-shrink onto the trunk before soldering. Everyone forgets this once.
Strip enough that the three copper ends overlap mechanically. Twist the branches together, then wrap with the trunk. The joint must hold without solder.
Solder so it wicks through the joint rather than balling on the outside; let it cool without movement or you get a dull, brittle cold joint.
Cover with the adhesive heat-shrink, then an outer sleeve wherever the harness will vibrate. Repeat for the negative side.
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.
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 pin
Signal
MD25HV
Wire
Pin 12
GPIO18 (PWM0, ALT5)
PWM
Blue — motor speed
Pin 14
GND
GND
Black — common reference
Pin 16
GPIO23
DIR
Purple — direction
—
no connection
5VO
Leave 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.
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
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
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
Variable
Default
Effect
REMOTE_ENABLED
1
Set 0 to disable the fob loop entirely
REMOTE_TOGGLE
1
1 = press toggles drive on/off; 0 = drives only while held
REMOTE_THROTTLE / _DIR
70 / forward
Throttle percent and direction the fob commands
REMOTE_ACTIVE_LOW
1
LOW = pressed. Matches a NO contact to ground
REMOTE_POLL_MS / _DEBOUNCE_TICKS
120 / 2
Poll 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.
Choose OS: Raspberry Pi OS (Bookworm, Debian 12). The provisioner's PWM and boot-config handling targets Bookworm. Do not pick a Trixie (Debian 13) image; if the Imager defaults to Trixie, go to Raspberry Pi OS (other) and pick a release explicitly labelled Bookworm.
Click gear / Edit Settings before writing: hostname photocar; user dealermade + password; WiFi SSID, password and the wireless LAN country; Services tab → enable SSH with password authentication.
Write, fit, power up. First boot takes a couple of minutes while it resizes the filesystem and joins WiFi.
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
If photocar.local does not resolve (mDNS is flaky on Windows), find the Pi in your router's client list, or run .\deploy.ps1 -Scan, then ssh dealermade@<ip>.
Sanity check: hostname must print photocar, and cat /etc/os-release must show VERSION_CODENAME=bookworm. If it says trixie, reflash before provisioning.
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
Parameter
Default
Meaning
-HostName
photocar.local
Target host or IP
-User
dealermade
SSH user; also the desktop user the provisioner expects to exist
-Password
$env:PHOTOCAR_PI_PASSWORD, else password
Set the env var so the credential never lives in the repo
-Scan
off
64-runspace parallel TCP:22 sweep of the /24, matching hostname against photo.?car
-Audio
keep
keep / on / off — the onboard-audio dtparam
-PwmFreqHz
1000
MD25HV throttle PWM frequency — do not raise
-DryRunMotor
off
Service runs but never drives GPIO
-RebootWaitSec
180
How 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
setup.sh downloads photocar-motor.tar.gz and extracts it to /home/dealermade/photocar-motor.
It hands off to provision-photocar.sh provision — the same provisioner Way 1 runs. The apt step (bluetooth bluez build-essential nodejs npm python3 git x11-xserver-utils) takes several minutes on a Zero 2 W and looks hung. It is not.
Writes the PWM overlay, adds the user to the gpio i2c spi video render input dialout plugdev groups, then npm install && npm run build.
Pins the WiFi profile you are connected on to autoconnect-priority 100 and disables autoconnect on every competing profile.
Installs and enables photocar-ble.service and photocar-bootled.service, runs the PWM self-test, starts the receiver.
On a fresh image the boot config changed, so the Pi reboots itself once and your SSH session drops. That is expected.
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
Variable
Value
Meaning
PWM_GPIO
18 (default)
Throttle PWM on GPIO18 / PWM0 / pin 12. Firmware default since the pin-default fix
DIR_GPIO
23 (default)
Direction on GPIO23 / pin 16. Firmware default since the pin-default fix
REMOTE_GPIO
17 (default)
Keyfob relay input on GPIO17 / pin 11. Firmware default since the pin-default fix
PWM_FREQUENCY_HZ
1000
1 kHz hardware PWM. Keep ≤ ~1 kHz
DIR_FORWARD_LEVEL
1
Forward = DIR high
MAX_THROTTLE
100
Commands are clamped to 0–MAX_THROTTLE
WATCHDOG_TIMEOUT_MS
1200
Motor stops if no drive command arrives within 1.2 s
DIRECTION_CHANGE_PAUSE_MS
20
Ramps to 0, sets DIR, pauses, then ramps up
RAMP_STEP_PERCENT / _DELAY_MS
100 / 0
100 % step, no delay = no ramp. Lower the step for a soft start
WIFI_CONTROL_PORT / _HOST
7777 / 0.0.0.0
TCP control listener
BLUETOOTH_ENABLED
1
BLE GATT control enabled
PHOTOCAR_REAL_MOTOR
1
1 = drive real GPIO/PWM; 0 = dry run
ALLOW_SHUTDOWN
1
Permits the app's power-off command to halt the Pi
BATTERY_VOLTAGE_FILE
unset
Path 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.
“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
Bench-test the Pi before it goes near the car. Image Bookworm with WiFi and SSH, provision it with the pin override, pair the tablet on USB bench power, and confirm the PWM self-test passes. A Pi that cannot pass selftest on a bench will not start working once bolted into an enclosure.
Mount the Pi and buck. Vacuum the enclosure. Mount the Pi on non-conductive standoffs. Keep the 5 V leads short and the Pi's antenna area clear of metal and large bundles.
Build the fused, switched buck branch. Fuse close to the bus tap, not next to the Pi. Verify buck input polarity, then set the output to 5.1 V with a meter before it ever meets the Pi.
Build the control harness from 1-pin pre-crimped leads into one 1×3 housing — PWM, GND, DIR in the order pin 12 → 14 → 16. Tug-test each lead; if a crimp backs out, replace that lead rather than re-seating it. Label it before plugging in.
Build the LED harness as a second 1×3 on pins 35 / 37 / 39, red first, ground last. Never solder wires directly to the header — serviceable connectors are what survive a moving car.
Land on the MD25HV control terminal only. Green screw terminal, by printed label, ferrules on the wire, 5VO empty. Then check continuity from Pi GND to MD25HV GND with a meter.
Wire the keyfob relay last — COM to pin 9, NO to pin 11 — and confirm the pin reads inactive at idle.
First motor test at low throttle with the wheel lifted. Not on the floor, not "just briefly".
07 · FIRST POWER-UP
Prove one subsystem at a time
Selector set, E-stop released, master switch off until every check below passes.
MD25HV and buck input polarity verified with a meter, pack disconnected.
Buck output confirmed at 5.1 V measured at the Pi end of the cable before the Pi is plugged in.
MD25HV 5VO confirmed connected to nothing.
Control wiring confirmed by label: PWM pin 12, GND pin 14, DIR pin 16, landing on PWM / GND / DIR. Count from the outer row.
Exactly one PWM overlay line in the boot config, reading dtoverlay=pwm,pin=18,func=2, with a reboot since it was added.
systemctl show photocar-ble.service -p Environment shows PWM_GPIO=18 and DIR_GPIO=23. If it shows 12 and 21, stop and re-provision with the override.
provision-photocar.sh selftest passes. Remember it stops and restarts the receiver.
Status LED watched through a full boot: blinking red from early kernel boot, then solid green.
Wheel lifted. Test forward, confirm direction, then back. If reversed, swap the motor leads at MA/MB or flip DIR_FORWARD_LEVEL — never a control wire.
Test STOP, then the watchdog: start driving, kill the controller, confirm the motor stops within 1.2 s and the state reads watchdog_stop.
Test the keyfob with the wheel still lifted, including a second press to unlatch. Confirm the E-stop kills everything from a live drive.
Only then a low-throttle floor test, followed by a re-check of every exposed fastener and connector.
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
Symptom
Most likely cause
Section
Car does not move at all, service healthy
Provisioned from an old payload whose defaults were GPIO12/21 — re-provision with a current payload (defaults are GPIO18/23)
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.