Built-in project · Arduino Uno R3

Satellite tracker on an Arduino Uno

A stepper and a servo point a small dish at a satellite on a demo pass, from a simplified circular-orbit model, with an LCD counting down to rise and set. The whole build, an Arduino Uno and 7 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Advanced Runs in your browser. Free, and no account needed.

The parts retail for about $40. On Mokxi they're free.

Satellite tracker · Arduino Uno R3live0.000 s 0.00x
Press START to predict and track a pass
The circuit itself, running here on the simulator. Press what can be pressed; click anything else to open it in the editor.

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · Arduino Uno R3
// Satellite tracker: a stepper turns the dish in azimuth, a servo lifts it in
// elevation, and an LCD counts down to the pass.
//
//   pins 11..8   28BYJ-48 on its ULN2003 board: IN1, IN2, IN3, IN4
//   pin 7        elevation servo: 0 is the horizon, 90 straight up
//   pin 2        START button: predict and fly the next demo pass
//   pin 5        green lamp: the satellite is above the horizon
//   pin 6        buzzer: a chirp at rise and at set
//   A4, A5       16x2 LCD on its I2C backpack at 0x27
//
// # Where is the satellite?
//
// Real trackers use SGP4 with a fresh TLE (two-line element set) from a
// catalog, because a real orbit is squashed, tilted and slowly dragged down by
// the air. This sketch uses a simpler model that still shows every idea: a
// circular orbit 420 km up at 51.6 degrees inclination, a round Earth turning
// underneath, and an observer at 40 degrees north. Each update it finds the
// satellite in space, finds you in space, and turns the difference into an
// azimuth (compass bearing) and an elevation (angle above the horizon).
//
// # The demo pass
//
// Press START and the sketch sets up an orbit that passes over you a few
// minutes later, predicts when it will rise (AOS, acquisition of signal), how
// high it will get and when it will set (LOS, loss of signal), then counts
// down and tracks it. Time runs 30 times faster than real time, so a ten
// minute pass takes about twenty seconds. Each press flies a different pass,
// so press again to skip ahead to the next one.
//
// # Over the top
//
// Near the top of a high pass the azimuth swings fast, faster than a small
// stepper can follow. So for a pass above 70 degrees the tracker flips: it
// points the base the opposite way and tips the servo past vertical, which
// turns that fast swing into a gentle one.
//
// # Two jobs at once
//
// The orbit sums take a while in software floating point, and the LCD takes
// longer still: every character is six writes to its I2C backpack. A stepper
// that only moves when loop() comes round would stutter through all of that.
// So Timer1 interrupts 2000 times a second and steps the motor from there,
// and loop() only ever changes where it is heading.
//
// Point the dish north before power-up: a stepper does not know where it is.

#include <Wire.h>
#include <Servo.h>
#include <AccelStepper.h>
#include <LiquidCrystal_I2C.h>
#include <TimerOne.h>

const int PIN_START = 2;
const int PIN_LAMP = 5;
const int PIN_EL = 7;
const int PIN_BUZZER = 6;
const long STEPS_PER_TURN = 4096;   // half steps, through the 64:1 gearbox
const float WARP = 30.0;            // demo time runs this much faster

// The model, in kilometers, seconds and radians.
const float EARTH_KM = 6371.0;
const float ORBIT_KM = EARTH_KM + 420.0;
const float MU = 398600.4;          // Earth's gravitational parameter, km^3/s^2
const float EARTH_SPIN = 7.292116e-5;
const float INCLINATION = 51.6 * DEG_TO_RAD;
const float MY_LAT = 40.0 * DEG_TO_RAD;
const float PASS_IN = 420.0;        // seconds until the top of the pass
const float OFFSETS[] = {2.0, -4.0, 0.5, 6.0};   // how far north it passes, degrees

// AccelStepper takes the pins in the order IN1, IN3, IN2, IN4.
AccelStepper base(AccelStepper::HALF4WIRE, 11, 9, 10, 8);
Servo tilt;
LiquidCrystal_I2C lcd(0x27, 16, 2);

void stepMotor() { base.run(); }   // from the Timer1 interrupt

float meanMotion;                   // radians a second around the orbit
float raan, u0;                     // where this pass's orbit sits
float aosT, losT, maxEl, maxT;
bool flip = false;
int passNumber = 0;
enum Mode { READY, COUNTDOWN, TRACKING, DONE } mode = READY;
unsigned long startedAt = 0;
unsigned long shownAt = 0;
bool wasPressed = false;

