// Raspberry Pi 3 · Firmware Rewrite · Python
Ground-up rewrite of the Freenove 4WD Smart Car firmware. Custom TCP client/server, real-time WASD control over WiFi, live camera streaming, motor and servo control — no frameworks, minimal dependencies.
// System Architecture
// Hardware
Freenove 4WD Smart Car chassis running a Raspberry Pi 3. The PCA9685 PWM driver sits between the Pi and all actuators, communicating over I2C and outputting independent PWM signals on 16 channels.
// Controls
WASD drives the motors. Arrow keys pan and tilt the camera.
// Software Stack
Server side runs on the Pi with zero pip installs. Client runs on the laptop with pygame for input.
Two parallel sockets — port 5000 for commands, port 5001 for the camera stream. Raw Python stdlib, no frameworks.
I2C PWM driver chip. 16 independent channels. Motors use two channels each — one forward, one backward — with 12-bit duty cycles.
Client-side input via key.get_pressed() — polls all keys simultaneously every loop iteration. No OS repeat delay.
Captures raw RGB888 frames at 640×480. Streamed over TCP with a 4-byte size header so the client knows exactly how many bytes to read.
Commands and camera streaming run in separate threads on both sides. Prevents either socket from blocking the other.
Pi filesystem mounted on the bare-metal workstation. Edit in Neovim locally, files live and execute on the car. No sync step.
// How It Works
Each subsystem is isolated into its own module. The server dispatches commands to the right module based on the string received.
Each motor uses two PCA9685 channels — one forward, one backward. Duty cycles run 0–4095 (12-bit). Setting both channels to 4095 simultaneously triggers an active brake. Turning is skid-steer — one side drives, the other stops — the same mechanism used in tanks.
Driven at 50Hz via PCA9685. Pulse width maps to angle — 500μs to 2500μs covers 0–180°. Pan and tilt are incremental — each keypress adjusts the stored pulse by a fixed offset, so the camera holds position when the key is released.
Pi captures RGB888 frames via picamera2. Each frame is prefixed with a 4-byte big-endian size header before sending. Client reads the size first, then reads exactly that many bytes — reassembles the numpy array and blits it to the pygame surface.
Commands are plain UTF-8 strings — move_forward, tilt_up, stop etc. TCP_NODELAY is set on both sockets to disable Nagle's algorithm, ensuring each 12-byte command is sent immediately without buffering.
// Reflection
Things that weren't obvious until I built it.
Nagle's algorithm buffers small packets to improve throughput — great for file transfers, terrible for a 12-byte keypress command every 30ms. Disabling it with TCP_NODELAY was the difference between laggy and responsive.
Python's curses library relies on OS key repeat — there's a built-in delay before a held key starts repeating. For motor control this caused a stutter on first press. pygame's key.get_pressed() polls every key simultaneously on every frame, eliminating it entirely.
The motor driver uses one channel for forward and one for backward. Setting both to full duty simultaneously creates an active brake rather than a coast-to-stop. Understanding this was key to getting clean directional control.
The PCA9685 driver is dictated entirely by the chip's datasheet — register addresses, I2C protocol, bit manipulation. Reading and understanding it was more valuable than rewriting it from scratch.
Camera streaming and command handling need to run concurrently. A single-threaded server would block on recv() while the camera starved. Two threads, two sockets, clean separation.