How to read this guide
Here's the system this guide uses:
- Overview boxes open each section and tell you what the thing is, what job it does on the cart, and why it's built the way it is. If a section ever stops making sense, go back and re-read its Overview box.
- "Field note" boxes are warnings and hard-won lessons. Every one of them exists because something broke, burned, or drove off wrong. Read them before doing the steps they sit next to, not after.
- Numbered steps are in the order you should actually do them. The section order (frame → power → brain → sensors → autonomy) is also the build order — each layer is tested working before the next goes on top.
Text that looks like thisis a literal filename, setting name, or command — type it exactly.- Words you don't recognize are probably in the glossary right below. Skim it once now; you don't need to memorize it.
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.
| Part | What it is | What it does on the cart |
|---|---|---|
| Frame | 1.5" square aluminum tubes welded into a rectangle | The 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-skateboards | The 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 power | The 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 chemistry | The 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"). |
| BMS | Battery management system — a protection circuit built inside each battery | The 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 converter | A voltage step-down device | Turns 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 5 | A credit-card-sized Linux computer | The brain. Runs every line of the cart's software: reading sensors, steering, throttle, the follow-me logic, the web dashboard. |
| Steering servo | A motor with position control built in: tell it an angle, it goes there and holds | The 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 + receiver | A 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 lidar | A thumb-sized laser tape measure | Close-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 S3 | A spinning laser scanner | The 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 + tag | Ultra-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. |
| Camera | A 12MP USB camera | Streams live video to your phone so you can see what the cart sees. |
| CAN bus | A rugged two-wire network borrowed from the automotive world | How 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 lasts | The 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 Pi | Where every signal wire lands. Pins are referred to by number (e.g. GPIO13 = steering signal). |
| Level shifter | A voltage translator on a fingernail-sized board | The 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 UART | Two simple "wire languages" chips use to talk to each other | I2C 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). |
| Fuse | A strip of metal that melts if too much current flows | The fire-safety valve on the battery. A short circuit blows the fuse instead of igniting the wiring. |
| TVS diode | A surge-protector component the size of a grain of rice | Clamps 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 ground | A wiring rule, not a part | Every 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
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 — 1.5" square aluminum, TIG welded
- 2. Steering — wagon steering bracket + tie rods + 24V servo
- 3. Drivetrain — 2 BLDC motors, 5:1 chain, 16.5" wheels (BOND THE TIRES FIRST)
- 4. Power system — 8S LiFePO4 (24V 50Ah), fuse, TVS, dual 5V bucks, star ground
- 5. Compute — Raspberry Pi 5 + Argon NEO 5 + OS + cooling
- 6. Motor controller — Flipsky DUET XS60 dual VESC, configured over VESC Tool + CAN
- 7. RC control — Flysky receiver, level shifter, GPIO wiring
- 8. Drive & steering software — hardware PWM, ramps, auto park-brake, deadman
- 9. Sensors & telemetry — 6x TFmini ring, RPLIDAR S3, CAN drive telemetry + Bluetooth BMS battery gauge
- 10. Displays — camera to the phone, radar on the 5" screen
- 11. UWB localization — 4 corner anchors + wearable tag (WORKING, 3.3Hz 4-anchor fix)
- 12. Follow-me autonomy — 4-corner solver + bearing PD steering (road-tested)
- 13. Safety systems — the full gate/watchdog/park stack
- 14. Networking, web panel & remote access
- 15. Software, autostart & backup
- 16. 3D-printed parts
- 17. Pin & config reference
- 18. Open items as of 7/14
1. Frame & chassis
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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).
1b. Running wires through the tubing — step by step
- 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).
- 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).
- 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.
- 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.
- 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.
- 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.
- 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).
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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.
- Mount the wagon bracket to the front cross member; fit the front wheels to its stub axles.
- 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).
- 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.
- 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).
3. Drivetrain (motors, chain, wheels)
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.
- Mount the motors (motor plates / 6063 angle); each drives one rear wheel.
- Sprockets for 5:1 (small on motor, large on wheel), keyed with carbon-steel key stock.
- 1/2"x1/8" 114-link single-speed bike chain per side ("410" chain). Align sprockets in-plane; slight slack, not taut.
- Rear axle on uxcell SBPFL204-12 pillow blocks / UCF204-12 flange bearings (3/4" bore); 3/4" 304 stainless rod is the axle stock.
- Print + fit the wheel/bearing spacers (§16): OUTER 30mm with counterbores, INNER 25mm. PETG, high infill — load-bearing.
- Bolt on the wheels.
What the drivetrain can and can't do (measured 7/8–7/10 with tests/hill_power_log.py):
- Flat + moderate hills: fine. Small steep hill: climbs (654W peak, 26.8A pack, FETs ≤61°C).
- The BIG hill stalls with BOTH motors pinned at 60A (the DUET XS60 per-side hardware ceiling) while battery current sat at only 23A of 30A available → the cart is TORQUE-limited, not power-limited. More battery will NOT move that stall point; only gearing would (60T wheel sprocket = 6:1, +20% torque — real 410-chain 60T is a custom part, ESP SPR-41060G1 ~$82; decision deferred). ⚠ Cheap "60 tooth" listings are #40/41/420 pitch — 410 bike chain will NOT seat on them. My current verdict: the mechanical rating of the printed spacers/chain is the torque spec; the big hill is an accepted boundary.
- One motor spins "backwards" because the motors face opposite directions — fixed in software (Invert Motor Direction on that half), NEVER by swapping phase wires.
4. Power system & distribution
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.
- Build the 24V bus with 8AWG/12AWG silicone wire + the Recoil bus bar. Bus order: battery (+) → main fuse → bulk caps → TVS → loads.
- 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.
- 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.
- Bulk low-ESR electrolytic (≥35V) across the bus to soak regen energy (complements the TVS).
- 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).
- 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.
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)
Raspberry Pi 5 8GB in an Argon NEO 5 aluminum case, powered via USB-C bare-wire lead from Buck #1 (5V 5A).
- Flash Raspberry Pi OS 64-bit (labwc/Wayland desktop). Hostname
Beachcart, SSH on. - In
/boot/firmware/config.txt:usb_max_current_enable=1(raises USB-A budget 600mA → 1.6A — required for camera + RPLIDAR; verify withvcgencmd get_config usb_max_current_enable= 1). - 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.
- Two outputs use RP1 TRUE hardware PWM via sysfs (
/sys/class/pwm/pwmchip0): GPIO13 = servo (chan1, needsdtoverlay=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):
- The NEO 5's built-in base fan died (likely the same transient that killed Pi #1). Replacement 30mm 5V blower installed 7/3 on the Pi 5 fan header — it runs FULL-ON via GPIO45 (the pwm overlay disables the firmware fan curve; no tach, so software can't see spin — check physically).
- ⚠ The 7/3 fan install required pulling the HAT + every header pin, and the SoC thermal pad shifted: the Pi now idles ~65°C when a healthy NEO 5 idles 45–55°C, and it soft-throttled at 79.6°C on 7/10. RE-SEAT THE THERMAL PAD (open case, re-seat/replace pads, snug evenly; expect ~50°C idle). Software trim already applied: lidar_radar.py capped 30→15fps (top CPU load 35%→~20% of a core; the sweep only updates at 10Hz so nothing visible is lost).
- After ANY header/HAT surgery, rebuild wiring from PINOUT.md — it exists precisely because the 7/3 rebuild had to reverse-engineer four files. Reconnect order + per-wire tables are there.
6. Motor controller (Flipsky DUET XS60 dual VESC)
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):
- Motor detection for the Flipsky 7070, sensored FOC — confirm the hall table isn't all 0/255.
- 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).
- Max ERPM 3300 (~4.5mph), reverse cap matched.
- 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).
- 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.
- 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).
- 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."
- Invert Motor Direction on the backward-facing half only.
CAN hygiene rules (each learned the hard way):
- STOP the cart software before any VESC Tool config write (
systemctl --user stop beachcart.service) — battery.py's polling corrupts writes ("config reverts every power-cycle"). - After ANY wizard: re-scan the bus for moved CAN IDs.
- After any CAN-tool config write, re-sync the phone VESC app (↓M read BOTH halves → save backup) — else its stale one-button "apply" stomps the change later.
- External CAN tools poll with sender id 0x2B (safe alongside the service):
tests/vesc_absmax.py(read/patch Abs Max),tests/vesc_battmax.py(battery amps),tests/hill_power_log.py(live power CSV logger). - Never sender-check DUET replies: the second drive answers at base+1 (0x14) but stamps its replies with the BASE id (0x13). A sender==target check makes half the ESC look dead (7/10 bug).
7. RC control
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).
- Bind the GR3E; it outputs 5V PWM on 3 channels.
- CRITICAL: run all 3 signals through the LONELY BINARY bidirectional level shifter (5V→3.3V). Raw 5V onto Pi GPIO = garbage reads.
- 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.
- Test inputs with
tests/rc_noise.py(~±6us jitter, 0 rejected) and outputs withtests/output_test.py(wheels up; it holds neutral 4s first because the VESC must see steady ~1.5ms neutral to ARM before accepting throttle).
8. Drive & steering software (drive.py / steering.py)
- Throttle in: (ch2−1500)/500, inverted, deadband 0.18. VESC pulse = 1500us ± 400 (
DRIVE_RANGE_US), clamp 1100–1900us, 50Hz. - Pi-side throttle slew: up 0.7/s, down 2.5/s (down stays fast for braking). ⚠ Open item: this stacks with the VESC's 1.0s PPM ramp — pick ONE ramp source eventually.
- Steering: pulse 1500us ± 540, clamp 600–2400us, deadband 0.10, slew 1500us/s.
- AUTO PARK-BRAKE (working, re-enabled 7/8 late): after 2s of commanded-zero (
PARK_BRAKE_IDLE_S=2.0,PARK_BRAKE_CMD_EPS=0.02), drive.py drops the PPM pulse train entirely (duty 0) → the VESC's 1s signal-loss timeout clamps its 15A timeout-brake on BOTH halves. Release is command-driven only, so it never cycles on a hill. Powered-off cart still freewheels (pushable dead cart preserved — that's deliberate). - Deadman: drive parks neutral if the follow-me command is stale >1.5s (
DRIVE_AUTO_STALE_S) or the phone E-STOP (remote_stop) is set — a hung autonomy thread can't keep driving. - Crash-runaway protection:
park_neutral.shruns asExecStopPost=in beachcart.service — on ANY main.py death (crash/OOM/SIGKILL) it forces the servo to neutral and the VESC channel to duty 0 (= parked, not freewheeling) straight from sysfs. RP1 hardware PWM keeps emitting the last pulse after a process dies; this is what handles that.
9. Sensors & telemetry
9a. Obstacle LiDAR ring — 6x TFmini Plus
- 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-uunbuffered). - All 6 through a TCA9548A I2C mux (0x70, bus 1); each sensor is 0x10 behind its channel.
modules/lidar.pypolls the mux;modules/proximity.pyproduces the alert line (feet, yellow <5ft, red flashing <2ft) AND the safety gate (§13).- 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. - 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.
- Rear sensors (ch4/5) are ALERT-ONLY by design — see the reverse rule in §12.
9b. Battery gauge + coulomb counter over CAN
- 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. - can0 @ 500k auto-raised at boot by the system oneshot can0.service (BindsTo can0.device) — without it the gauge dies on every reboot.
modules/battery.pypolls 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.5for 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).
- Measured 8S LFP SoC table (7/4: full 16.6h bench discharge, 26.4→24.6V, 14.8Ah usable —
- 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.
- 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
- 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. - 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.pyexists for post-remount calibration. - Rear mask:
S3_MASK_SECTORS=[(90,270)]blanks the rear half at ingestion (cargo sits there); all consumers inherit. - 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.
- 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)
Layout since 7/5 (wheels-down): the camera streams to the PHONE web panel; the 5" screen is a fullscreen radar scope + info strip.
- Camera: Arducam 12MP autofocus USB (UVC → /dev/video0; rpicam/libcamera saying "no cameras" is normal for USB).
WEBPANEL_CAMERA=Truemakes 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). - 5" display: ELECROW 800x480 HDMI on HDMI-A-2 — no EDID, so pin the mode twice:
video=HDMI-A-2:800x480M@60in 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. 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.- 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)
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)
- 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).
- 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.
- 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).
- 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
- Board 0x48 (jumpers default) + board 0x49 (A0=1), both on I2C bus 1 at 3.3V (never 5V). Two
dtoverlay=sc16is752-i2clines → ports /dev/ttySC0–3. Don't solder address bridges — setaddr=in the overlay (a solder attempt burned a board). - 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/interruptswhile polling — a counter that doesn't climb = INT problem. - Anchor UART is a TXD↔RXD crossover into the board channel. An echo (board reads its own TX) = RX-path wiring fault — meter continuity.
- Recovery when boards are absent at boot (deferred probe): fix wires →
i2cdetect -y 1shows 48/49 →echo 1-0048 | sudo tee /sys/bus/i2c/drivers/sc16is7xx/bind(+ 1-0049) → ports appear. No reboot needed.
Configuration & SOPs
- 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). - ⛔ 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. - 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.
- Bring-up per corner (proven recipe): caps + metered 3.3V →
uwb_charz.py(link quality) →uwb_config_anchor.py→uwb_range.py(expect 10/10 vs the tag). Green LED = ranging link (needs tag AND anchor up), not power. - 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
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):
- EMA-smoothed bearing (
FOLLOW_BRG_SMOOTH=0.55), slew-clamped 18°/fix, deadband 10°, gain 0.03/° (full lock ~43°), D-termFOLLOW_STEER_KD=0.012on the filtered bearing rate (unwinds the wheel early as the bearing converges — kills the wheels-down overshoot the parked bench tune couldn't see). - Ease: engage 0.12 / release 0.18 — self-centering FASTER than engage (the reverse asymmetry weaved on the road).
FOLLOW_STEER_MAX_FOLLOW=0.60caps normal-following throw; full 1.00 lock is reserved for the planned phone U-turn button.- Trace logger: /var/tmp/follow_trace.csv (all tuning from data now — the first road test left zero data).
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):
- Boot-disarm: boots DISARMED; arms only when the tag goes absent→present (cycle the tag OFF→ON). Power-on-with-tag-nearby can never drive away. (The gate is seeded correctly so the anchor-wake window can't falsely clear it — that was a real bug, fixed 7/5.)
- Tag-behind stop: a fix at/behind the front axle = throttle 0, hold straight (it used to compute throttle from |distance| only and would drive AWAY from someone behind it).
- Prox gate multiplies the follow throttle (§13); manual RC is never gated.
- ⛔ REVERSE RULE (locked): no autonomous reverse, ever, on sand — a stuck cart must stay push-able. Rear sensors are alert-only until terrain detection + rear sensing + a closed-loop current self-check exist (hard-surface-only reverse is the eventual design).
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)
| Layer | What 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 BYPASS | Webpanel 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 gates | Boot-disarm, tag-behind stop, tag-lost stop, stop bubble 250cm. |
| Deadman | drive.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-brake | 2s commanded-zero → PPM pulse dropped → VESC 15A timeout brake both halves. |
| ExecStopPost | park_neutral.sh forces servo-neutral + VESC duty-0 clamp on ANY process death. |
| VESC-side | Timeout Brake Current 15A; PID min-ERPM 5 brakes hard while moving; voltage cutoffs. |
| CH3 | Always 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 the backbone: the cart is one stable private address from anywhere.
- ALWAYS ssh user@<the cart's Tailscale address> +
tmux attach(never the LAN IP) — works from any WiFi, survives network flips. PuTTY saved session "beachcart" (keepalive 30s); phone = Termius. - wayvnc is bound to the Tailscale IP only (it was passwordless on 0.0.0.0 — anyone on home WiFi had desktop control).
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
- Manual run:
cd ~/beach_cart && PYTHONPATH=~/beach_cart python3 main.py.~/start_cart.sh= manual foreground run. To drive manually: flip RC CH3. - 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; systemcan0.service(CAN up at boot),uwb-gnd.service(anchor ground FET). - Stop cleanly: SIGINT to the python main.py process (match by exact comm; avoid kill -9 — though ExecStopPost now parks safely even then).
- 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. - 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.shsnapshots everything outside the repo (systemd units, /boot, deps) so the repo is a full restore image —RESTORE.mdis the new-Pi playbook. sudo -npasswordless 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)
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).
- TFmini flat bracket (tfmini_mc.py) — 55x21x6mm.
- TFmini 28°-down bracket (tfmini_angled_down.py) — curb/ground sensor.
- TFmini compound corner brackets (tfmini_corner_compound.py) — 28° down + 28° out, LEFT/RIGHT pair.
- Wheel/bearing spacers (wheel_spacer.py) — 90mm OD, 40mm bore, 47mm bearing pocket; INNER 25mm, OUTER 30mm w/ counterbores. High infill, pocket-face-up. Load-bearing.
- 5" screen case — 45° tilt enclosure.
- UWB tag case (tag_case_gen.py) — 52x38x20mm, press-fit lid, USB-C cutout, switch slot, thinned RF wall at the antenna end, D-ring fob loop. Assembly: boards + wiring first, battery LAST (it blocks the ports otherwise).
- Battery boxes: battery_box_50_side_gen.py (the installed one — PA6-CF under-frame side-loader for the 50Ah pack: hanging box + screw-on door + 2x slotted L-hangers, hung 7/22) + battery_box_gen.py v5 (two-bay motor-pack box) + battery_box_50_gen.py (two-piece shiplap between-rails box, superseded, kept as spare).
- sprocket_60t_gen.py — 60T 410-chain sprocket MOCK-UP (G1 bolt pattern, 250mm) — fit-check only, PETG teeth are NOT a drive part.
- Solid 2-piece wheel hub (wheel_hub_core_gen.py) — PA6-CF, the big one. Replaces the factory hub + spacer stack (which develops play under torque): bell + grooved Ø121 tire barrel + bearing boss as one solid body per half; two identical halves lock with hidden castellated fingers — print the same STL twice, rotate one 90°. Battery strap (battery_strap_50_gen.py) — rim-mounted hold-down bar for the 50Ah box, print PETG on its side.
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
- Hardened everything: carbon fiber is abrasive — it eats a standard nozzle in about 10 hours. Hardened-steel nozzle (0.6mm is the safest choice for CF — better flow, near-zero clog risk on long prints) AND hardened extruder drive gears, both, before the first meter of filament.
- Dry or die: nylon absorbs water from the air in hours. Dry spools 8–12h at 80°C before printing, keep fresh desiccant in the enclosure DURING the print, and get a dried backup spool loaded before any print longer than one spool.
- Slice for the load path: 100% infill everywhere is wasted time. Use the slicer's modifier-volume trick: a cylinder over the structural core (bolts, bearing pocket, locking joint) set to 100% infill + extra walls, with ~40% cubic infill and 6 walls in the rest. Solid where the forces live, lattice where they don't. Result: 50h / 1.3kg per hub half.
- Bed & chamber: follow the filament maker's own profile and nobody else's — brands genuinely conflict (Bambu PA6-CF wants a ~100°C+ bed, glue stick, and the lid ON; other brands want the opposite). Never raise print speeds on nylon — warp risk.
- Skip the oven anneal: tempting, but a bake risks warping precision bores and bearing seats. Bond/assemble while the part is dry, then let ambient humidity condition the nylon — it reaches peak toughness on its own over a few days.
- Fit calibration: never "fix" a tight bore by scaling the model in the slicer (shrinkage % scales EVERYTHING). Print small test coupons of the critical features first — bearing pocket, bolt holes, joint fingers — measure, and adjust the named fit constant in the part's generator script instead.
17. Key pin & config reference (full map: PINOUT.md)
- GPIO library lgpio, gpiochip 0. Star ground — VESC/servo grounds NEVER on the Pi header.
- Steering servo: GPIO13 (RP1 hw PWM chan1), signal only. VESC PPM: GPIO18 (chan2), signal only.
- RC in (BCM, via level shifter): CH1=22, CH2=27, CH3=24.
- CAN HAT: MCP2515 16MHz, interrupt GPIO23, can0 @ 500k. VESC halves at CAN 0x13 + 0x14 (volatile — re-scan after any VESC Tool session). External tools poll as 0x2B.
- I2C bus 1: TFmini mux 0x70 (sensors 0x10 behind it); SC16IS752 boards 0x48 (INT GPIO17/pin11) + 0x49 (INT GPIO12/pin32). ⛔ GPIO25/pin22 is dead — leave empty.
- UWB ground FET: GPIO16 (pin 36).
- Battery: Power Queen 24V 50Ah Bluetooth LiFePO4 (8S, 52.5Ah measured), 29.2V charger; SoC read from the pack's Bluetooth BMS. VESC cutoffs 22.4/20.0/regen 29.2. Motor 60A / Batt 20A/side / regen −20A / Timeout Brake 40A / Abs Max 120A.
- Power protection: 50A ANL main fuse (10AWG leg), SMBJ30A TVS (installed), bulk cap. Electronics OFF while charging.
- UWB: NETWORKID=BEACHCAR, anchors ANCH1(FL)/ANCH2(FR)/ANCH3(RR)/ANCH4(RL), tag TAG1. Anchor spacing 86.36 x 114.3 cm.
- Key addresses: cart Tailscale <the cart's Tailscale address> — webpanel :8080, photo drop :8090, VNC :5900 (Tailscale-bound). Own-AP mode: 10.42.0.1.
18. Open items as of 7/14
- Re-seat the Pi's thermal pad (idles 65°C; throttled at 79.6°C on 7/10) — before the trip.
- Walk-in-front re-test of the S3 corridor braking (on blocks, then in follow-me).
- Replace ANCH4's fail-unsafe 3.3V step-down with a Mini360 (last one on the cart).
- Inspect the front-right corner bracket/harness for 7/10 hill-slide damage beyond the module.
- Install the 50A ANL main pack fuse (re-sized for the 50Ah pack; not yet in).
- 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.
- Ramp reconciliation: Pi-side 0.7/s vs VESC 1.0s PPM ramp still stack.
- 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.
- Low-battery action (warning + clean poweroff above BMS cutoff) — needs the loaded discharge threshold; limp-home mode still deferred on the same data.
- Webpanel /api/resume is unauthenticated on :8080.
- 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.
- 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.
- 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)
Compute & Cooling
| Component | Model / Spec | Qty | Status | Notes |
|---|---|---|---|---|
| Single-board computer | Raspberry Pi 5, 8GB | 1 | BUILD | hostname Beachcart. (Pi #1 was killed by a charger transient — see Surge Protection.) |
| Pi case + cooling | Argon NEO 5 Aluminum, dual passive/active | 1 | BUILD | Onboard base fan died (same transient era). |
| Cooling fan (replacement) | 30mm 5V blower, Pi 5 fan-header plug | 1 | BUILD | INSTALLED 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 reader | acer USB-C dual-slot | 1 | BUILD |
Batteries — INSTALLED (motor pack: Power Queen 50Ah, 7/22)
| Component | Model / Spec | Qty | Status | Notes |
|---|---|---|---|---|
| Motor battery pack | Power Queen 24V 50Ah Bluetooth LiFePO4 (10.24 x 6.61 x 8.27 in, 1280Wh, ~23lb), 8S / 25.6V nominal | 1 | BUILD | INSTALLED 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 = 8S | 2 | RETIRED | The 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) | 2 | RETIRED | Retired 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) | 1 | BUILD | Works as-is for the Power Queen (8S). ⚠ Never run electronics while charging. |
| Charger (12V, electronics batt) | 14.6V 5A LiFePO4 | 1 | PLANNED | Picked 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 fuse | 50A ANL, high interrupt rating, 10AWG+ leg | 1 | PLANNED | Re-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) | 1 | BUILD | Printed + 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) | 1 | SPARE | Printed 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
| Component | Model / Spec | Qty | Status | Notes |
|---|---|---|---|---|
| Main buck converters | 24V/12V→5V 10A 50W waterproof | 2 | BUILD | Buck #1 = Pi + logic bus; Buck #2 = dedicated clean 5V UWB anchor bus (fault-isolated). Star ground meets HERE. |
| TVS diode | SMBJ30A (100-pc lot) | 1 (+spares) | BUILD | INSTALLED 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-pack | 4 in use | BUILD | Per-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) | 1 | BUILD | Tag 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-C | 1 (+spares) | BUILD | Always-on (B+/B−) so the tag charges switched-off. |
| Tag battery | 803040 LiPo (40x30x8) | 1 | BUILD | Wall-charge it — a dead tag reads as "all anchors OK, 0 hits". |
| Pi power lead | USB-C bare-wire DC input, 20AWG 5V 5A | 2-pack | BUILD | Pi 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) | 1 | BUILD | WIRED + 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 kit | BOJACK 1000pc, 25 values 1Ω–1MΩ | 1 | BUILD | Arrived; used for the FET gate resistors + spares. |
| Remote switch | dstfuy wireless 40A relay | 1 | BUILD | Electronics-off-while-charging discipline. |
| USB hub (old) | Yahboom 4-Port USB 3.0 | 1 | SPARE | Ran 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 hub | 1 | DEAD | Arrived, 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
| Component | Model / Spec | Qty | Status | Notes |
|---|---|---|---|---|
| LiDAR ring (obstacle) | DIYmall Benewake TFmini Plus, IP65, UART | 6 | BUILD | All 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 multiplexer | DEVMO TCA9548A (CJMCU-9548) 1-to-8 | 1 | BUILD | addr 0x70, I2C bus 1. |
| 360° LiDAR | RPLIDAR S3 dToF, 40m | 1 | BUILD | In 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 modules | REYAX RYUW122_Lite (6.5/8GHz) | ~11 bought | BUILD | 4 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. |
| Camera | Arducam 12MP Autofocus USB, HDR | 1 | BUILD | UVC → /dev/video0; streams to the phone webpanel (rotated 180 in browser CSS). |
| LiDAR (alt) | MakerFocus TF-Luna single-point | 2 | SPARE | Superseded by TFmini. |
| Camera (alt) | Raspberry Pi Camera Module 3 | 2 | SPARE | Superseded by Arducam. |
Motor Control & Drivetrain
| Component | Model / Spec | Qty | Status | Notes |
|---|---|---|---|---|
| Drive motors | Flipsky 7070 110KV BLDC, 10mm sensored | 2 | BUILD | 14 poles / 7 pole pairs; one software-inverted. 60A per side = the cart's torque ceiling (big-hill stall is an accepted boundary). |
| Motor controller | Flipsky DUET XS60 — one DUAL ESC (two 60A drive halves) | 1 | BUILD | ~$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. |
| Wheels | 16.5" (42cm) PU wheel, 3/4" bearing | 4 | BUILD | WZ1-42UB; ~$679 incl. shipping. ⚠ Tire MUST be epoxy-bonded to the hub before drivetrain work. |
| Steering servo | Happymodel Super400 Plus, PWM 300°, 24V industrial | 1 | BUILD | GPIO13 signal only; ~$148. Check linkage seating — popped its socket under hill load once. |
| Drive chain | Single-speed bike chain 1/2"×1/8" ("410") 114-link | 2 | BUILD | 5:1 reduction. |
| Sprocket (gearing option) | ESP SPR-41060G1 60T 410-chain (6:1, +20% torque) | 1 | PLANNED | Custom-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. |
| Bearings | uxcell SBPFL204-12 pillow block, 3/4" bore | 4 | BUILD | |
| Bearings | UCF204-12 square flange, 3/4" bore | 4-pack | BUILD | self-aligning |
| Steering tie rods | X AUTOHAUX M8 ball joint (final length) | 1 set | BUILD | bought 80/100/120/185/210mm — sizing iteration |
| Tie rods (extra sizes) | X AUTOHAUX M8, unused lengths | several | SPARE | |
| VESC BT module (alt) | V6 nrf51_vesc Bluetooth (Flipsky) | 1 | SPARE | Phone VESC Tool connects to the DUET directly. |
Interface & Comms
| Component | Model / Spec | Qty | Status | Notes |
|---|---|---|---|---|
| CAN interface | Waveshare 2-Ch Isolated CAN HAT (MCP2515 + SN65HVD230) | 1 | BUILD | can0 @ 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 each | 2 in use (3 bought) | BUILD | Boards 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 shifter | LONELY BINARY bidirectional 3.3V↔5V kit (27pc) | 1 | BUILD | BSS138; all 3 RC channels pass through it (raw 5V = garbage reads). |
| RC transmitter | Flysky FS-GT3C 2.4GHz 3-channel | 1 | BUILD | CH3 = manual-override switch. |
| RC receiver | Flysky GR3E / FS-GR3E | 2 | BUILD | CH1 GPIO22, CH2 GPIO27, CH3 GPIO24 (old 23/22 labels wrong). |
| Display | ELECROW 5" 800×480 HDMI | 1 | BUILD | HDMI-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 cable | Micro HDMI→HDMI 4ft + Duttek 8K Mini HDMI | 2 | BUILD | |
| Cellular hotspot | Moxee | 1 | BUILD | The 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 adapters | HJHYUL CP2102 USB→TTL 4-pin (3.3V) | 3 | SPARE | Were the anchor path pre-SC16IS752; still handy for bench AT-command work. |
| Slide switches | EGSCST SS12D00G micro SPDT (150pc) | 1 kit | BUILD | tag power switch + spares |
Wiring, Connectors & Cable Management
| Component | Model / Spec | Qty | Status |
|---|---|---|---|
| Silicone wire 12awg | ZIGPEO 50ft red/black | 1 | BUILD |
| Silicone wire 8awg | 10ft red/10ft black | 1 | BUILD |
| Silicone wire 24awg | TUOFENG 6-color + BNTECHGO spools | 2 | BUILD |
| Jumper wires | EDGELEC Dupont M-F 100cm + 50cm | 2 | BUILD |
| Pre-crimped connectors | GH/Dupont 2.54 + JST GH 1.25mm kit | 1 | BUILD |
| Power distribution | Recoil BBS25P bus bar (2×M5 + 5 screw) | 1 | BUILD |
| Terminal blocks | MILAPEAK 4/5/6-position 600V 15A (6 sets) | 1 | BUILD |
| Fuse holders | Anyongora 12awg inline (4pk) + VANTRONIK Maxi 100A | — | BUILD |
| Connectors | Amass MT60 (5pr) + Amass XT90 anti-spark (5pr) | — | BUILD |
| Cable glands | LISTENJIALE waterproof PG7–PG19 (50pc) | 1 | BUILD |
| Cable sleeving | 132ft expandable braided PET loom | 1 | BUILD |
| Heat shrink | 400pc 3:1 adhesive-lined marine grade | 1 | BUILD |
| Quick disconnects | Sherco-Auto 8awg heat-shrink (10pk) | 1 | BUILD |
| Zip-tie mounts | XHF 3/4" back-glue (100pc) | 1 | BUILD |
| USB cables | Poyiccot short USB-USB (2) + Jelly Tang USB3 ext + FEMORO micro | — | BUILD |
| Decoupling caps (UWB) | 10uF 50V low-ESR electrolytic + 0.1uF ("104") ceramic | per module | BUILD |
Structural, Frame & Fasteners
| Component | Model / Spec | Qty | Status | Notes |
|---|---|---|---|---|
| Raw aluminum stock | 1.5" square tubing / sheet / bar | bulk | BUILD | ~$800, local metal supplier |
| Steering bracket | salvaged kid's-wagon front pivot assembly | 1 | BUILD | center pivot + stub axles |
| Aluminum angle | 4"×4"×1/4" 6063 | 4 | BUILD | |
| Stainless tube | GeilSpace 3/4" OD 304 SS round (2pk) | 1 | BUILD | |
| Stainless rod | 3/4" 304 SS round rod, 48" | 1 | BUILD | rear axle stock |
| Threaded rod | Arwnnklo M6 304 SS 10" + nuts | 2 | BUILD | |
| TIG filler | MorningRo ER309L 3/32" stainless rod 2lb | 1 | BUILD | |
| Cap screws | Joamang M4 (100pc) + ATHYUTH M2 (960pc) | — | BUILD | |
| Rivet nuts | SAE pressure rivet nut tool kit (530pc) | 1 | BUILD | tool + nuts |
| Drive keys | Swpeet carbon steel key stock (140pc) | 1 | BUILD | |
| Tube end caps | Prescott 3/4" ribbed plastic (20pk) | 1 | BUILD | |
| Grommets | Vrupin rubber kit (188pc) | 1 | BUILD | |
| Thread locker | ESKONKE 648 retaining compound | 1 | BUILD | |
| Enclosures | LeMotech ABS boxes (10pc) + GITRUAX IP67 box | — | BUILD |
3D-Printed Parts (PETG, generators in 3d_models/)
| Part | Generator | Status |
|---|---|---|
| TFmini brackets (flat / 28°-down / compound corners L+R) | tfmini_mc / tfmini_angled_down / tfmini_corner_compound | BUILD |
| Wheel/bearing spacers (INNER 25mm, OUTER 30mm) | wheel_spacer.py | BUILD (load-bearing — the torque spec) |
| 5" screen case, 45° tilt | — | BUILD |
| UWB tag enclosure (press-fit lid, fob loop) | tag_case_gen.py | BUILD |
| Motor-pack battery box (two-bay) | battery_box_gen.py v5 | BUILD |
| 50Ah battery box (two-piece shiplap) | battery_box_50_gen.py | BUILD (awaiting the pack) |
| 60T sprocket MOCK-UP (fit-check only, not a drive part) | sprocket_60t_gen.py | BUILD |
Tools (reusable — not part of cart value)
| Tool | Model | Status |
|---|---|---|
| Soldering gun | Weller D550PK 260W/200W kit | TOOL |
| Solder | MAIYUM 63-37 rosin core 0.8mm 100g | TOOL |
| 3D printer | Bambu Lab P1S | TOOL |