AN-004 · Embedded & Robotics · Finished
Started Aug 2026
TeleKart: the Pi owns safety; the Mac only asks
1.0Abstract
An RC car driven from a laptop over Wi-Fi with a live camera feed. Every safety decision lives on the Pi that owns the motors, and all of v1 was built and tested against a simulator before the car was allowed to move.
At a glance
| Parameter | Value |
|---|---|
| Status | Finished |
| When and where | Aug 2026, personal project |
| Built with | Raspberry Pi with an L298N motor driver via pigpio, an HS-311 steering servo and picamera2; Python; a pygame client on a Mac |
| Code | github.com/zhaojinchu/TeleKart |
| Links | Driving intent as JSON over UDP, telemetry back the same way, MJPEG video over HTTP |
| Control loop | 100 Hz on the Pi, with a watchdog, slew limiting and an arm gate |
| Simulator | Shares the vehicle model with the real Pi, so v1 was verified before the car could move |
Scope, deliberately small
v1 does one thing: drive the car from a Mac with WASD while watching the Pi’s camera. The wheel encoders are wired and ignored: no odometry, no closed loop. A steering wheel as the input device is the goal for v2 and explicitly not part of this version.
Who computes what
MAC (client)
┌──────────────────────────────────┐
│ main.py · pygame window │
│ video pane ◀── MJPEG /stream │ HTTP :8090
│ WASD keys ──▶ cmd JSON │ UDP :8091
│ debug panel ◀── tlm JSON │ UDP
│ logs/session-*.jsonl │
└────────────────┬─────────────────┘
│ identical protocol
┌──────────┴──────────┐
▼ ▼
PI (vehicle) SIM (on the Mac)
┌────────────────────┐ ┌────────────────────┐
│ video.py picamera2 │ │ sim/fake_pi.py │
│ main.py control │ │ same protocol, │
│ @100 Hz: watchdog │ │ same │
│ · slew · arm gate │ │ common/vehicle.py │
│ car.py pigpio → │ └────────────────────┘
│ L298N · HS-311 │
└────────────────────┘
The Mac sends raw intent; the Pi computes all vehicle behavior. Ramping, deadband, duty mapping, direction guards and limits live in one place: the side that owns the hardware. The Mac never computes a PWM value.
Two things follow from that. A buggy or crashed client can’t bypass the safety logic, because the safety logic isn’t on the client. And v2 can swap key-booleans for wheel-axis floats without the Pi changing at all.
Nothing responds to throttle until you arm it (Enter). A page load or a reconnect should never be able to
make the car lurch.
The simulator
sim/fake_pi.py runs on the Mac and speaks the identical protocol with a synthetic camera. It doesn’t
approximate the Pi: it imports the same common/vehicle.py, so the failsafes tested against it are the same
code that runs on the car. That is what made it possible to build and verify all of v1 while the real Pi
was off-limits, running other software.
Debugging by design
Both ends write logs/session-<ts>.jsonl: one JSON object per line, every message, both directions, tagged
tx or rx. Two independent captures of the same conversation is what turns “it worked on the Mac but the car
didn’t move” into a five-second diagnosis:
# did the slew limiter engage?
jq 'select(.dir=="rx") | .throttle_out' logs/session-*.jsonl
# when did the link drop?
jq 'select(.state=="FAILSAFE")' logs/session-*.jsonl
The live debug panel shows state, round-trip time, throttle_cmd → throttle_out, servo microseconds, duty
percentage, link rates and drop counts. If throttle looks stuck, comparing throttle_cmd with throttle_out
tells you immediately whether it’s the slew limiter or something real.
What’s next
v2: a steering wheel as the input device. The Pi does not change for it, since all it ever receives is intent.