// --- the orbit ------------------------------------------------------------------

void setUpPass(float offsetDeg) {
  // Put the ground track over latitude MY_LAT + offset, on our meridian, at
  // PASS_IN seconds from now.
  float lat = MY_LAT + offsetDeg * DEG_TO_RAD;
  float up = asin(sin(lat) / sin(INCLINATION));
  float alpha = atan2(cos(INCLINATION) * sin(up), cos(up));
  raan = EARTH_SPIN * PASS_IN - alpha;
  u0 = up - meanMotion * PASS_IN;
}

// Azimuth and elevation in degrees, t seconds after START.
void lookAngles(float t, float &az, float &el) {
  float u = u0 + meanMotion * t;
  float cu = cos(u), su = sin(u), cr = cos(raan), sr = sin(raan), ci = cos(INCLINATION);
  float x = ORBIT_KM * (cr * cu - sr * su * ci);
  float y = ORBIT_KM * (sr * cu + cr * su * ci);
  float z = ORBIT_KM * su * sin(INCLINATION);
  float th = EARTH_SPIN * t;                     // how far Earth has turned
  float ct = cos(th), st = sin(th), cl = cos(MY_LAT), sl = sin(MY_LAT);
  float rx = x - EARTH_KM * cl * ct;
  float ry = y - EARTH_KM * cl * st;
  float rz = z - EARTH_KM * sl;
  // Into south, east and up at the observer.
  float s = sl * ct * rx + sl * st * ry - cl * rz;
  float e = -st * rx + ct * ry;
  float up = cl * ct * rx + cl * st * ry + sl * rz;
  float range = sqrt(rx * rx + ry * ry + rz * rz);
  el = asin(up / range) * RAD_TO_DEG;
  az = atan2(e, -s) * RAD_TO_DEG;
  if (az < 0) az += 360;
}

// Step through the next twenty minutes to find rise, peak and set.
void predict() {
  aosT = -1;
  losT = -1;
  maxEl = -90;
  for (float t = 0; t < 1200; t += 20) {
    float az, el;
    lookAngles(t, az, el);
    if (el > 0 && aosT < 0) aosT = t;
    if (el > 0) losT = t;
    if (el > maxEl) {
      maxEl = el;
      maxT = t;
    }
  }
  flip = maxEl > 70;
}

// --- pointing ---------------------------------------------------------------------

void point(float az, float el) {
  if (flip) {
    az += 180;
    if (az >= 360) az -= 360;
    el = 180 - el;
  }
  // Take the short way round from wherever the base is now.
  // The interrupt is stepping the motor, so hold it off while the target moves.
  noInterrupts();
  long now = base.currentPosition();
  long want = (long)(az * STEPS_PER_TURN / 360);
  long turns = (now - want + STEPS_PER_TURN / 2) / STEPS_PER_TURN;
  if (now - want + STEPS_PER_TURN / 2 < 0) turns--;
  base.moveTo(want + turns * STEPS_PER_TURN);
  interrupts();
  tilt.write((int)constrain(el, 0, 180));
}

void chirp() {
  digitalWrite(PIN_BUZZER, HIGH);
  delay(80);
  digitalWrite(PIN_BUZZER, LOW);
}

void printClock(float seconds) {
  if (seconds < 0) seconds = 0;
  int m = (int)seconds / 60, s = (int)seconds % 60;
  if (m < 10) lcd.print('0');
  lcd.print(m);
  lcd.print(':');
  if (s < 10) lcd.print('0');
  lcd.print(s);
}

void show(float az, float el, float t) {
  lcd.setCursor(0, 0);
  lcd.print("AZ");
  if (az < 100) lcd.print(' ');
  if (az < 10) lcd.print(' ');
  lcd.print((int)az);
  lcd.print(" EL");
  if (el < 10 && el >= 0) lcd.print(' ');
  lcd.print((int)el);
  lcd.print(flip ? "  FLIP" : "      ");
  lcd.setCursor(0, 1);
  if (mode == COUNTDOWN) {
    lcd.print("AOS in ");
    printClock(aosT - t);
  } else if (mode == TRACKING) {
    lcd.print("LOS in ");
    printClock(losT - t);
  } else {
    lcd.print("DONE max ");
    lcd.print((int)maxEl);
  }
  lcd.print(" x30 ");
}

// --- setup and loop ---------------------------------------------------------------

