The Manual

How It's Built

This is the cart's real working build manual — the same document the project runs on, published here so anyone curious can see exactly how it's done, step by step, with the values and hard-won lessons that actually work. Current as of July 14, 2026; the cart is a living project, so this page updates as the build evolves. (Network credentials and private addresses are omitted from this public copy.) The full parts list is at the bottom.

Every section opens with an Overview box that covers what you're building and why before the technical steps begin, and a glossary of every major part is right below. Together they carry the whole page.

How to read this guide

Here's the system this guide uses:

One tip before you start: the whole build comes down to patience and order. Measure before cutting, label before hiding a wire, test each system alone before connecting it to the next. Every disaster on this page happened because I skipped one of those.

Meet the parts — what everything does

The cart is really just a dozen kinds of parts working together. Here's what each one does. Think of the cart as a body: it has a skeleton, muscles, a heart, a brain, nerves, and senses.

PartWhat it isWhat it does on the cart
Frame1.5" square aluminum tubes welded into a rectangleThe skeleton. Every other part bolts to it, and the hollow tubes double as protective channels for the wiring.
Drive motors (BLDC)"Brushless DC" electric motors — the type used in e-skateboardsThe muscles. There are two, one per rear wheel, each turning its wheel through a bike chain.
Motor controller (ESC / VESC)An "electronic speed controller" — a small dedicated computer that feeds the motors precisely timed bursts of battery powerThe translator between "go 2 mph" and the thousands of electrical pulses per second that actually make a brushless motor spin. Ours is one box (Flipsky DUET XS60) containing two independent 60-amp controllers, one per motor. "VESC" is the open-source flavor of ESC this project uses.
Battery (LiFePO4)Lithium iron phosphate battery — a safe, long-life lithium chemistryThe fuel tank. One native 24-volt 50Ah pack (25.6V nominal, ~1.3kWh) powers everything. Earlier builds made the same voltage from two 12.8-volt batteries wired end-to-end ("in series").
BMSBattery management system — a protection circuit built inside each batteryThe babysitter. It cuts the battery off if you pull too much current or drain it too low. You can't remove it, and you must respect its limits (we found one the hard way). Ours also reports over Bluetooth, so the Pi reads exact state-of-charge straight from the pack.
Buck converterA voltage step-down deviceTurns 24V battery power into the clean 5V the computer and sensors drink — a phone charger for the robot brain. The cart has two, so a fault on one can't take out the other's loads.
Raspberry Pi 5A credit-card-sized Linux computerThe brain. Runs every line of the cart's software: reading sensors, steering, throttle, the follow-me logic, the web dashboard.
Steering servoA motor with position control built in: tell it an angle, it goes there and holdsThe steering muscle. It pushes the front axle left and right through a tie rod. Ours is a 24V industrial unit, much stronger than a hobby servo.
RC transmitter + receiverA hobby radio remote (pistol-grip controller + matchbox receiver)Manual control. One switch on the remote instantly overrides the self-driving and hands you the wheel.
TFmini lidarA thumb-sized laser tape measureClose-range eyes. Six of them ring the cart, each firing an invisible laser and timing the reflection to measure distance dozens of times a second. They spot obstacles, curbs, and drop-offs.
RPLIDAR S3A spinning laser scannerThe 360° eyes. It sweeps a laser in a full circle ten times a second, producing a floor-plan-style map of everything around the cart — this drives the radar screen and part of the safety braking.
UWB anchors + tagUltra-wideband radios — think "indoor GPS"How the cart knows where you are. Four "anchor" radios on the cart's corners each time a radio ping to the "tag" you carry (radio travels about a foot per nanosecond). Four distances pin down your exact position; the cart steers toward it.
CameraA 12MP USB cameraStreams live video to your phone so you can see what the cart sees.
CAN busA rugged two-wire network borrowed from the automotive worldHow the Pi and the motor controllers talk: battery voltage, motor current, temperatures, fault codes all flow over these two wires.
PWM"Pulse-width modulation" — sending a number by varying how long an electrical pulse lastsThe language of throttle and steering. A 1.5-millisecond pulse means "center/stop"; longer means more forward/right, shorter means more reverse/left. The RC receiver, the servo, and the motor controller all speak it.
GPIO"General-purpose input/output" — the 40 metal pins on the PiWhere every signal wire lands. Pins are referred to by number (e.g. GPIO13 = steering signal).
Level shifterA voltage translator on a fingernail-sized boardThe RC receiver shouts at 5 volts; the Pi's pins only tolerate 3.3. This little board converts between them so nothing fries and nothing garbles.
I2C and UARTTwo simple "wire languages" chips use to talk to each otherI2C is a two-wire party line where devices take turns (the six lidars share one). UART is a private two-wire conversation between exactly two devices (each UWB radio gets one).
FuseA strip of metal that melts if too much current flowsThe fire-safety valve on the battery. A short circuit blows the fuse instead of igniting the wiring.
TVS diodeA surge-protector component the size of a grain of riceClamps sudden voltage spikes before they reach the electronics. A spike from the battery charger killed our first Pi; this part is why it can't happen again.
Star groundA wiring rule, not a partEvery circuit's ground (return) wire routes back to ONE common point. This stops the motors' electrical noise from sneaking into the sensitive sensor and radio signals. It's non-negotiable on this cart.

0. Overview & build order

I built this in layers on purpose. First a "dumb wagon" — a frame that rolls, steers, and drives by remote — and I made sure that worked before any self-driving stuff went on. Test each layer by itself before stacking the next one on top. That way, when something acts up, you know which layer to blame. I know the autonomous parts are the fun parts. But every hour I spent making a layer solid saved me five hours of head-scratching later. Trust me on this one.

Build the mechanical cart first (frame, steering, drivetrain), then power, then the computer and motor controller, then RC control, then sensors and displays, then the autonomy hardware (UWB) and the follow-me software, and finally the safety systems that wrap all of it.

1. Frame & chassis

The frame is a flat rectangle of hollow square aluminum tube — picture a bed frame — 32 inches wide and 4 feet long, with extra tubes across it wherever something heavy sits (motors, axle, battery). I went with aluminum because it's light and won't rust at the beach, and welded because bolted joints shake loose in sand and vibration. Everything else in this guide bolts to this rectangle, so take your time getting it square and flat. A crooked frame means crooked wheels and a chain that keeps jumping off.

