PROJECT 002 / EMBEDDED SYSTEMS LAB
OrbitScan.See the echoes.
I’m taking a distance sensor beyond a single number. This project turns a servo sweep into a map—and makes signal quality part of the story.
Where is the object?How much do I trust
the reading?
I break the problem into five layers: motion, echo timing, filtering, feedback, and a map I can inspect.
30°–150° ARC / 31 ANGLES / 3 ECHOES PER ANGLE
TRY THE IDEA / LOCAL WORKBENCH
A map that shows
what it knows.
I use synthetic echoes here to explain the signal pipeline. Change the scene, add noise, or step through one angle.
Copy the text into a plain-text .csv file with the header intact. The label above identifies synthetic data.
Synthetic scene loaded.
0 rows · dots = accepted ranges · × = unknown · centimetersThe viewer highlights the nearest range in the latest map; the hardware indicator responds to the current angle and adds a 5 cm release margin. The simulation is accelerated. It generates idealized rays with synthetic noise; real ultrasonic beams are broad and reflections depend on target material and angle. Imported CSV stays in this browser. Nothing is uploaded, and no board connects automatically. Each dot is the latest sample for that angle, so a sweep is not a simultaneous snapshot.
MY APPROACH
More than a moving sensor.
I use the Mega 2560 to coordinate the sensor, servo, buttons, display, and serial output. The useful part is the pipeline: each layer makes a clear decision about timing, validity, and what the user should see.
Keep the loop responsive.
I capture Echo edges on D18 with an interrupt. The loop schedules motion and a 26 ms timeout. A 10 μs trigger pulse is the only deliberate microsecond wait; there is no blocking pulseIn().
Accept a useful reading.
I collect three echoes per angle, reject values outside 2–200 cm, and require at least two valid samples. Three use the median; two use their rounded mean. I expose sample count and spread instead of hiding uncertainty.
Unknown means unknown.
An invalid reading becomes −1 in the CSV and an unknown marker in the map. The near indicator has a 5 cm release margin. Filtering rejects isolated spikes; it cannot correct a consistent reflection error.
The conversion assumes approximately 343 m/s sound speed. Temperature, target geometry, servo backlash, and mounting alignment affect the result. I keep the software operating window at 2–200 cm and compare readings with a ruler rather than quoting the module’s best-case accuracy.
PIN-BY-PIN / MEGA 2560
One clear connection map.
I use the kit’s Mega, HC-SR04, micro servo, parallel LCD1602, 10 kΩ potentiometer, RGB LED, two buttons, resistors, breadboard, and jumpers. I also need a secure lightweight sensor bracket and a separate regulated 5 V servo supply rated for the actual servo’s startup/stall current; 1 A is a reasonable starting supply rating for one unloaded SG90-class servo. These extra mounting and power items may be outside the kit.
| Part / signal | Connection | Detail |
|---|---|---|
| HC-SR04 VCC / GND | Mega 5V / GND | Use the kit’s 5 V module; check printed pin labels. |
| HC-SR04 Trig / Echo | D22 / D18 | D18 supports an external interrupt on Mega 2560. Do not use Serial1 at the same time. |
| Servo signal | D9 | Verify signal, positive and ground from the actual servo documentation. |
| Servo power / ground | External regulated 5V / common GND | External +5V goes only to the servo. Grounds join; positive supplies do not. |
| Pause button | D24 → switch → GND | INPUT_PULLUP; tap to pause/resume. |
| Near-zone button | D25 → switch → GND | Cycles 40 → 60 → 20 cm. |
| RGB R / G / B | D6 / D7 / D8, each through 220 Ω | Common cathode → GND. Confirm leg order. Set COMMON_ANODE=true and common → Mega 5V only for that confirmed type. |
Parallel LCD1602
| LCD pin | Connection | Purpose |
|---|---|---|
| 1 VSS / 2 VDD | GND / Mega 5V | Logic supply |
| 3 VO | 10 kΩ potentiometer wiper | Pot outer legs → Mega 5V and GND |
| 4 RS / 5 RW / 6 E | D12 / GND / D11 | Matches LiquidCrystal(12,11,5,4,3,2) |
| 7–10 D0–D3 | Unconnected | 4-bit interface |
| 11 D4 / 12 D5 / 13 D6 / 14 D7 | D5 / D4 / D3 / D2 | LCD pin names differ from Mega pin numbers |
| 15 LED+ / 16 LED− | Mega 5V through 220 Ω / GND | Backlight polarity and current limiting |
I use the pin tables as the connection source of truth. The LCD wiring differs from LumaDesk, so I rebuild the connections rather than swapping sketches on the old circuit. I check breadboard rail continuity and button internal pairs before powering up.
MY BUILD SEQUENCE
Prove each layer.
Then connect the system.
01 / Bring up the board and display
I select Arduino Mega or Mega 2560 / ATmega2560 in Arduino IDE, confirm the port with Blink, and wire the LCD with power disconnected. I install the official Servo and LiquidCrystal libraries through Library Manager. I extract the ZIP and keep OrbitScan.ino and ScanCore.h together in the OrbitScan folder.
02 / Check motion without a load
I keep the servo horn and sensor detached, connect the separate 5 V supply and common ground, and upload OrbitScan. The display greets “Hello, Ori!” before scanning. I send c over serial to center the servo at 90° and pause. I disconnect both supplies and attach a lightweight sensor mount facing straight ahead without rotating the shaft. I verify that the 30°–150° arc clears the base and wiring. The code cannot sense a mechanical stall.
03 / Read a stationary target
I wire the HC-SR04, place a broad flat target upright, and open Serial Monitor at 115200 baud. I wait for the head to point at the target and compare the corresponding angle’s distance with a ruler. A soft or tilted surface can return no echo; I check with a flat target before changing the conversion.
04 / Check the interface
I tap Pause to hold the last angle and resume with a fresh three-sample group. The second button cycles the near-zone threshold. Red means a current valid range is inside the zone, green means valid and outside, magenta means unknown, and blue means paused. I can also send p to pause or n to change the threshold over serial. The c command centers the head at 90° and pauses; resume returns it to the scan position after a 500 ms settle.
05 / Inspect a complete sweep
I copy serial rows into a plain-text CSV with the exact header below, then use Import scan CSV in this page. The map shows the latest value per angle. I save a separate log for each target arrangement and record the target positions, surface, power setup, and sensor alignment.
COMPLETE SOURCE / SMALL TESTABLE CORE
The firmware behind the map.
I separate pure logic in ScanCore.h from hardware I/O in the sketch. The core covers pulse conversion, validity checks, three-sample aggregation, bounded sweep reversal, button debounce, and near-zone hysteresis. Atomic sections protect shared interrupt values on the 8-bit Mega.
Telemetry contract
ms,sweep,angle_deg,distance_cm,valid_samples,spread_cm
ms is device uptime, sweep counts direction reversals, and angle_deg is the commanded servo angle. distance_cm=-1 means unknown. Spread is the largest minus smallest accepted sample; it is zero when fewer than two are valid. The firmware spaces triggers by at least 65 ms and waits 65 ms after each 4° move, with an initial 500 ms settle. Logs go over USB; the board does not store a session after power-off.
Read the complete Arduino sketch
// OrbitScan — Mega 2560 ultrasonic mapping lab. Project guide: /projects/orbitscan
// Install official Servo and LiquidCrystal libraries. Keep ScanCore.h in this folder.
#include <Servo.h>
#include <LiquidCrystal.h>
#include <util/atomic.h>
#include "ScanCore.h"
const uint8_t TRIG=22,ECHO=18,SERVO=9,PAUSE_BUTTON=24,THRESHOLD_BUTTON=25;
const uint8_t RED=6,GREEN=7,BLUE=8;
const bool COMMON_ANODE=false;
LiquidCrystal lcd(12,11,5,4,3,2);
Servo head;
Scan::Sweep sweep;
Scan::Button pauseButton,thresholdButton;
Scan::Proximity proximity;
volatile bool armed=false,riseSeen=false,echoReady=false;
volatile uint32_t riseUs=0,pulseUs=0;
bool running=true,waiting=false;
uint32_t sentUs=0,lastShotMs=0,movedMs=0,settleMs=500;
uint16_t raw[3]={0,0,0},threshold=40;
uint8_t sample=0;
enum Stage {SETTLE,SAMPLE};Stage stage=SETTLE;
uint16_t mapCm[Scan::BINS]={0}; // 0 means unknown; never free space
void echoChanged(){
if(!armed)return;
uint32_t now=micros();
if(digitalRead(ECHO)){riseUs=now;riseSeen=true;}
else if(riseSeen){pulseUs=uint32_t(now-riseUs);echoReady=true;armed=false;}
}
void disarm(){ATOMIC_BLOCK(ATOMIC_RESTORESTATE){armed=false;echoReady=false;riseSeen=false;}waiting=false;}
void rgb(bool r,bool g,bool b){
digitalWrite(RED,r^COMMON_ANODE);digitalWrite(GREEN,g^COMMON_ANODE);digitalWrite(BLUE,b^COMMON_ANODE);
}
void displayResult(Scan::Filtered f){
lcd.setCursor(0,0);lcd.print(F(" "));lcd.setCursor(0,0);
lcd.print(sweep.angle());lcd.print(F("deg "));
if(f.cm){lcd.print(f.cm);lcd.print(F("cm"));}else lcd.print(F("NO ECHO"));
lcd.setCursor(0,1);lcd.print(F(" "));lcd.setCursor(0,1);
lcd.print(F("T"));lcd.print(threshold);lcd.print(F(" V"));lcd.print(f.valid);lcd.print(F("/3 "));
lcd.print(!f.cm?F("UNKNOWN"):proximity.near?F("NEAR"):F("SCAN"));
if(!f.cm)rgb(true,false,true);else if(proximity.near)rgb(true,false,false);else rgb(false,true,false);
}
void finishBin(uint32_t now){
Scan::Filtered f=Scan::filter(raw);mapCm[sweep.bin]=f.cm;proximity.update(f.cm,threshold);
displayResult(f);
Serial.print(now);Serial.print(',');Serial.print(sweep.passes);Serial.print(',');
Serial.print(sweep.angle());Serial.print(',');
if(f.cm)Serial.print(f.cm);else Serial.print(-1);
Serial.print(',');Serial.print(f.valid);Serial.print(',');Serial.println(f.spread);
sweep.advance();head.write(sweep.angle());movedMs=now;settleMs=65;stage=SETTLE;sample=0;
}
void takeSample(uint32_t duration,uint32_t now){
waiting=false;raw[sample++]=Scan::cmFromPulse(duration);
if(sample==3)finishBin(now);
}
void togglePause(uint32_t now){
running=!running;disarm();sample=0;stage=SETTLE;movedMs=now;
if(running){head.write(sweep.angle());settleMs=500;}
if(!running){lcd.setCursor(0,1);lcd.print(F("PAUSED "));rgb(false,false,true);}
}
void setup(){
pinMode(TRIG,OUTPUT);digitalWrite(TRIG,LOW);pinMode(ECHO,INPUT);
pinMode(PAUSE_BUTTON,INPUT_PULLUP);pinMode(THRESHOLD_BUTTON,INPUT_PULLUP);
pinMode(RED,OUTPUT);pinMode(GREEN,OUTPUT);pinMode(BLUE,OUTPUT);rgb(false,false,true);
Serial.begin(115200);Serial.println(F("ms,sweep,angle_deg,distance_cm,valid_samples,spread_cm"));
lcd.begin(16,2);lcd.print(F("Hello, Ori!"));lcd.setCursor(0,1);lcd.print(F("OrbitScan / init"));
attachInterrupt(digitalPinToInterrupt(ECHO),echoChanged,CHANGE);
head.attach(SERVO);head.write(sweep.angle());movedMs=millis();lastShotMs=movedMs;
}
void loop(){
uint32_t now=millis();
if(pauseButton.update(digitalRead(PAUSE_BUTTON)==LOW,now))togglePause(now);
if(thresholdButton.update(digitalRead(THRESHOLD_BUTTON)==LOW,now))threshold=threshold==60?20:threshold+20;
while(Serial.available()){
char c=Serial.read();if(c=='p')togglePause(now);
if(c=='n')threshold=threshold==60?20:threshold+20;
if(c=='c'){
running=false;disarm();sample=0;head.write(90);rgb(false,false,true);
lcd.setCursor(0,0);lcd.print(F("CENTER 90deg "));lcd.setCursor(0,1);lcd.print(F("PAUSED "));
}
}
if(!running)return; // servo holds its last angle; never force its shaft
if(stage==SETTLE){if(!Scan::elapsed(now,movedMs,settleMs))return;stage=SAMPLE;}
if(waiting){
bool ready;uint32_t width;
ATOMIC_BLOCK(ATOMIC_RESTORESTATE){ready=echoReady;width=pulseUs;if(ready)echoReady=false;}
if(ready){takeSample(width,now);return;}
if(Scan::elapsed(micros(),sentUs,Scan::TIMEOUT_US)){disarm();takeSample(0,now);}
return;
}
if(!Scan::elapsed(now,lastShotMs,Scan::SHOT_GAP_MS))return;
// If Echo is stuck HIGH, count an invalid sample rather than manufacturing distance.
lastShotMs=now;
if(digitalRead(ECHO)==HIGH){takeSample(0,now);return;}
ATOMIC_BLOCK(ATOMIC_RESTORESTATE){echoReady=false;riseSeen=false;armed=true;}
sentUs=micros();waiting=true;
digitalWrite(TRIG,HIGH);delayMicroseconds(10);digitalWrite(TRIG,LOW);
}
The downloadable package includes this guide, the firmware, a native C++ core test, a hardware checklist, and exact software validation results. The guide and code use AI assistance; I keep the technical explanations and measurements open to review.
MY VALIDATION PLAN
Measure what the map earns.
I use the checklist to record observations rather than turning a plausible screen into a performance claim. I separate software checks from sensor measurements.
Boundaries first.
The core tests exercise invalid pulses, a single outlier, missing samples, scan reversal, timer rollover, debounce, and hysteresis. Mega compilation checks the sketch and library interfaces. Exact tool versions and results are in VALIDATION.txt.
Range & repeatability.
I compare broad targets at 20, 50, 100, and 150 cm, repeat each arrangement, and record error and spread. I test a tilted surface, missing Echo, pause/resume, and servo power stability.
Explain the compromise.
I compare raw and filtered readings, count unknown angles, and time a full pass. My project log has space for photos, real CSV captures, a fault and fix, and the next design change.
When a reading looks wrong
| Symptom | My first checks |
|---|---|
| All angles unknown | Power off and verify VCC/GND, Trig D22, Echo D18, shared ground, and the exact module labels. Retry with a broad flat target at 50 cm. |
| Servo jitters / Mega resets | Check external regulated supply rating, common ground, cable strain, and mechanical clearance. Remove power before adjusting the mount. Never force the shaft. |
| LCD backlight but no text | Adjust VO contrast. Check RW → GND, LCD data ordering, and pin 1 orientation. |
| One angle jumps between surfaces | Keep the scene still; compare raw returns, spread and target geometry. Median filtering does not narrow the acoustic beam. |
| CSV import fails | Copy only the six-column header and numeric records. Keep 30–150° in 4° steps; use −1 for unknown. Reset returns to the synthetic scene. |
I can extend this design with temperature compensation, a scan-to-scan change view, or a rigid enclosure. Each extension needs its own measurement question and test.
PRIMARY REFERENCES