void setup() {
  pinMode(PIN_START, INPUT_PULLUP);
  pinMode(PIN_LAMP, OUTPUT);
  pinMode(PIN_BUZZER, OUTPUT);
  Serial.begin(115200);
  meanMotion = sqrt(MU / (ORBIT_KM * ORBIT_KM * ORBIT_KM));
  base.setMaxSpeed(800);
  base.setAcceleration(1600);
  Timer1.initialize(500);            // every 500 microseconds
  Timer1.attachInterrupt(stepMotor);
  tilt.attach(PIN_EL);
  tilt.write(0);
  lcd.init();
  lcd.backlight();
  lcd.print("SAT TRACKER");
  lcd.setCursor(0, 1);
  lcd.print("press START");
  Serial.print("Tracker ready. Orbit period ");
  Serial.print((int)(2 * PI / meanMotion / 60));
  Serial.println(" min. Press START.");
}

void loop() {
  tilt.refresh();

  bool pressed = digitalRead(PIN_START) == LOW;
  // START always predicts the next pass, even part way through this one.
  if (pressed && !wasPressed) {
    digitalWrite(PIN_LAMP, LOW);
    setUpPass(OFFSETS[passNumber % 4]);
    passNumber++;
    predict();
    Serial.print("PASS ");
    Serial.print(passNumber);
    Serial.print(": AOS in ");
    Serial.print((int)aosT);
    Serial.print(" s, max elevation ");
    Serial.print((int)maxEl);
    Serial.print(" deg, LOS at ");
    Serial.print((int)losT);
    Serial.println(" s");
    if (flip) Serial.println("High pass: FLIP mode, the servo goes past vertical");
    lcd.clear();
    mode = COUNTDOWN;
    startedAt = millis();
    float az, el;
    lookAngles(aosT, az, el);
    point(az, 0);   // be waiting where it will rise
  }
  wasPressed = pressed;
  if (mode == READY) return;

  float t = (millis() - startedAt) / 1000.0 * WARP;
  if (millis() - shownAt < 200) return;
  shownAt = millis();

  float az, el;
  lookAngles(t, az, el);
  if (mode == COUNTDOWN && t >= aosT) {
    mode = TRACKING;
    digitalWrite(PIN_LAMP, HIGH);
    Serial.println("AOS: tracking");
    chirp();
  }
  if (mode == TRACKING) {
    point(az, el < 0 ? 0 : el);
    if (t > losT) {
      mode = DONE;
      digitalWrite(PIN_LAMP, LOW);
      Serial.print("LOS: pass done, max elevation ");
      Serial.println((int)maxEl);
      chirp();
      point(0, 0);   // park facing north
    }
  }
  show(az, el, t);
  static float printedT = -100;
  if (mode == TRACKING && t - printedT >= 30) {
    printedT = t;
    Serial.print("az ");
    Serial.print((int)az);
    Serial.print(" el ");
    Serial.println((int)el);
  }
}

Parts list

10 parts, plus the jumper wires. They retail for about $40; on Mokxi they're free.

How it is wired

15 connections, pin by pin, read from the circuit itself. Each line is one set of pins joined together, by a jumper wire or a breadboard strip.

  • Arduino Uno R3 pin 11; Stepper motor, 28BYJ-48 with ULN2003 pin IN1
  • Arduino Uno R3 pin 10; Stepper motor, 28BYJ-48 with ULN2003 pin IN2
  • Arduino Uno R3 pin 9; Stepper motor, 28BYJ-48 with ULN2003 pin IN3
  • Arduino Uno R3 pin 8; Stepper motor, 28BYJ-48 with ULN2003 pin IN4
  • Arduino Uno R3 pin 7; Hobby servo pin PWM
  • Arduino Uno R3 pin 6; Buzzer pin 1
  • Arduino Uno R3 pin 5; Resistor, 220 Ω pin 1
  • Arduino Uno R3 pin 2; Pushbutton pin 1a
  • Arduino Uno R3 pin 5V; Stepper motor, 28BYJ-48 with ULN2003 pin VCC; Character LCD with I2C backpack pin VCC
  • Ground: Arduino Uno R3 pin GND; Stepper motor, 28BYJ-48 with ULN2003 pin GND; Pushbutton pin 2a; LED, green pin C; Buzzer pin 2; Character LCD with I2C backpack pin GND
  • Arduino Uno R3 pin A4; Character LCD with I2C backpack pin SDA
  • Arduino Uno R3 pin A5; Character LCD with I2C backpack pin SCL
  • Ground: Hobby servo pin GND
  • 5 V: Hobby servo pin VCC
  • Resistor, 220 Ω pin 2; LED, green pin A

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.