Material: 1.5-inch square aluminum tubing. Deck footprint: 32 inches wide x 4 ft deep. Raw stock ~$800 from a local metal supplier; joints TIG welded with ER309L 3/32" stainless filler; 4"x4"x1/4" 6063 angle for brackets/gussets.

  1. Plan and mark before cutting. Draw the frame on paper with every dimension: the perimeter rectangle (two 32" cross members + two 4-ft side rails) plus a cross member wherever load lands — the motor mounts, the rear axle bearings, and the battery box. Mark cuts on the tube with a square and a fine marker. Measure twice; aluminum stock is too expensive to scrap.
  2. Cut the tube. A miter/chop saw with a non-ferrous (aluminum-rated) blade gives clean square cuts; a metal bandsaw or even a hacksaw with a miter box works if you're patient. Deburr every cut edge with a file — fresh-cut aluminum is razor sharp and burrs keep joints from seating flush.
  3. Dry-fit and square it up. Lay the perimeter out on a known-flat surface, clamp the corners, then measure the two diagonals corner-to-corner. When both diagonals are exactly equal, the rectangle is square — this is the whole trick. Nudge and re-measure until they match.
  4. Tack-weld, re-check, then fully weld. Tack-weld the corners (small temporary welds), re-check the diagonals — welding pulls metal as it cools — then add the interior cross members and TIG weld all joints with the ER309L filler. Can't weld? That's fine: do all the cutting, fitting, and clamping yourself, then take the clamped assembly to a local welding shop — an hour of a pro's TIG time is cheap compared to the tools, and the fit-up is the real labor anyway.
  5. Add gussets at the load points. Weld pieces of the 4"x4"x1/4" 6063 angle as corner braces and mounting feet wherever the frame takes real force: axle bearings, the steering bracket, and the motor plates. A gusset turns a joint that can flex into one that can't.
  6. Set rivet nuts for everything that bolts on later. A rivet nut is a threaded metal insert: drill a hole in the tube wall, squeeze the insert in with the rivet-nut tool, and you've got a strong machine-screw thread in metal that's otherwise too thin to tap. Use them (SAE rivet-nut kit) everywhere accessories mount — far stronger than self-tapping screws in thin aluminum, and you can unbolt things forever without wrecking the frame.
  7. Finish the tube work. Cap the open tube ends (3/4" ribbed push-in caps) so they don't collect sand and water, and fit a rubber grommet in every hole where wire will pass through (much more on this in §1b).
NOTE: The frame is the datum for everything else. Wheel bearing alignment and chain alignment both depend on it being square and flat.

1b. Running wires through the tubing — step by step

The frame's hollow tubes are free wire channels. Wires run inside them stay safe from sun, salt spray, snags, and dropped cargo — and the cart looks clean instead of like a hairball. You won't pull every wire on day one; wiring happens bit by bit as each system goes on. But drill the holes, add the grommets, and drop in the pull strings now, while the frame is bare and easy to work on. Follow the rules below and you'll never think about the wiring again. Skip them and you're hunting a mystery short in month two.
  1. Plan every run on paper first. List each wire: what it connects (both endpoints), roughly how long, and which of the two families it belongs to — POWER (thick, electrically noisy: battery leads, motor phase wires, servo power) or SIGNAL (thin, sensitive: sensor wires, RC channels, UART, CAN, camera USB).
  2. Keep the two families apart. Power and signal ideally run in different tubes, or at minimum enter/exit far from each other. The three fat phase wires between each motor controller and motor are the worst offenders — they radiate switching noise — so they never share a tube with signal wires. This isn't perfectionism: motor noise coupling into the RC and UART lines was a real, observed failure on this cart (it's why the star-ground rule in §4 exists).
  3. Drill entry and exit holes a size up from the bundle that will pass through, on the underside or inward-facing tube faces so rain can't run in. Drill only where the tube isn't structural at a weld or load point.
  4. Deburr every hole — a countersink bit spun by hand, or sandpaper rolled on a dowel. A raw drilled hole in aluminum has an edge that will saw through insulation as the cart vibrates.
  5. Grommet every hole. No exceptions. Push a rubber grommet (the Vrupin kit) into each hole before any wire goes through. Vibration + bare aluminum edge = a chafed-through wire and a dead short weeks later, at the beach, where you can't find it.
  6. Get a string through the tube first. Three ways, easiest first: (a) tilt the tube and drop a small nut tied to light string through; (b) hold a shop-vac at the far hole and let it suck a string tied to a scrap of plastic bag through; (c) push an electrician's fish tape or a straightened coat hanger through and tie the string to it.
  7. Pull the wires with the string. Tie the string to the wire bundle and wrap the joint in tape so it forms a smooth bullet shape with no snagging edges. Pull gently and steadily — if it fights, back up and re-tape rather than yanking. Connectors never go through the tube: pull bare wire ends and crimp the connectors on afterward (the pre-crimped Dupont/JST kit for signal wires; proper crimp lugs plus adhesive-lined heat shrink for power).
  8. Pull extra, always. Leave 6–12" of slack at each end — a service loop — so connectors can be re-made and boxes can be opened without the wire going drum-tight. Wire is cheap; a too-short run through a sealed tube is a do-over.
  9. Leave a pull string behind in every tube. When you pull a bundle through, tie a spare length of string to it and pull that through too, then leave it in place. Future-you will add a sensor next month, and the string turns a 30-minute fishing job into a 30-second pull. Re-leave a new string every time you use one.
  10. Label both ends of every wire BEFORE it disappears into a tube. Masking-tape flags and a fine marker are fine ("CH3 RC → GPIO24", "ANCH2 5V"). Once a wire is inside the frame you cannot trace it by looking; ten unlabeled white wires emerging from a hole are a puzzle you gave yourself.
  11. Dress the runs outside the tubes. Where wires travel in the open: braided PET loom over the bundle, adhesive zip-tie mounts every 6" or so, and a drip loop (the wire dips below the entry point, so water drips off the bottom of the loop instead of following the wire in) before every enclosure. Wires enter waterproof boxes through cable glands (the PG7–PG19 kit), never through a bare hole.
  12. Grounds follow the star-ground rule. Every ground wire routes back to the single star point at the buck converters (§4) — resist every temptation to grab a "convenient" nearby ground. This is a wiring-time decision, so make it while pulling wire, not after.
  13. Test before you connect. With everything still unpowered, use a multimeter's continuity beeper on each labeled run end-to-end, and check that adjacent wires don't beep to each other (no shorts from a nicked jacket during the pull). Two minutes per wire now versus hours of live debugging later.

2. Steering

The front wheels pivot on one swiveling beam, just like a toy wagon — actually, it is one. I salvaged the pivot assembly from a kid's wagon, and that solved the hardest mechanical problem for free. Instead of a kid pulling a handle, a strong servo motor swings the beam left and right through a "tie rod" (a threaded rod with a ball joint on each end, same idea as in your car). The Pi steers by sending the servo a timed electrical pulse; the servo moves to that angle and holds it. This part matters: the servo runs on 24 volts, and only its thin signal wire goes anywhere near the Pi.

Built around a steering bracket / pivot assembly salvaged from a kid's WAGON (the front pivoting axle beam) — ready-made center pivot + stub axles — with powered steering added via tie rods and a 24V industrial servo.

  1. Mount the wagon bracket to the front cross member; fit the front wheels to its stub axles.
  2. Connect the servo to the knuckle(s) with X AUTOHAUX M8 ball-joint tie rods (several lengths bought — 80/100/120/185/210mm — pick the one giving full lock without binding, then thread-lock).
  3. Servo = Happymodel Super400 Plus, 300° PWM. It is a 24V industrial servo with its own internal driver — NOT a hobby servo. Power it from the 24V bus; feed ONLY the PWM signal from the Pi.
  4. Signal to GPIO13 (RP1 hardware PWM chan1). Do NOT run a servo ground jumper to the Pi header — grounds meet once at the buck (star ground, see §4).

Working tune (config.py): slew limiter 1500us/s, center deadband 0.10, travel SERVO_RANGE_US=540 (60% throw — 50% was too little; raised 6/28 and it drives much better).

Field lesson (7/8): the servo popped out of its socket under hill load once — check the linkage seating when steering goes soft.
Front-end rebuild (7/12–13) — plan for this from day one. The wagon's original stamped-metal steering tabs are fine for a kid pulling a handle, but a 24V servo fighting loaded wheels in sand eventually bends them. The fix that made the front end load-rated: cut new tabs from 3/16" steel plate and weld them onto the steering structure, remount the servo, and add diagonal braces (5/8" square solid aluminum, one each side of the servo up to the steering frame) so servo torque can't rack the mount. Two hard-won rules from this rebuild: (1) route the servo's drag link BEHIND the cross tie rod — routed in front it binds mid-travel, and a stalled servo has enough torque to rip itself off its mount (it did); (2) after any servo remount, re-verify the steering center and travel limits in software — the old trims were tuned to the old geometry. Clearance at full wheel lock is tight with the braces in — sweep lock-to-lock by hand before powering up.

3. Drivetrain (motors, chain, wheels)

Each rear wheel works like a bicycle in reverse: the motor spins a small sprocket, and a bike chain carries that to a big sprocket bolted to the wheel. The big sprocket has five times the teeth of the small one, so the wheel turns five times slower than the motor — but with five times the twisting force. That's the "5:1 reduction," and it's what lets a fist-sized motor push a loaded cart through soft sand. There are two motors, one per wheel, with no axle between them — each side drives on its own. And read the orange warning below before you do anything else: the tires will just spin on their metal hubs unless you glue them first. This one bit me.

Two-wheel rear drive: two Flipsky 7070 110KV sensored BLDC outrunners (14 poles / 7 pole pairs), each driving a rear wheel through a 5:1 single-speed bike-chain reduction. Wheels: 16.5" (42cm) PU, 3/4" bearings. ~733 ERPM per mph on this drivetrain.

⚠ CRITICAL — BOND THE TIRE TO THE HUB FIRST. The tubular PU tire spins freely on the metal hub under drive torque — the sprocket turns the hub, the tire doesn't follow. Before anything else on the wheels: split the hub → scuff tube AND hub mating surfaces with 80-grit → plastic epoxy → re-bolt halves → FULL cure → only then mount spacers + sprocket. Skip this and the cart will not move — guaranteed.
  1. Mount the motors (motor plates / 6063 angle); each drives one rear wheel.
  2. Sprockets for 5:1 (small on motor, large on wheel), keyed with carbon-steel key stock.
  3. 1/2"x1/8" 114-link single-speed bike chain per side ("410" chain). Align sprockets in-plane; slight slack, not taut.
  4. Rear axle on uxcell SBPFL204-12 pillow blocks / UCF204-12 flange bearings (3/4" bore); 3/4" 304 stainless rod is the axle stock.
  5. Print + fit the wheel/bearing spacers (§16): OUTER 30mm with counterbores, INNER 25mm. PETG, high infill — load-bearing.
  6. Bolt on the wheels.

What the drivetrain can and can't do (measured 7/8–7/10 with tests/hill_power_log.py):

4. Power system & distribution

Everything runs off one battery: a native 24-volt (25.6V nominal) 50Ah lithium iron phosphate pack. The big muscles (motors, steering servo) drink that "24V bus" straight; the delicate electronics get 5V from buck converters. Three things guard the bus: a fuse (melts and disconnects if a short tries to pull huge current — that's your fire protection), a TVS diode (a surge protector that clamps voltage spikes — I didn't know what a TVS diode was either, until a spike killed my first Raspberry Pi), and a bulk capacitor (a small energy sponge that smooths out jolts). And don't skip the "star ground" rule at the end: every ground wire meets at exactly one point. That keeps the motors' electrical noise out of the sensors' faint signals.

Battery (current, installed 7/22): Power Queen 24V 50Ah Bluetooth LiFePO4 — 8S, 25.6V nominal, 1280Wh, ~23lb. BMS: 50A continuous / 60A 30-min peak; measured real capacity 52.5Ah at the first full charge. Native 24V means no series wiring and a single BMS — one the Pi reads directly over Bluetooth as the cart's fuel gauge (§9b). It rides in a 3D-printed under-frame side-loader box hung below the rails. This pack replaced the interim 2x SEFEPODER SP1220M 12.8V 20Ah series pair (7/10–7/22), which itself replaced the original 2x 15Ah pair (whose 16–20A BMS was the proven power ceiling — 19.4A measured draw pinned it). Charger: BROODAY 24V 10A LiFePO4 (29.2V) — unchanged, works on every 8S pack this cart has run.

  1. Build the 24V bus with 8AWG/12AWG silicone wire + the Recoil bus bar. Bus order: battery (+) → main fuse → bulk caps → TVS → loads.
  2. MAIN PACK FUSE: 50A ANL (re-sized 7/22 for the 50Ah pack — 40A drive draw plus 40A regen braking, at the BMS's 50A continuous rating; needs 10AWG+ on that leg; high interrupt rating — LiFePO4 dumps huge fault current). Confirm it is physically installed — it was still on the to-do list at last audit.
  3. TVS (installed 6/25): SMBJ30A across the 24V bus at the bus/charge entry — stripe/cathode → V+, anode → GND, SHORT FAT leads (polarized; backwards = dead short). This is the defense against the charger transient that killed Pi #1. 30V standoff vs 29.2V regen-full is tight but acceptable; if it ever nuisance-clamps near full charge, swap to SMBJ33A/36A.
  4. Bulk low-ESR electrolytic (≥35V) across the bus to soak regen energy (complements the TVS).
  5. Two 24V→5V 10A 50W waterproof bucks: BUCK #1 = Pi + 5V logic bus; BUCK #2 = dedicated clean 5V for the UWB anchor bus (fault-isolated from the Pi). Fuse each buck input (~3A).
  6. STAR GROUND (non-negotiable): the noisy 24V ground (VESC, servo) and the quiet 5V logic ground meet at ONE point, at the buck. Never run VESC/servo grounds to the Pi header — it was tried, it did not help, and it injects motor noise into RC/UART signals.
CHARGING DISCIPLINE: never run the electronics while the pack is on the charger — the surge that killed Pi #1 came in through the charge path. Electronics OFF (remote relay) while charging. (The battery gauge now survives charged-while-off correctly — see §9b.)

Split-bus plan (motor packs separate from a 15Ah electronics battery, XT60 charge ports keyed by voltage, 14.6V 5A charger for the 15Ah): designed 7/9, frame-rail battery boxes printed (§16) — finish per the checklist in memory/beachcart-vesc-tuning.md.

5. Compute (Raspberry Pi 5)

The Raspberry Pi is the cart's brain — an $80 computer the size of a credit card, running Linux. Every sensor reading, steering decision, and safety check in this guide is a program running on it. Two things matter most right now: power it properly (clean 5V from Buck #1, never straight battery voltage) and keep it cool (a hot Pi slows itself down, and a slow brain makes a sluggish cart). The "hardware PWM" stuff below just means two of the Pi's pins can make perfectly steady timing pulses in dedicated silicon. Steering and throttle use those two pins because software-made pulses jitter — and jittery pulses made my motors twitch until I figured that out.

Raspberry Pi 5 8GB in an Argon NEO 5 aluminum case, powered via USB-C bare-wire lead from Buck #1 (5V 5A).

  1. Flash Raspberry Pi OS 64-bit (labwc/Wayland desktop). Hostname Beachcart, SSH on.
  2. In /boot/firmware/config.txt: usb_max_current_enable=1 (raises USB-A budget 600mA → 1.6A — required for camera + RPLIDAR; verify with vcgencmd get_config usb_max_current_enable = 1).
  3. GPIO library: lgpio (pigpio and RPi.GPIO do NOT work on Pi 5), gpiochip 0. Python venv at ~/venv (pyserial, pygame, numpy, scikit-image for the 3D generators). Project in ~/beach_cart.
  4. Two outputs use RP1 TRUE hardware PWM via sysfs (/sys/class/pwm/pwmchip0): GPIO13 = servo (chan1, needs dtoverlay=pwm,pin=13,func=4), GPIO18 = VESC (chan2, muxed by drive.py at startup — no overlay/reboot needed).

Cooling (hard-won, still one open item):

6. Motor controller (Flipsky DUET XS60 dual VESC)

A brushless motor can't just be hooked to a battery — something has to fire its three wire coils in exactly the right rhythm, thousands of times a second, or it just twitches. That something is the ESC (electronic speed controller). A "VESC" is the open-source kind: same job, but you can see and tune everything from a free laptop app called VESC Tool. This section is almost all settings — teaching the controller what motor it's spinning, how fast it can go, how much current it can pull, and how to brake. I earned every number below by watching something misbehave, so copy them exactly. And watch for this: our unit is really two controllers in one box, and every setting has to be written to both halves. Forgetting the second half got me more than once.

One Flipsky DUET XS60 — a DUAL ESC: two independent 60A drive halves in one box, one half per motor, internally CAN-linked. Treat the halves as two separate VESCs for configuration (every setting must be written to BOTH). The Pi sends a single PPM throttle on GPIO18 to the master; CAN forwards it to the other half.

⚠ CAN IDs are currently 0x13 + 0x14 — but EVERY VESC Tool wizard/session can silently renumber one. It has happened at least three times (0x10→0x12, then →0x11/0x12, then →0x13/0x14). If a "VESC vanishes," re-scan the bus and update BATT_CONTROLLERS in config.py before suspecting hardware.

Wiring: each motor's 3 phase wires + hall sensor cable to its half; 24V from the bus; CAN-H/CAN-L to the Pi's CAN HAT (§9b) with shared ground.

Configuration that works (write to BOTH halves):

  1. Motor detection for the Flipsky 7070, sensored FOC — confirm the hall table isn't all 0/255.
  2. Control Type = PID Speed Control (stick = target speed; closed-loop holds speed in sand and on hills — this is what finally made throttle smooth; Current mode couldn't).
  3. Max ERPM 3300 (~4.5mph), reverse cap matched.
  4. Speed PID: Minimum ERPM 5 (factory 900 made centered-stick = coast — this is the roll-out fix), Allow Braking checked, Kp 0.008 / Ki 0.03 (factory-soft gains wouldn't brake on hills). Note: PID-speed can NEVER hold a standstill — firmware releases the motor below min ERPM (0 included). Standstill hold is done Pi-side (auto park-brake, §8).
  5. Voltage cutoffs (8S LiFePO4): Start 22.4V / End 20.0V / regen 29.2V — type them manually. The Battery Cutoff Calculator is set for Li-ion and will stomp these if you press Apply.
  6. Current limits (live 7/26): Motor 60A / Brake −60A / Battery 20A per side (=40A pack) / regen −20A per side / Timeout Brake 40A. Absolute Max 120A (a fault-trip threshold, NOT a power knob — it was left at the 83A default and stall-spikes in FOC tripped ABS_OVER_CURRENT on every drive-off, the 7/10 "hill cutout"; 120A = headroom restored, zero faults since). ⛔ Do not raise battery amps past the pack's BMS spec (the old 15Ah pack died at exactly this).
  7. App Cfg → PPM: Ramping 1.0s positive / 0.5s negative; Timeout Brake Current 15A (signal loss now brakes instead of coasting — the park brake and every safety path rely on it). Re-calibrate PPM with the Input Setup Wizard (full-rev / full-fwd / neutral, ~15% deadband) — an uncalibrated pulse range reads as "throttle is 0-to-full on/off."
  8. Invert Motor Direction on the backward-facing half only.
SAVE PROCEDURE (bites every time): edits are local until WRITTEN. ↑M writes Motor Cfg; ↓M READS the VESC and overwrites your edits; ↑A writes App Cfg. Write BOTH halves — they do not auto-sync. (The 7/8 brake fix failed the first time because only ↑A was pressed.)

CAN hygiene rules (each learned the hard way):

7. RC control

This is a normal hobby-store radio set: a pistol-grip transmitter in your hand and a matchbox-sized receiver on the cart. The receiver puts out three "channels" — steering wheel, trigger, and a switch — each as a timed pulse the Pi reads on its pins. The cart is self-driving by default; flip the channel-3 switch and a human has the wheel instantly. One thing to catch: the receiver speaks in 5-volt pulses, and the Pi's pins can only take 3.3. Every signal goes through a small "level shifter" board that translates. Skip it and the Pi reads garbage — or worse, gets damaged.

Manual driving: Flysky FS-GT3C 3-channel transmitter + GR3E receiver. RC CH3 = the manual-override / safety switch (autonomy is the default mode; CH3 high = instant manual).

  1. Bind the GR3E; it outputs 5V PWM on 3 channels.
  2. CRITICAL: run all 3 signals through the LONELY BINARY bidirectional level shifter (5V→3.3V). Raw 5V onto Pi GPIO = garbage reads.
  3. Shifted signals to the Pi (BCM): CH1 steer = GPIO22, CH2 throttle = GPIO27, CH3 = GPIO24 (CH3 moved off 23 to free the CAN interrupt — don't trust old labels). Receiver + shifter grounds tie to Pi GND.
  4. Test inputs with tests/rc_noise.py (~±6us jitter, 0 rejected) and outputs with tests/output_test.py (wheels up; it holds neutral 4s first because the VESC must see steady ~1.5ms neutral to ARM before accepting throttle).
Gotchas: a transmitter that is OFF still reads a steady ~1515us on the Pi (looks centered-but-live) — confirm the TX is actually on. Manual RC drive is NEVER gated by the proximity system (hard design rule); only autonomous follow is.

8. Drive & steering software (drive.py / steering.py)

These two programs are the cart's reflexes. They take whatever is commanding the cart right now — your remote, or the follow-me brain — and turn "40% throttle, slight left" into the actual timed pulses the servo and motor controller expect. Along the way they enforce the manners: speed changes ramp up smoothly instead of lurching ("slew limiting"), tiny stick wiggles near center get ignored ("deadband"), and a stack of automatic protections kicks in when anything goes quiet — stop if the brain stops talking, park-brake after two seconds of sitting still, and clamp everything safe even if the whole program crashes.

9. Sensors & telemetry

Sensors are the cart's senses. This section wires up three of them: six little laser rangefinders that watch for obstacles, a battery gauge that works like a smart fuel gauge, and a spinning laser that maps everything around the cart. Each subsection's Overview box explains its own sensor.

9a. Obstacle LiDAR ring — 6x TFmini Plus

Each TFmini is a laser tape measure the size of your thumb: it fires an invisible laser and times the reflection, reporting a distance many times a second. Six of them ring the cart — some aimed straight out to spot obstacles, some angled down at the ground ahead. The angled ones are the neat trick: at boot they memorize how far away the ground normally is. Something closer than that means "obstacle." Something farther means the ground fell away — a curb edge or washout, so STOP. All six share one pair of wires to the Pi through a "multiplexer," a switchboard chip that lets the Pi talk to them one at a time.
  1. Six TFmini Plus (IP65) on printed brackets: ch0 Front Straight, ch1 Front Down (curb), ch2 Front Right + ch3 Front Left (compound 28° down + 28° out corners), ch4 Back Left, ch5 Back Right. Verify the map with tests/lidar_identify.py (stop the service first; run -u unbuffered).
  2. All 6 through a TCA9548A I2C mux (0x70, bus 1); each sensor is 0x10 behind its channel.
  3. modules/lidar.py polls the mux; modules/proximity.py produces the alert line (feet, yellow <5ft, red flashing <2ft) AND the safety gate (§13).
  4. The angled down-sensors auto-calibrate a ground baseline at boot (~15s in). Baseline outlier cross-check (7/8): a baseline >0.8ft (PROX_CAL_OUTLIER_FT) off the group median means something was in view during cal → that sensor goes UNCALIBRATED/quiet for the run ("=OUTLIER" in the status file) instead of latching a false DROP-OFF stop. This killed the recurring "false cliff" failure mode.
  5. Drop-off (cliff) detection: a down sensor reading FARTHER than baseline+1ft (or no-return) = STOP. Built for the dune-walkway washouts. ⚠ Rain defeats it (wet sand + wet lenses = 850nm no-returns = false cliff latches) — rain fixes are an open item; the phone BYPASS button is the workaround.
  6. Rear sensors (ch4/5) are ALERT-ONLY by design — see the reverse rule in §12.

9b. Battery gauge + coulomb counter over CAN

UPDATE 7/26: the coulomb counter is retired. The Power Queen pack (installed 7/22) has a Bluetooth BMS the Pi now reads directly every 5 minutes — exact state-of-charge straight from the battery, so the software no longer has to estimate it (a voltage-table floor at the steep low-end knee remains as the only estimator, a safety net in case the Bluetooth link dies). The CAN plumbing below still carries voltage, current, and drive telemetry, and its reliability lessons stand — but if your battery's BMS speaks Bluetooth, read it; don't rebuild the checkbook.
The Pi asks the motor controllers "what voltage do you see, and how much current is flowing?" over the CAN network, and turns the answers into a percent-full gauge. The catch: LiFePO4 batteries hold almost exactly the same voltage from 90% full down to 60%, so voltage alone can't tell you what's left. I learned that the hard way — my first gauge read way too full in the middle of the range. The fix is "coulomb counting": the software counts every amp-hour flowing out of (and back into) the battery, like balancing a checkbook, and only trusts voltage at the very top and bottom. The rest of this section is the plumbing it took to make those CAN readings reliable while two controllers talk over each other on the same two wires.
  1. Waveshare 2-Ch Isolated CAN HAT (MCP2515, 16MHz), CAN0 interrupt GPIO23. VESC CAN-H/L to CAN_0 with shared ground. config.txt: dtparam=spi=on + dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=23.
  2. can0 @ 500k auto-raised at boot by the system oneshot can0.service (BindsTo can0.device) — without it the gauge dies on every reboot.
  3. modules/battery.py polls COMM_GET_VALUES on both halves [0x13, 0x14], averages voltage, sums current, and runs the hybrid SoC estimator:
    • Measured 8S LFP SoC table (7/4: full 16.6h bench discharge, 26.4→24.6V, 14.8Ah usable — tests/battery_discharge_test.py). The huge LFP dwell is real: 26.3V spans ~90→60%. Rested-full anchor = 26.30V.
    • Coulomb counting between voltage anchors (VESC i_in + 0.8A electronics overhead; BATT_CAPACITY_AH=19.5 for the new pack — estimate, re-measure). Persists across reboots in /var/tmp/cart_soc.json. Idle drain ~5.4%/h is real (electronics).
    • Charge-detect ≥27.2V resets to 100% on unplug; low-end v≤26.15 clamps DOWN via the table; boot anchor BATT_CC_FULL_V=26.28 — waking at rested-full voltage takes max(saved, table), which fixes the "charged while the Pi was off → gauge says 19%" gap.
    • 5-sample median filter kills the top-of-table jitter (100%↔77% flashing).
  4. CAN read hardening (7/10, after two counter-poisonings): VESC FILL frames carry no sender id, so the two halves' multi-frame replies interleave on a busy bus. battery.py drains stale frames before each request, requires the PROCESS_RX_BUFFER completion frame, and verifies payload CRC16 — corrupt replies return None instead of integrating garbage.
  5. Webpanel motor-sync watchdog (VESC_SYNC_*): red banner when the halves' ERPM diverge >20% for 3s or one vanishes off CAN.

9c. 360° LiDAR — RPLIDAR S3

This is the spinning puck you see on self-driving cars, just smaller: a laser rangefinder spinning ten times a second, drawing a 360° floor plan of everything around the cart out to 40 meters. It plugs into the Pi over USB. Its map drives the radar display on the cart's screen, and since 7/10 it's also a safety sensor — it watches a corridor ahead of the cart and slows or stops for anything walking into the path. It catches what the narrow-beam TFminis can miss, like a person's legs slightly off-center.
  1. CP2102N USB-UART adapter — use the /dev/serial/by-id symlink, 1 Mbaud, standard SCAN (0x20), raw SLAMTEC protocol (modules/lidar360.py), ~10Hz sweeps into shared state.
  2. Mounted on the puck at torso height (2D scanner — one plane; the TFmini ring covers low/ground/curb). 0° = forward, alignment confirmed via the cargo blob; tests/s3_offset_cal.py exists for post-remount calibration.
  3. Rear mask: S3_MASK_SECTORS=[(90,270)] blanks the rear half at ingestion (cargo sits there); all consumers inherit.
  4. USB self-heal watchdog: no data for 5s → USBDEVFS_RESET ("software replug", no root needed) — fixes the CP210x control-transfer wedge that silently froze the feed for hours. Health file /tmp/cart_lidar360_status.txt.
  5. The S3 is a safety sensor now (7/10): _scan_s3() in proximity.py projects front-arc bins (±60°) to (ahead, lateral); ≥2 bins inside the 1.7ft-half-width drive corridor at <6ft = SLOW, <3ft = STOP ("AHEAD (360)"). Built because a person walking in front during follow-me slipped past ch0's 2° beam. Stale sweep >1s = fail-open to TFmini-only. Walk-in-front re-test pending.

10. Displays (camera + 5" screen)

The cart has two "faces": your phone gets the live camera feed plus every control and gauge through a web page (§14), and a small 5-inch screen on the cart shows a radar-style sweep of what the 360° lidar sees. The radar is drawn mirror-style on purpose — forward points down. During follow-me you're standing in front of the cart looking back at the screen, so this way left is left and right is right from where you stand.

Layout since 7/5 (wheels-down): the camera streams to the PHONE web panel; the 5" screen is a fullscreen radar scope + info strip.

  1. Camera: Arducam 12MP autofocus USB (UVC → /dev/video0; rpicam/libcamera saying "no cameras" is normal for USB). WEBPANEL_CAMERA=True makes the webpanel the sole camera owner (ends the old ffplay/panel hand-off bug). CAMERA_ROT180=True — the cart flip put it upside down; rotation is done in the phone browser CSS (zero CPU; a raw /snapshot.jpg is still unrotated).
  2. 5" display: ELECROW 800x480 HDMI on HDMI-A-2 — no EDID, so pin the mode twice: video=HDMI-A-2:800x480M@60 in cmdline.txt (boot splash) AND a kanshi profile (~/.config/kanshi/config) for the labwc desktop. Video-only as wired (touch doesn't travel over HDMI); plexiglass cover planned.
  3. tools/lidar_radar.py (auto-started by radar.service) draws the fullscreen PPI scope + battery/follow/prox info strip from the /tmp status files. RADAR_ROT180=True — the scope is drawn mirror-style (FWD=down) so it reads correctly when you stand in FRONT of the cart during follow-me. 15fps cap (CPU trim). Object tracking, crossing assistant (WAIT/CLEAR), and the follow-the-gap arrow are display/advisory only.
  4. Desktop notifications: the wf-panel-pi "Wi-Fi Authentication Required" agent covered the radar mid-ride — disabled via ~/.config/wf-panel-pi/wf-panel-pi.ini (must be that subdir path). pkill -f lidar_radar.py = supervisor relaunches it fullscreen.

11. UWB localization — WORKING (4 anchors + tag, [4A] fix @3.3Hz)

This is how the cart knows where you are — the heart of follow-me. UWB (ultra-wideband) is basically indoor GPS: four radio "anchors" on the cart's corners each ping a small "tag" you carry and time how long the radio wave takes to come back. Radio travels about a foot per nanosecond, so the round-trip time is the distance. Four distances from four known corners pin down exactly where the tag is, same as GPS satellites pin down your phone. This section has the strictest rules in the whole guide, because these little radio modules are the most fragile thing on the cart. They run on exactly 3.3 volts and die instantly at 5. They'll even kill themselves if powered without two tiny "decoupling" capacitors steadying their power during a transmission burst. I didn't know any of that going in — about half the modules I ever bought died teaching me. Follow the rules below and yours won't.

4x REYAX RYUW122_Lite anchors on the cart corners + 1 wearable tag. Anchor spacing (measured): 34" left↔right x 45" front↔rear (FOLLOW_ANCHOR_W_CM=86.36, L_CM=114.3). ANCH1=front-left, ANCH2=front-right, ANCH4=rear-LEFT, ANCH3=rear-RIGHT (verified by tag-held-left test — the first guess was swapped and steered the cart the wrong way).

Power (the part that killed a dozen modules — follow exactly)

  1. RYUW122 is strictly 3.3V and dies at 5V. Per corner: 5V from Buck #2 → local 3.3V step-down at the module → module. Use the Mini360 (fails SAFE — can't pass 5V through). The newer step-downs fail-unsafe and have murdered multiple modules when they blew; ANCH4 still has one — replace it (open item).
  2. HARD GATE — no power, no serial, not even a probe, to a module without its decoupling caps: 10uF low-ESR electrolytic (+leg to VDD, stripe to GND) in parallel with a 0.1uF ceramic across VDD/GND, shortest leads at the radio pins. The UWB TX burst browns out the rail without them — that brown-out is what "silently killed" the first batches. Violating this once blew an anchor.
  3. METER 3.3V at the module before connecting anything after any corner work. Diagnostic shortcut that keeps proving out: UART-fine-but-ranging-dead, or repeated module death at ONE corner = meter the step-down FIRST (it's putting out 5V, or browning out on the RF burst).
  4. Each anchor gets its own signal-ground wire back to the Pi reference; the anchor COMMON ground is ONE wire to the Pi through the reset FET (below). Never route grounds through a USB hub.

Ground-reset FET (fixes power-on latch-up)

Anchors wedge at power-up (the Pi's UART lines drive into them before their rail settles — IO-injection latch-up; only breaking the GND return clears it, a RESET-pin pulse won't). Working fix: IRLB8721 logic-level MOSFET in the anchor common ground — Drain→anchor GND, Source→Pi GND, Gate→220Ω→GPIO16 (pin 36), 10k gate pulldown (held OFF at boot). uwb-gnd.service switches it on ~5s after boot; ~/uwb_reset.sh = software unplug/replug for mid-run wedges. Wait ~30s after a reset before probing. A real reset shows the board data lights FLICKER (anchor boot spew); no flicker = they never lost power.

UART transport — 2x Waveshare SC16IS752 I2C-to-UART boards

  1. Board 0x48 (jumpers default) + board 0x49 (A0=1), both on I2C bus 1 at 3.3V (never 5V). Two dtoverlay=sc16is752-i2c lines → ports /dev/ttySC0–3. Don't solder address bridges — set addr= in the overlay (a solder attempt burned a board).
  2. One INT wire per board, and it's mandatory (the driver is interrupt-driven — no INT = RX data stuck in the FIFO forever = "anchors dead" while they're fine): 0x48 INT → GPIO17 (pin 11); 0x49 INT → GPIO12 (pin 32). ⛔ GPIO25/pin22 is physically DEAD on this Pi — leave it empty. Verify: grep -E '1-004[89]' /proc/interrupts while polling — a counter that doesn't climb = INT problem.
  3. Anchor UART is a TXD↔RXD crossover into the board channel. An echo (board reads its own TX) = RX-path wiring fault — meter continuity.
  4. Recovery when boards are absent at boot (deferred probe): fix wires → i2cdetect -y 1 shows 48/49 → echo 1-0048 | sudo tee /sys/bus/i2c/drivers/sc16is7xx/bind (+ 1-0049) → ports appear. No reboot needed.

Configuration & SOPs

  1. Flash each anchor: tests/uwb_config_anchor.py ANCHn /dev/ttySCx → MODE=1, NETWORKID=BEACHCAR (the field truncates to 8 chars!), unique ADDRESS (ANCH1–4), CPIN=zeros — must match the tag. The tool retries writes to +OK and verifies by CONSENSUS reads (single reads glitch into fake mismatches).
  2. ⛔ THE PORT MAP IS BOOT-UNSTABLE. The two boards bind in either order; the ttySCx↔anchor map shuffles between boots (bit us 7/3 AND 7/10 — a config write went to the wrong, healthy module via a stale map). Never configure by port. Read AT+ADDRESS? on every ttySC* first. (follow_me is immune — it discovers anchors by address.) Factory-fresh modules read TAG12345/MODE=0/Anchor12; "new" modules from the batch may arrive PRE-configured with colliding addresses — always read all channels after adding one.
  3. Stop beachcart.service before hand-probing anchors — it holds the ports and eats replies (verify it's inactive; it has come back mid-session). Empty reads with the service running mean nothing.
  4. Bring-up per corner (proven recipe): caps + metered 3.3V → uwb_charz.py (link quality) → uwb_config_anchor.pyuwb_range.py (expect 10/10 vs the tag). Green LED = ranging link (needs tag AND anchor up), not power.
  5. Ranging is ANCHOR-driven: the anchor issues AT+ANCHOR_SEND and reports distance; the tag auto-replies but never streams unsolicited.

The wearable tag

A single LiPo crosses 3.3V as it drains, so the tag uses a TPS63020 buck-boost (a plain buck "works then quits" — that mystery cost a module). Chain: 803040 LiPo → TP4056 USB-C charger (B+/B−, always-on so it charges switched-off) → slide switch → TPS63020 (set 3.3V by shorting the 3V3 pad ONLY) → 10uF+0.1uF → RYUW122. Printed case (§16), antenna end away from the body/metal. A dead tag battery looks like "all anchors +OK but 0 hits" — the panel now shows that exact diagnosis automatically. Wall-charge the tag.

12. Follow-me autonomy (modules/follow_me.py) — WORKING, road-tested

This is the program that does the actual following. Every third of a second it takes the four anchor distances, works out where you are (a bit of geometry called "trilateration" — finding the one point that fits all four measured distances), and turns that into two numbers: which way to steer (toward you) and how fast to go (based on how far away you are — it slows as it gets close and stops about 8 feet away, so it never crowds you). On top of that are the manners that make it trustworthy: it wakes up disarmed and won't move until you deliberately cycle the tag off and on, it refuses to drive at you if you're behind it, and it never, ever reverses on its own in sand — a stuck cart has to stay light enough to push out by hand.

Control model: AUTONOMOUS is the default and runs with the RC off. Grab the RC + CH3 high = instant manual override. Failsafe (RC lost) blocks manual only, never autonomy.

Position solver (7/3): age-weighted least-squares trilateration over every fresh anchor distance → true tag (x,y) → range + bearing. Any ≥2 anchors keep a fix; degrades gracefully. Fronts range every loop, rears round-robin (FOLLOW_REAR_EVERY=2); ranging port timeout 0.1s (this was the speed fix) → 3.3Hz with all 4 anchors at 20/20. Freshness window 0.9s.

Steering = bearing PD law (the original cm-diff law was distance-blind and pulsed):

Throttle: from solver range — stop inside 250cm (FOLLOW_SETPOINT_CM; sized so your legs sit ~3ft OUTSIDE the prox-gate caution edge — at 150 the gate flickered and stalled the cart), full cap at 450cm, FOLLOW_THROTTLE_CAP=0.60 (~2.7mph) until braking is fully trusted.

Safety behaviors (all live):

Next layer (designed, not built): goal-biased follow-the-gap — steer to the clear lidar gap nearest the tag bearing. One code change in recommend_heading() when it's time.

13. Safety systems (the stack, in order of what fires)

Safety here is an onion, not one switch: nine separate layers, each able to stop the cart on its own. They run from "sees a problem coming" (the proximity gate) down to "everything crashed" (a script that parks the wheels even if the whole program dies). The rule behind all of them is simple: any single failure should make the cart stop, never make it run away. The table reads top to bottom in the order things would fire. And check the gap list at the bottom — I keep an honest list of what's not protected yet, because pretending it's all covered is how you get surprised.
LayerWhat it does
Prox gate (proximity.py)Fuses forward TFminis + S3 corridor into a throttle scale: 1.0 clear / 0.5 slow / 0.0 STOP (obstacle, curb, drop-off, corridor hit). Follow-only; manual RC never gated. Master switch PROX_GATE_ENABLED=True.
Phone BYPASSWebpanel button pauses sensor BRAKING for 45s then auto-re-arms (never latching). For deliberate curb hops / gate false-trips. Display alerts stay live.
Follow-me gatesBoot-disarm, tag-behind stop, tag-lost stop, stop bubble 250cm.
Deadmandrive.py parks neutral if the autonomy command is stale >1.5s or phone E-STOP is set.
Thread watchdog (main.py)rc_input/drive/steering thread death → immediate safe shutdown (was silent-latch-forever).
Auto park-brake2s commanded-zero → PPM pulse dropped → VESC 15A timeout brake both halves.
ExecStopPostpark_neutral.sh forces servo-neutral + VESC duty-0 clamp on ANY process death.
VESC-sideTimeout Brake Current 15A; PID min-ERPM 5 brakes hard while moving; voltage cutoffs.
CH3Always overrides to manual.

Known gaps (decided-but-not-built or open): the gate fails OPEN on dead I2C / dead lidar thread / optical no-return; no low-battery action (pack just bogs and dies); webpanel /api/resume is unauthenticated on :8080; rain no-return false cliffs.

14. Networking, web panel & remote access

Tailscale is a free app that puts your devices on their own private, encrypted mini-internet. The cart keeps one permanent private address no matter whose WiFi it's on, so your phone and laptop can always reach it — even at the beach, where a pocket cellular hotspot gives the cart its internet. On top of that sits the web panel: a webpage served by the cart itself that works like a car dashboard on your phone — live camera, speed, battery, sensor health, an E-STOP button. The "autoconnect ladder" below just means the cart tries its list of known WiFi networks in order at boot and falls back if one's missing.

Tailscale is the backbone: the cart is one stable private address from anywhere.

WiFi autoconnect ladder (one radio, NetworkManager picks highest in range): home WiFi = 100 → Moxee cellular hotspot = 50 (power it ON when out — it gives the cart real internet at the beach so Tailscale works) → rental WiFi = 50 → BeachCart own-AP = −50 (last resort; phone can join it, panel at 10.42.0.1; no internet in that mode). AP-trap fix (7/8): if the boot scan misses everything, NM used to wedge in the BeachCart AP forever (it never tears down an active AP to rescan). tools/wifi_rescue.sh + user wifi-rescue.timer (2 min) auto-escapes unless a phone is actually connected — live-tested, trap→home WiFi in 24s.

Web panel (modules/webpanel.py, port 8080, phone control panel): live camera, Pi temp (°F, yellow 158/red 176), Drive card (mph, motor A, FET °F, live + latched faults, Ah/Wh, trip miles), per-anchor UWB health card + tag-dead banner, motor-sync banner, prox gate reason + BYPASS button, E-STOP/resume, "ASK CLAUDE" box (dictate into the textarea → injects into the live Claude Code tmux pane — iOS dictation doesn't work in raw terminals).

Photo drop (tools/photo_upload.py, port 8090): phone → Pi picture uploads → ~/beach_cart/uploads/ (how I get phone photos onto the Pi). nohup-run — restart it after a reboot.

15. Software, running & autostart

The cart's software starts itself: flip the power on, and about a minute later everything — drive, sensors, follow-me, the radar screen, the web panel — is running with no keyboard or monitor. That's done with "services" (Linux for "always run this program, restart it if it dies, run this cleanup if it crashes"). This section lists them all, plus how to run things by hand when you're tinkering, and the automatic backups that push every change to a private online copy twice an hour.
  1. Manual run: cd ~/beach_cart && PYTHONPATH=~/beach_cart python3 main.py. ~/start_cart.sh = manual foreground run. To drive manually: flip RC CH3.
  2. Services: user beachcart.service (linger on; ExecStopPost=park_neutral.sh), radar.service (5" scope, After=beachcart; WantedBy=default.target — graphical-session.target is inactive on this setup), wifi-rescue.timer, beachcart-autopush.timer, wayvnc; system can0.service (CAN up at boot), uwb-gnd.service (anchor ground FET).
  3. Stop cleanly: SIGINT to the python main.py process (match by exact comm; avoid kill -9 — though ExecStopPost now parks safely even then).
  4. Dev workflow: type cart → tmux split (Claude Code left, shell right), survives SSH drops. Claude Code runs ON the Pi (memory + README auto-load); README = the handoff brain.
  5. Backup: auto-commit + push every 30 min to a private GitHub repo (beachcart-autopush.timer; push now: systemctl --user start beachcart-autopush.service). system_config/collect.sh snapshots everything outside the repo (systemd units, /boot, deps) so the repo is a full restore image — RESTORE.md is the new-Pi playbook.
  6. sudo -n passwordless flip-flops by session — test, don't assume. nmcli works without sudo (polkit grants the console user network control).

16. 3D-printed parts (generators in 3d_models/, PETG unless noted)

Every custom bracket, spacer, and case on the cart is 3D-printed in PETG — a plastic that handles sun and moisture better than the usual PLA. The odd part: instead of drawing these parts in a CAD program, each one is generated by a small Python script. A dimension tweak is a one-line edit and a re-run. No printer? Every part here is simple enough for a local print shop or library makerspace to run from the STL files.

The .py voxel/marching-cubes generator is the source of truth; the STL is its output (stl/ is gitignored — regenerate, or serve with a temp http.server 8091 over Tailscale for the PC).

P1S print notes (PETG): 245/255°C, bed 70°C, 0.25mm first layer, LOW part-cooling fan (40–50% — high fan warps corners), brim on small parts. First-layer lifting = wash the plate with warm water + DISH SOAP (not just IPA).

16b. Printing carbon-fiber nylon (PA6-CF) — the hub recipe

The wheel hubs are the one part PETG can't handle, so they print in carbon-fiber nylon — much stiffer and tougher, but a picky material. I did the homework below (and double-checked it) before betting 50-hour prints on it.

17. Key pin & config reference (full map: PINOUT.md)

The one-page cheat sheet: which wire lands on which Pi pin, plus the key electrical numbers. When something's acting up or you're rebuilding, check this list first.

18. Open items as of 7/14

The honest to-do list. Every real build has one — putting it out here keeps me from pretending the cart is more finished than it is.
  1. Re-seat the Pi's thermal pad (idles 65°C; throttled at 79.6°C on 7/10) — before the trip.
  2. Walk-in-front re-test of the S3 corridor braking (on blocks, then in follow-me).
  3. Replace ANCH4's fail-unsafe 3.3V step-down with a Mini360 (last one on the cart).
  4. Inspect the front-right corner bracket/harness for 7/10 hill-slide damage beyond the module.
  5. Install the 50A ANL main pack fuse (re-sized for the 50Ah pack; not yet in).
  6. Gearing decision (60T = 6:1) after the trip. (Power Queen 50Ah: DONE — installed 7/22, VESCs at 20A/side.) Split-bus electronics battery still to finish.
  7. Ramp reconciliation: Pi-side 0.7/s vs VESC 1.0s PPM ramp still stack.
  8. Prox-gate rain fixes (longer no-return persistence, 2-of-3 agreement, phone RECAL-GROUND button); gate fails-open on dead sensors → make it fail closed + sensor liveness.
  9. Low-battery action (warning + clean poweroff above BMS cutoff) — needs the loaded discharge threshold; limp-home mode still deferred on the same data.
  10. Webpanel /api/resume is unauthenticated on :8080.
  11. Park-brake debug leads if symptoms return: timeout_brake=15A verified on one half only; battery.py polling may reset the VESC timeout; safe_start release lag.
  12. New 7/14: re-verify steering center + travel limits after the front-end rebuild (trims were tuned to the old servo mount), and hand-sweep full lock for brace clearance before the next drive.
  13. New 7/14: the carbon-fiber hub project (§16b) — PLA shape check, then 4 halves at 50h each, bond, and swap wheels before the end of the month.

--- End of guide. Chronological history: README.md. Bill of materials: STOCK_LIST.md. Wiring: PINOUT.md. Restore: RESTORE.md. ---

Parts List (Bill of Materials)

Confirmed from purchase records June 2026; statuses updated July 26, 2026. Status key: [BUILD] in the final cart · [SPARE] bought, not in the final build · [RETIRED] was in the build, superseded · [DEAD] destroyed along the way · [PLANNED] decided, not yet bought/arrived · [TOOL] reusable tool.

Compute & Cooling

ComponentModel / SpecQtyStatusNotes
Single-board computerRaspberry Pi 5, 8GB1BUILDhostname Beachcart. (Pi #1 was killed by a charger transient — see Surge Protection.)
Pi case + coolingArgon NEO 5 Aluminum, dual passive/active1BUILDOnboard base fan died (same transient era).
Cooling fan (replacement)30mm 5V blower, Pi 5 fan-header plug1BUILDINSTALLED 7/3 (the HAT-removal header disaster session). Runs full-on via GPIO45 (pwm overlay kills the firmware curve; no tach). ⚠ OPEN: the SoC thermal pad shifted during the 7/3 surgery — Pi idles ~65°C, soft-throttled 79.6°C on 7/10. Re-seat the pad.
SD card readeracer USB-C dual-slot1BUILD

Batteries — INSTALLED (motor pack: Power Queen 50Ah, 7/22)

ComponentModel / SpecQtyStatusNotes
Motor battery packPower Queen 24V 50Ah Bluetooth LiFePO4 (10.24 x 6.61 x 8.27 in, 1280Wh, ~23lb), 8S / 25.6V nominal1BUILDINSTALLED 7/22 in the under-frame box. Spec: 50A continuous / 60A 30-min peak BMS; native 24V = no series wiring, ONE BMS; existing BROODAY charger + VESC 8S cutoffs work as-is. First full charge recalibrated the BMS reference: real capacity 52.5Ah (ships underrated). VESC battery amps 20A/side (=40A pack) / regen −20A/side. State-of-charge comes straight from the pack's Bluetooth BMS, read by the Pi every 5 minutes (the software coulomb counter is retired — see §9b). ⚠ One Bluetooth central at a time: close the vendor phone app fully or the Pi's reads fail. Won't fix the big hill — that stall is TORQUE-limited (motors pinned at 60A hw cap), not power-limited.
Battery pack (interim, 7/10–7/22)2x SEFEPODER SP1220M 12.8V 20Ah LiFePO4, wired in SERIES = 8S2RETIREDThe interim motor pack (installed 7/10, retired 7/22 when the Power Queen went in; may be returned/repurposed). Spec: 40A BMS trip / 20A continuous / 3C pulse / 10A charge; ran at 15A/side = 30A. Hill re-test 7/10 night: zero faults, 654W peak, 26.8A, Vmin 24.1V.
Battery pack (previous)2x SEFEPOWER SP1215 12.8V 15Ah LiFePO4 (series)2RETIREDRetired from motor duty 7/10 — its 16–20A BMS was the measured power ceiling (19.4A draw pinned it on the hill). Split-bus plan: one 15Ah becomes the electronics battery in the existing bracket (14.6V 5A charger picked for it). Measured 14.8Ah usable (7/4 bench discharge).
Battery charger (24V)BROODAY 24V 10A LiFePO4 (29.2V)1BUILDWorks as-is for the Power Queen (8S). ⚠ Never run electronics while charging.
Charger (12V, electronics batt)14.6V 5A LiFePO41PLANNEDPicked 7/9 for the 15Ah electronics battery (a 20A unit was rejected — over every pack's charge spec). XT60 charge-port scheme: different connector per voltage.
Main pack fuse50A ANL, high interrupt rating, 10AWG+ leg1PLANNEDRe-sized 7/22 for the 50Ah pack: 40A drive draw (20A/side) + 40A regen, at the pack's 50A-continuous BMS rating. Still to physically install.
Battery box (50Ah, under-frame side-loader)3D-printed two-piece hanging box + door + 2x slotted L-hangers (battery_box_50_side_gen.py, PA6-CF, ~3 spools)1BUILDPrinted + hung under the frame 7/22 — battery lies on its side and slides out through a screw-on door (battery out most of the way → bolt the lugs → slide home). Cover field-revised to a stepped profile. ⚠ Full steering lock now reaches the box → steering throw is software-clamped with ~1" verified clearance.
Battery box (50Ah, between-rails — superseded)3D-printed two-piece frame-rail box (battery_box_50_gen.py, PETG)1SPAREPrinted 7/9–7/10: 18mm shiplap joint (each half > P1S bed alone), each half rail-mounts independently. Superseded by the under-frame side-loader (the battery wouldn't fit standing up); halves + strap design kept as spares.

Power Distribution & Protection

ComponentModel / SpecQtyStatusNotes
Main buck converters24V/12V→5V 10A 50W waterproof2BUILDBuck #1 = Pi + logic bus; Buck #2 = dedicated clean 5V UWB anchor bus (fault-isolated). Star ground meets HERE.
TVS diodeSMBJ30A (100-pc lot)1 (+spares)BUILDINSTALLED 6/25 across the 24V bus at charge entry — stripe→V+, short fat leads. The defense against the transient that killed Pi #1. 30V standoff vs 29.2V regen is tight; swap to SMBJ33A/36A if it nuisance-clamps.
3.3V regulators (anchors)Mini360 DC 5-30V→3.3V 1.8A, 5-pack4 in useBUILDPer-corner anchor power. Mini360 = fails SAFE (can't pass 5V); the newer no-name step-downs FAIL-UNSAFE and killed multiple RYUW122s when they blew. ANCH1/2/3 = Mini360 as of 7/10; ⚠ ANCH4 still has a fail-unsafe one — replace it. Always meter 3.3V before connecting a module.
Buck-boost (tag)TPS63020 module ("XL63020" seller label)1BUILDTag power — a plain buck can't hold 3.3V across a LiPo's 4.2→3.0V discharge. Set 3.3V by shorting the 3V3 pad ONLY.
LiPo charger (tag)TP4056 USB-C1 (+spares)BUILDAlways-on (B+/B−) so the tag charges switched-off.
Tag battery803040 LiPo (40x30x8)1BUILDWall-charge it — a dead tag reads as "all anchors OK, 0 hits".
Pi power leadUSB-C bare-wire DC input, 20AWG 5V 5A2-packBUILDPi powered via USB-C off Buck #1. usb_max_current_enable=1 applied.
MOSFET (UWB GND reset)IRLB8721PBF logic-level N-ch, 30V/62A (10pk)1BUILDWIRED + WORKING: low-side switch in the anchor common ground on GPIO16 (220Ω gate, 10k pulldown). uwb-gnd.service + ~/uwb_reset.sh. Fixes the power-on latch-up.
Resistor kitBOJACK 1000pc, 25 values 1Ω–1MΩ1BUILDArrived; used for the FET gate resistors + spares.
Remote switchdstfuy wireless 40A relay1BUILDElectronics-off-while-charging discipline.
USB hub (old)Yahboom 4-Port USB 3.01SPARERan bus-powered (no matching 9–24V plug) → browned out at the 4th anchor. Superseded by the SC16IS752 path.
USB hub (powered)Compact 4-Port USB 3.2 Gen1 powered hub1DEADArrived, then burned (VL817 controller scorched) when wired to the 5V bus 6/29 — suspect short at the cut barrel-jack joint. This failure pivoted anchors to the SC16IS752 boards for good. Bench any replacement on a current-limited supply first.

Sensing

ComponentModel / SpecQtyStatusNotes
LiDAR ring (obstacle)DIYmall Benewake TFmini Plus, IP65, UART6BUILDAll 6 ranging via mux (I2C 0x10 behind it). ch0 F-straight, ch1 F-down, ch2 FR, ch3 FL, ch4 BL, ch5 BR. Feeds the safety gate + drop-off detection.
I2C multiplexerDEVMO TCA9548A (CJMCU-9548) 1-to-81BUILDaddr 0x70, I2C bus 1.
360° LiDARRPLIDAR S3 dToF, 40m1BUILDIn the final build since 6/20 (old "spare" status wrong): 1Mbaud via CP2102N, radar scope on the 5" screen, rear 90–270° masked, and since 7/10 it feeds the forward corridor safety braking (person detection follow-me gap).
UWB modulesREYAX RYUW122_Lite (6.5/8GHz)~11 boughtBUILD4 anchors + 1 tag LIVE ([4A] fix @3.3Hz). Several consumed learning the power rules (TX-brownout w/o caps, 5V from failed step-downs, the 7/10 hill-slide smash). Remainder = spares. 3.3V-ONLY; caps mandatory before any power/serial.
CameraArducam 12MP Autofocus USB, HDR1BUILDUVC → /dev/video0; streams to the phone webpanel (rotated 180 in browser CSS).
LiDAR (alt)MakerFocus TF-Luna single-point2SPARESuperseded by TFmini.
Camera (alt)Raspberry Pi Camera Module 32SPARESuperseded by Arducam.

Motor Control & Drivetrain

ComponentModel / SpecQtyStatusNotes
Drive motorsFlipsky 7070 110KV BLDC, 10mm sensored2BUILD14 poles / 7 pole pairs; one software-inverted. 60A per side = the cart's torque ceiling (big-hill stall is an accepted boundary).
Motor controllerFlipsky DUET XS60 — one DUAL ESC (two 60A drive halves)1BUILD~$250. Halves on CAN 0x13 + 0x14 as of 7/10 — ⚠ IDs are volatile; every VESC Tool wizard can renumber one, re-scan the bus. (Old "CAN ID 17 + 2nd local VESC" note was this same unit, mis-described.) Current tune: PID speed, 3300 ERPM, 60A motor / 15A batt / 120A abs, timeout brake 15A.
Wheels16.5" (42cm) PU wheel, 3/4" bearing4BUILDWZ1-42UB; ~$679 incl. shipping. ⚠ Tire MUST be epoxy-bonded to the hub before drivetrain work.
Steering servoHappymodel Super400 Plus, PWM 300°, 24V industrial1BUILDGPIO13 signal only; ~$148. Check linkage seating — popped its socket under hill load once.
Drive chainSingle-speed bike chain 1/2"×1/8" ("410") 114-link2BUILD5:1 reduction.
Sprocket (gearing option)ESP SPR-41060G1 60T 410-chain (6:1, +20% torque)1PLANNEDCustom-made, ~$82, wouldn't arrive before the trip — not ordered; decision after the trip. PETG mock-up printed for fit-check only. ⚠ Cheap "60T" listings are #40/41/420 pitch — 410 chain won't seat.
Bearingsuxcell SBPFL204-12 pillow block, 3/4" bore4BUILD
BearingsUCF204-12 square flange, 3/4" bore4-packBUILDself-aligning
Steering tie rodsX AUTOHAUX M8 ball joint (final length)1 setBUILDbought 80/100/120/185/210mm — sizing iteration
Tie rods (extra sizes)X AUTOHAUX M8, unused lengthsseveralSPARE
VESC BT module (alt)V6 nrf51_vesc Bluetooth (Flipsky)1SPAREPhone VESC Tool connects to the DUET directly.

Interface & Comms

ComponentModel / SpecQtyStatusNotes
CAN interfaceWaveshare 2-Ch Isolated CAN HAT (MCP2515 + SN65HVD230)1BUILDcan0 @ 500k (auto-up via can0.service); CAN0 INT GPIO23; can1 unused. Battery gauge + all CAN tooling.
Serial expansion (UWB)Waveshare SC16IS752 I2C-to-UART, 2 UART each2 in use (3 bought)BUILDBoards 0x48 (INT GPIO17/pin11) + 0x49 (INT GPIO12/pin32), wired off-header, 3.3V only. One earlier board burned attempting a solder address-bridge (set addr= in the overlay instead) → replacement bought. ⚠ ttySC↔anchor map shuffles between boots — identify by AT+ADDRESS?, never by port. GPIO25/pin22 is DEAD on this Pi — never use for INT.
Logic level shifterLONELY BINARY bidirectional 3.3V↔5V kit (27pc)1BUILDBSS138; all 3 RC channels pass through it (raw 5V = garbage reads).
RC transmitterFlysky FS-GT3C 2.4GHz 3-channel1BUILDCH3 = manual-override switch.
RC receiverFlysky GR3E / FS-GR3E2BUILDCH1 GPIO22, CH2 GPIO27, CH3 GPIO24 (old 23/22 labels wrong).
DisplayELECROW 5" 800×480 HDMI1BUILDHDMI-A-2, no EDID (mode pinned in cmdline.txt + kanshi). Video-only as wired; shows the fullscreen radar since 7/5. 45° tilt case printed.
HDMI cableMicro HDMI→HDMI 4ft + Duttek 8K Mini HDMI2BUILD
Cellular hotspotMoxee1BUILDThe beach-internet uplink: cart auto-joins it (priority 50) so Tailscale reaches the cart anywhere. Separate battery device — power it ON and keep it charged when out.
USB-TTL adaptersHJHYUL CP2102 USB→TTL 4-pin (3.3V)3SPAREWere the anchor path pre-SC16IS752; still handy for bench AT-command work.
Slide switchesEGSCST SS12D00G micro SPDT (150pc)1 kitBUILDtag power switch + spares

Wiring, Connectors & Cable Management

ComponentModel / SpecQtyStatus
Silicone wire 12awgZIGPEO 50ft red/black1BUILD
Silicone wire 8awg10ft red/10ft black1BUILD
Silicone wire 24awgTUOFENG 6-color + BNTECHGO spools2BUILD
Jumper wiresEDGELEC Dupont M-F 100cm + 50cm2BUILD
Pre-crimped connectorsGH/Dupont 2.54 + JST GH 1.25mm kit1BUILD
Power distributionRecoil BBS25P bus bar (2×M5 + 5 screw)1BUILD
Terminal blocksMILAPEAK 4/5/6-position 600V 15A (6 sets)1BUILD
Fuse holdersAnyongora 12awg inline (4pk) + VANTRONIK Maxi 100ABUILD
ConnectorsAmass MT60 (5pr) + Amass XT90 anti-spark (5pr)BUILD
Cable glandsLISTENJIALE waterproof PG7–PG19 (50pc)1BUILD
Cable sleeving132ft expandable braided PET loom1BUILD
Heat shrink400pc 3:1 adhesive-lined marine grade1BUILD
Quick disconnectsSherco-Auto 8awg heat-shrink (10pk)1BUILD
Zip-tie mountsXHF 3/4" back-glue (100pc)1BUILD
USB cablesPoyiccot short USB-USB (2) + Jelly Tang USB3 ext + FEMORO microBUILD
Decoupling caps (UWB)10uF 50V low-ESR electrolytic + 0.1uF ("104") ceramicper moduleBUILD

Structural, Frame & Fasteners

ComponentModel / SpecQtyStatusNotes
Raw aluminum stock1.5" square tubing / sheet / barbulkBUILD~$800, local metal supplier
Steering bracketsalvaged kid's-wagon front pivot assembly1BUILDcenter pivot + stub axles
Aluminum angle4"×4"×1/4" 60634BUILD
Stainless tubeGeilSpace 3/4" OD 304 SS round (2pk)1BUILD
Stainless rod3/4" 304 SS round rod, 48"1BUILDrear axle stock
Threaded rodArwnnklo M6 304 SS 10" + nuts2BUILD
TIG fillerMorningRo ER309L 3/32" stainless rod 2lb1BUILD
Cap screwsJoamang M4 (100pc) + ATHYUTH M2 (960pc)BUILD
Rivet nutsSAE pressure rivet nut tool kit (530pc)1BUILDtool + nuts
Drive keysSwpeet carbon steel key stock (140pc)1BUILD
Tube end capsPrescott 3/4" ribbed plastic (20pk)1BUILD
GrommetsVrupin rubber kit (188pc)1BUILD
Thread lockerESKONKE 648 retaining compound1BUILD
EnclosuresLeMotech ABS boxes (10pc) + GITRUAX IP67 boxBUILD

3D-Printed Parts (PETG, generators in 3d_models/)

PartGeneratorStatus
TFmini brackets (flat / 28°-down / compound corners L+R)tfmini_mc / tfmini_angled_down / tfmini_corner_compoundBUILD
Wheel/bearing spacers (INNER 25mm, OUTER 30mm)wheel_spacer.pyBUILD (load-bearing — the torque spec)
5" screen case, 45° tiltBUILD
UWB tag enclosure (press-fit lid, fob loop)tag_case_gen.pyBUILD
Motor-pack battery box (two-bay)battery_box_gen.py v5BUILD
50Ah battery box (two-piece shiplap)battery_box_50_gen.pyBUILD (awaiting the pack)
60T sprocket MOCK-UP (fit-check only, not a drive part)sprocket_60t_gen.pyBUILD

Tools (reusable — not part of cart value)

ToolModelStatus
Soldering gunWeller D550PK 260W/200W kitTOOL
SolderMAIYUM 63-37 rosin core 0.8mm 100gTOOL
3D printerBambu Lab P1STOOL