BriefThe problem
Hospitals move medication, samples and supplies between departments all day. The work is repetitive and time-sensitive, and it carries a chain-of-custody requirement: the cargo can only go to the right person.
Our brief was a vehicle that drives a corridor on its own, refuses to run into anything, and will not release its cargo without authorization.
PCB designOff the breadboard, onto a custom shield
I designed the electrical architecture as a single-layer Altium PCB shield that stacks directly onto an Arduino Uno. Every subsystem gets its own labelled header: 5 V power, RFID, ultrasonic, motor driver, Bluetooth, temperature sensor, servo and LCD, plus an onboard push button with a 10 kΩ pull-down.
Labelling every connector on the silkscreen meant the harness could only go together one way, which removed a whole class of wiring mistakes during integration.
PowerTwo rails from one battery
- One 11.1 V LiPo feeds two buck converters: a 5.1 V logic rail and a 7.9 V motor rail.
- Keeping the rails separate holds motor switching noise off the sensors and the I²C bus.
- Stable ultrasonic readings are what made the obstacle stop safe to rely on.
Control loopSensing and actuation
Five sensors and four actuator systems run in one microcontroller control loop:
- HC-SR04 ultrasonic measures range ahead and stops the robot inside 20 cm.
- RC522 RFID reader unlocks the cargo compartment only for an authorized tag.
- HC-05 Bluetooth carries commands and telemetry.
- DHT11 logs temperature and humidity inside the cargo box in transit.
- A 16×2 LCD shows status, two DC motors run through an L298N, and a servo drives the latch.
Remote controlDriving it from a PS5 controller
A Python script on a laptop reads a PS5 controller with pygame and streams drive commands to the robot over the HC-05 Bluetooth link using pyserial at 9600 baud.
- R2 is the throttle, L2 brakes and reverses, and the left stick steers, with a 10% deadzone so the robot holds a straight line.
- Throttle and steering are mixed into separate left and right motor commands from −255 to 255, sent as a
left,rightline. The controller is polled every 20 ms, and a command is only sent when it changes, which keeps the Bluetooth link quiet. - Pressing ✕ toggles autopilot. The script sends
AUTOorMANto the robot and stops sending stick input while autopilot is on. - Quitting with Ctrl+C sends
0,0first, so the motors stop instead of holding their last command.
import pygame
import serial
import time
# ── Configuration ──────────────────────────────────────────────
BLUETOOTH_PORT = 'COM5' # <--- CHANGE TO YOUR PORT
BAUD_RATE = 9600
UPDATE_RATE = 0.02
DEADZONE = 0.1
try:
bt = serial.Serial(BLUETOOTH_PORT, BAUD_RATE, timeout=1)
print("Bluetooth connected successfully!")
except Exception as e:
print(f"Error connecting to Bluetooth: {e}")
exit()
pygame.init()
pygame.joystick.init()
if pygame.joystick.get_count() == 0:
print("No controller detected.")
exit()
controller = pygame.joystick.Joystick(0)
controller.init()
print(f"Controller Connected: {controller.get_name()}")
# PS5 Controller Mappings
AXIS_STEER = 0
AXIS_L2 = 4
AXIS_R2 = 5
BTN_CROSS = 0 # The 'X' Button on PS5
last_command = ""
auto_mode = False
last_btn_state = False
print("\n--- PS5 Controls Active ---")
print("R2: Gas | L2: Brake/Reverse | L3: Steer")
print("Press 'X' to toggle Autopilot!")
try:
while True:
pygame.event.pump()
# --- AUTOPILOT TOGGLE LOGIC ---
btn_state = controller.get_button(BTN_CROSS)
if btn_state and not last_btn_state: # Button was just pushed down
auto_mode = not auto_mode # Toggle the state
if auto_mode:
bt.write(b"AUTO\n")
print("\n[!] AUTOPILOT ENGAGED [!]\n")
else:
bt.write(b"MAN\n")
print("\n[-] MANUAL CONTROL RESTORED [-]\n")
last_btn_state = btn_state
# --- MANUAL DRIVING LOGIC ---
# We only calculate and send joystick data if Autopilot is OFF
if not auto_mode:
steer_axis = controller.get_axis(AXIS_STEER)
l2_axis = controller.get_axis(AXIS_L2)
r2_axis = controller.get_axis(AXIS_R2)
if abs(steer_axis) < DEADZONE:
steer_axis = 0.0
brake = max(0.0, (l2_axis + 1.0) / 2.0)
gas = max(0.0, (r2_axis + 1.0) / 2.0)
throttle = gas - brake
left_motor = throttle + steer_axis
right_motor = throttle - steer_axis
left_motor = max(-1.0, min(1.0, left_motor))
right_motor = max(-1.0, min(1.0, right_motor))
left_pwm = int(left_motor * 255)
right_pwm = int(right_motor * 255)
command = f"{left_pwm},{right_pwm}\n"
if command != last_command:
bt.write(command.encode('utf-8'))
last_command = command
# Only print the dashboard occasionally so it doesn't flood the terminal
print(f"\rMANUAL >> L2: {brake:.2f} | R2: {gas:.2f} | Motor L: {left_pwm:4d} | Motor R: {right_pwm:4d}",
end="")
time.sleep(UPDATE_RATE)
except KeyboardInterrupt:
print("\nExiting. Stopping car...")
bt.write(b"0,0\n")
bt.close()
pygame.quit()IntegrationBuild and debug
I specified the components, drew the schematics and kept the full bill of materials. I then hand-soldered and assembled the shield, and used a multimeter to trace each wiring and integration fault back to its root cause until every subsystem test passed.
ValidationResults
FiguresBoard, wiring and build
Select any figure to open it full size.