Most motion sensors – PIR, ultrasonic, even basic radar – can only tell you if something moved. They’re like a doorbell: you know someone’s there, but not what they’re doing.
Enter 60GHz mmWave radar. This tiny sensor doesn’t just detect motion; it measures distance, body signal amplitude (micro‑movements from breathing/heartbeat), and even distinguishes between standing still, fidgeting, and walking.
I wanted to turn all that rich data into something visual – an LED that blinks when someone moves, and a sleek desktop GUI that shows everything in real time. And to make it look professional, I housed the electronics in a beautiful 3D printed case from JUSTWAY.
This article walks you through the build – from Arduino code to Python GUI and finally snapping it into the case.
🧠 How It Works (The Short Version)- 60GHz Radar → detects presence, movement type, distance, and body signal.
- Arduino (XIAO RP2040) → reads the radar via UART, controls a NeoPixel LED, and sends formatted data over USB Serial.
- Python GUI → reads the Serial data, displays live stats with colourful widgets, and makes the movement indicator blink when motion is detected.
- 3D Printed Case → holds everything neatly, with a window for the radar and a hole for the LED.
No sleep tracking, no cloud – just instant, local motion intelligence.🔌 Wiring Diagram
Connect everything as shown below. The XIAO RP2040 has two hardware UARTs – we use Serial1 (pins 0 & 1) for the radar, and Serial (USB) for talking to the PC.
I stripped out all sleep‑tracking code because I only care about motion. The radar gives us:
- Presence:
Someone here/No one - Movement:
Moving/Stationary/No activity - Body sign: micro‑motion amplitude (0‑100, higher = more movement)
- Distance: from 0.2m up to ~5m
The LED behavior:
- No one → dim blue
- Someone present, stationary → green
- Moving → blinking yellow (500ms on/off)
- No activity → cyan
Here’s the complete Arduino code (upload this to your XIAO):
#include <Arduino.h>
#include <60ghzbreathheart.h>
#include <Adafruit_NeoPixel.h>
#define NEOPIXEL_PIN 12
#define NEOPIXEL_PWR 11
Adafruit_NeoPixel rgb(1, NEOPIXEL_PIN, NEO_GRB + NEO_KHZ800);
BreathHeart_60GHz radar = BreathHeart_60GHz(&Serial1);
// Motion data
String presenceStatus = "Unknown";
String movementStatus = "Unknown";
float bodysignVal = 0;
float distanceVal = 0;
// Blinking control
unsigned long lastBlinkTime = 0;
const unsigned long blinkInterval = 500;
bool ledState = false;
bool isBlinking = false;
int blinkR = 0, blinkG = 0, blinkB = 0;
void setRGB(uint8_t r, uint8_t g, uint8_t b) {
rgb.setPixelColor(0, rgb.Color(r, g, b));
rgb.show();
}
void updateRGB() {
if (presenceStatus == "No one") {
setRGB(0, 0, 30);
isBlinking = false;
}
else if (movementStatus == "Moving") {
isBlinking = true;
blinkR = 80; blinkG = 80; blinkB = 0;
}
else if (movementStatus == "Stationary") {
setRGB(0, 60, 0);
isBlinking = false;
}
else if (movementStatus == "No activity") {
setRGB(0, 30, 30);
isBlinking = false;
}
else if (presenceStatus == "Someone here") {
setRGB(0, 30, 0);
isBlinking = false;
}
else {
setRGB(10, 10, 10);
isBlinking = false;
}
}
void handleBlinking() {
if (!isBlinking) return;
unsigned long now = millis();
if (now - lastBlinkTime >= blinkInterval) {
lastBlinkTime = now;
ledState = !ledState;
if (ledState) setRGB(blinkR, blinkG, blinkB);
else setRGB(0, 0, 0);
}
}
void setup() {
Serial.begin(115200);
Serial1.begin(115200);
pinMode(NEOPIXEL_PWR, OUTPUT);
digitalWrite(NEOPIXEL_PWR, HIGH);
rgb.begin();
rgb.setBrightness(80);
setRGB(10, 10, 10);
while (!Serial);
Serial.println("=== 60GHz Radar — Motion Only ===");
}
void loop() {
radar.recvRadarBytes();
radar.HumanExis_Func();
if (radar.sensor_report != 0x00) {
switch (radar.sensor_report) {
case NOONE:
presenceStatus = "No one";
movementStatus = "---";
break;
case SOMEONE:
presenceStatus = "Someone here";
break;
case NONEPSE:
movementStatus = "No activity";
break;
case STATION:
movementStatus = "Stationary";
break;
case MOVE:
movementStatus = "Moving";
break;
case BODYVAL:
bodysignVal = radar.bodysign_val;
break;
case DISVAL:
distanceVal = radar.distance;
break;
}
}
updateRGB();
handleBlinking();
static unsigned long lastPrint = 0;
if (millis() - lastPrint > 500) {
lastPrint = millis();
Serial.println("======== MOTION DATA ========");
Serial.print("Presence : "); Serial.println(presenceStatus);
Serial.print("Movement : "); Serial.println(movementStatus);
Serial.print("Body Sign : "); Serial.println(bodysignVal, 1);
Serial.print("Distance : "); Serial.print(distanceVal, 1); Serial.println(" m");
Serial.println("==============================");
}
}Upload tip: Select board “Seeed XIAO RP2040”, port, and hit upload. Open Serial Monitor at 115200 baud – you’ll see the data streaming.
The Arduino sends human‑readable lines. The Python script listens on the USB port, parses them, and drives a modern dark‑themed GUI built with customtkinter.
- Presence displayed as text
- Movement shown in a coloured frame that blinks red/white only when “Moving”
- Body Sign as a numeric indicator
- Distance as a circular arc (green → red) + progress bar + large text
import serial
import threading
import time
import re
import customtkinter as ctk
from customtkinter import CTk, CTkLabel, CTkFrame, CTkProgressBar
import math
# ------------------ Serial Reader Thread ------------------
class RadarReader:
def __init__(self, port, baudrate=115200, callback=None):
self.port = port
self.baudrate = baudrate
self.callback = callback
self.running = True
self.serial_connection = None
def start(self):
try:
self.serial_connection = serial.Serial(self.port, self.baudrate, timeout=1)
threading.Thread(target=self._read_loop, daemon=True).start()
except Exception as e:
if self.callback:
self.callback({"error": str(e)})
def _read_loop(self):
while self.running and self.serial_connection and self.serial_connection.is_open:
try:
line = self.serial_connection.readline().decode('utf-8', errors='ignore').strip()
if line:
if self.callback:
self.callback({"type": "raw", "value": line})
self._parse_line(line)
except Exception as e:
if self.callback:
self.callback({"error": str(e)})
break
def _parse_line(self, line):
# Example lines:
# "Presence : Someone here"
# "Movement : Moving"
# "Body Sign : 12.3"
# "Distance : 1.2 m"
# Also header lines "======== MOTION DATA ========" etc.
if ":" not in line:
return
key, _, rest = line.partition(":")
key = key.strip().lower()
rest = rest.strip()
if "presence" in key:
if self.callback:
self.callback({"type": "presence", "value": rest})
elif "movement" in key:
if self.callback:
self.callback({"type": "movement", "value": rest})
elif "body" in key:
m = re.search(r"[-+]?\d*\.?\d+", rest)
if m and self.callback:
self.callback({"type": "bodysign", "value": float(m.group())})
elif "distance" in key:
m = re.search(r"[-+]?\d*\.?\d+", rest)
if m and self.callback:
self.callback({"type": "distance", "value": float(m.group())})
def stop(self):
self.running = False
if self.serial_connection and self.serial_connection.is_open:
self.serial_connection.close()
# ------------------ GUI Application ------------------
class RadarGUI(ctk.CTk):
def __init__(self, port):
super().__init__()
self.title("60GHz Radar – Motion Visualizer")
self.geometry("800x600")
self.configure(fg_color="#1a1a2e")
# Data storage
self.presence = "Unknown"
self.movement = "Unknown"
self.bodysign = 0.0
self.distance = 0.0
self.raw_lines = [] # rolling buffer of raw serial lines
# Setup UI
self.setup_ui()
# Start serial reader
self.reader = RadarReader(port, callback=self.update_data)
self.reader.start()
# Blink animation for motion
self.blink_state = False
self.blink_after_id = None
self.schedule_blink()
# Periodic update of dynamic widgets
self.update_display()
def setup_ui(self):
# Title
title = CTkLabel(self, text="Radar Motion Monitor", font=ctk.CTkFont(size=28, weight="bold"), text_color="#00d2ff")
title.pack(pady=20)
# Main frame (two columns)
main_frame = CTkFrame(self, fg_color="transparent")
main_frame.pack(fill="both", expand=True, padx=40, pady=10)
# Left column: Status indicators
left_frame = CTkFrame(main_frame, fg_color="#16213e", corner_radius=15)
left_frame.pack(side="left", fill="both", expand=True, padx=(0,20))
CTkLabel(left_frame, text="Live Status", font=ctk.CTkFont(size=20, weight="bold"), text_color="#e94560").pack(pady=15)
# Presence
self.presence_label = CTkLabel(left_frame, text="Presence: Unknown", font=ctk.CTkFont(size=18), text_color="white")
self.presence_label.pack(pady=10)
# Movement with a coloured box
self.movement_frame = CTkFrame(left_frame, fg_color="#0f3460", corner_radius=10)
self.movement_frame.pack(pady=10, padx=20, fill="x")
self.movement_label = CTkLabel(self.movement_frame, text="Movement: Unknown", font=ctk.CTkFont(size=18))
self.movement_label.pack(pady=10)
# Body sign
self.bodysign_label = CTkLabel(left_frame, text="Body Sign: 0.0", font=ctk.CTkFont(size=18), text_color="white")
self.bodysign_label.pack(pady=10)
# Right column: Distance meter and graph placeholder
right_frame = CTkFrame(main_frame, fg_color="#16213e", corner_radius=15)
right_frame.pack(side="right", fill="both", expand=True)
CTkLabel(right_frame, text="Distance", font=ctk.CTkFont(size=20, weight="bold"), text_color="#e94560").pack(pady=15)
# Circular distance indicator (using canvas)
self.canvas = ctk.CTkCanvas(right_frame, width=200, height=200, bg="#16213e", highlightthickness=0)
self.canvas.pack(pady=10)
self.distance_text = self.canvas.create_text(100, 100, text="0.0 m", fill="white", font=("Arial", 18, "bold"))
# Progress bar style
self.distance_bar = ctk.CTkProgressBar(right_frame, width=200, height=20, corner_radius=10)
self.distance_bar.pack(pady=20)
self.distance_bar.set(0)
# Raw serial data panel
raw_frame = CTkFrame(self, fg_color="#0f0f1e", corner_radius=10)
raw_frame.pack(fill="x", padx=40, pady=(0, 5))
CTkLabel(raw_frame, text="Raw Serial Data", font=ctk.CTkFont(size=14, weight="bold"), text_color="#00d2ff").pack(anchor="w", padx=10, pady=(5, 0))
self.raw_textbox = ctk.CTkTextbox(raw_frame, height=120, font=("Consolas", 11), fg_color="#0f0f1e", text_color="#7CFC00")
self.raw_textbox.pack(fill="x", padx=10, pady=5)
# Small note
note = CTkLabel(self, text="Data from Arduino via USB Serial | Updates every ~500ms", font=ctk.CTkFont(size=12), text_color="gray")
note.pack(side="bottom", pady=10)
def update_data(self, data):
"""Called from serial thread (safe to update GUI)."""
if "error" in data:
print(f"Serial error: {data['error']}")
self.raw_lines.append(f"[ERROR] {data['error']}")
self.raw_lines = self.raw_lines[-100:]
return
typ = data["type"]
val = data["value"]
if typ == "raw":
self.raw_lines.append(val)
self.raw_lines = self.raw_lines[-100:]
return
if typ == "presence":
self.presence = val
elif typ == "movement":
self.movement = val
elif typ == "bodysign":
self.bodysign = val
elif typ == "distance":
self.distance = val
def schedule_blink(self):
"""Blink the movement indicator when movement is 'Moving'."""
if self.movement == "Moving":
# toggle blink state
self.blink_state = not self.blink_state
if self.blink_state:
self.movement_frame.configure(fg_color="#e94560")
self.movement_label.configure(text_color="white")
else:
self.movement_frame.configure(fg_color="#0f3460")
self.movement_label.configure(text_color="lightgray")
else:
# steady colour based on movement type
self.blink_state = False
if self.movement == "Stationary":
self.movement_frame.configure(fg_color="#2ecc71")
self.movement_label.configure(text_color="black")
elif self.movement == "No activity":
self.movement_frame.configure(fg_color="#f1c40f")
self.movement_label.configure(text_color="black")
else:
self.movement_frame.configure(fg_color="#0f3460")
self.movement_label.configure(text_color="white")
self.blink_after_id = self.after(300, self.schedule_blink) # blink every 300ms
def update_display(self):
"""Update labels, progress bar, canvas every 200ms."""
# Update text labels
self.presence_label.configure(text=f"Presence: {self.presence}")
self.movement_label.configure(text=f"Movement: {self.movement}")
self.bodysign_label.configure(text=f"Body Sign: {self.bodysign:.1f}")
# Distance progress (max 5 meters, adjust as needed)
max_dist = 5.0
norm = min(1.0, self.distance / max_dist)
self.distance_bar.set(norm)
# Update canvas distance text
self.canvas.itemconfig(self.distance_text, text=f"{self.distance:.2f} m")
# Optional: draw a simple arc meter on canvas
self.draw_distance_arc(norm)
# Update raw data textbox
if self.raw_lines:
content = "\n".join(self.raw_lines[-20:])
self.raw_textbox.delete("1.0", "end")
self.raw_textbox.insert("1.0", content)
self.raw_textbox.see("end")
self.after(200, self.update_display)
def draw_distance_arc(self, fraction):
"""Draw a coloured arc around the canvas to indicate distance."""
self.canvas.delete("arc")
angle = fraction * 360
# outer rectangle
x0, y0, x1, y1 = 20, 20, 180, 180
# colour from green to red
r = int(255 * fraction)
g = int(255 * (1 - fraction))
colour = f"#{r:02x}{g:02x}00"
self.canvas.create_arc(x0, y0, x1, y1, start=90, extent=-angle, fill=colour, outline=colour, tags="arc")
def on_closing(self):
self.reader.stop()
if self.blink_after_id:
self.after_cancel(self.blink_after_id)
self.destroy()
def autodetect_port():
try:
from serial.tools import list_ports
ports = list(list_ports.comports())
for p in ports:
desc = (p.description or "").lower()
if any(k in desc for k in ("arduino", "ch340", "usb serial", "cp210", "silicon labs", "wch")):
return p.device
if ports:
return ports[0].device
except Exception:
pass
return None
if __name__ == "__main__":
# Change 'COM3' to your Arduino's serial port (e.g., '/dev/ttyUSB0' on Linux)
import sys
if len(sys.argv) > 1:
PORT = sys.argv[1]
else:
PORT = autodetect_port() or "COM3"
print(f"Using port {PORT}. Pass port as argument to override.")
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
app = RadarGUI(PORT)
app.protocol("WM_DELETE_WINDOW", app.on_closing)
app.mainloop()Soldering on a breadboard is fine for testing, but a proper enclosure makes it a product. I found a fantastic 3D printed case designed specifically for the MR60BHA1 from JUSTWAY (available on Printables and Thingiverse).
Why I love this case:
- Snug fit for the radar module – no wobble
- Pre‑designed holes for the port
- Looks stealthy in matte red PLA
A quick word about JUSTWAY: The case came out flawlessly because JUSTWAY uses industrial‑grade 3D printing with tight tolerances and a smooth finish – far better than what most hobby printers can achieve.
They offer on‑demand manufacturing with fast turnaround, a wide range of materials (from basic PLA to engineering‑grade filaments), and an easy online quoting system. Whether you need one enclosure or a small production run, JUSTWAY makes it painless to get professional results. Check them out at justway.com.
The result is a clean, professional‑looking motion sensor that you can leave on your desk or mount on the wall.
📈 What Can You Do with This Data?- Presence → Turn lights on/off, start recording when a room is occupied.
- Movement type → Trigger different alerts: notify if moving at night, change LED mood when stationary.
- Body sign → Detect breathing even when still – great for health monitoring, baby/elderly safety, or sleep presence.
- Distance → Zone-based automation: play a sound only when someone walks within 1 meter.
- Extend easily → Log to CSV, send SMS/email on after‑hours motion, integrate with Home Assistant via MQTT, or add a live graph of body sign over time.
This project turns a $40 mmWave radar into a smart motion monitor that shows you exactly what's happening in a room – presence, movement type, body signal, and distance. The Arduino reads the sensor, blinks an LED when motion is detected, and sends clean data over USB. The Python GUI displays everything in real time with a modern, colorful interface. And the 3D printed case from JUSTWAY makes it look like a finished product, not a breadboard experiment.
You can use it as a desk occupancy monitor, a privacy‑friendly presence sensor for home automation, or a learning tool for radar technology. The code is open, the hardware is affordable, and the whole system runs locally – no cloud, no subscription.
Build it, tweak it, and make it blink your way. 🚀











Comments