Tristan Graham
Published © MIT

ASTRA -Autonomous Stellar Transit & Reconnaissance Assistant

ASTRA points at the sky and tells you what you're looking at — no phone needed.

IntermediateWork in progress15 hours39
ASTRA -Autonomous Stellar Transit & Reconnaissance Assistant

Things used in this project

Hardware components

Arduino UNO Q
Arduino UNO Q
×1
Adafruit Ultimate GPS Breakout
Adafruit Ultimate GPS Breakout
×1
SparkFun Atmospheric Sensor Breakout - BME280
SparkFun Electronics SparkFun Atmospheric Sensor Breakout - BME280
×1
SparkFun Haptic Motor Driver - DRV2605L
SparkFun Electronics SparkFun Haptic Motor Driver - DRV2605L
×1
Step-Up Voltage Regulator - 5V
SparkFun Electronics Step-Up Voltage Regulator - 5V
×1
1.28" LCD Display
×1
Adafruit RFM95W
×1
915 Mhz LoRa Antenna
×1
BNO055 9-DGOF IMU
×1
LTR-390 UV + ALS Sensor
×1
EC11 Rotary Encoder Module
DFRobot EC11 Rotary Encoder Module
×1
B3F Tactile Pushbutton
×1
Coin Vibration Motor
×1
Tp4056 USB-C Li-Po Charger Module
×1
3.7v ~2000mAh Li-po Battery
×1
Vixen-style finder shoe adapter (3d printed)
×1
USB Cable, USB Type C Plug
USB Cable, USB Type C Plug
×1

Software apps and online services

Fusion
Autodesk Fusion
Arduino IDE
Arduino IDE
Arduino App Lab
Zephyr RTOS
Zephyr Project Zephyr RTOS
Github
PCBWay

Hand tools and fabrication machines

Materia 101
Arduino Materia 101
10 Pc. Jumper Wire Kit, 5 cm Long
10 Pc. Jumper Wire Kit, 5 cm Long
Soldering iron (generic)
Soldering iron (generic)
Tape, Electrical
Tape, Electrical

Story

Read more

Custom parts and enclosures

ASTRA

Schematics

ASTRA - Schematic

Code

ASTRA_firmware.ino

Arduino
/*
  ASTRA  Autonomous Stellar Transit Reconnaissance Assistant
  MCU firmware  (STM32U585 / Arduino core, Arduino UNO Q)
  ===========================================================================
  Handheld sky-pointing instrument. This is the real-time half of the device:
  it reads the IMU / GPS / controls, renders the red-on-black readout, runs the
  haptic "hot / cold" lock feedback, and handles the LoRa share-a-target link.

  The astronomy (where is Saturn right now, for this lat/lon and this second?)
  is computed on the Linux side with Skyfield and handed to us over the bridge
  as a plain target azimuth + elevation. The MCU never does ephemeris math  it
  just chases whatever az/el it's told to. See the SkyBridge section.

  Board      Arduino UNO Q (ABX00162), STM32U585 M33 core
  Toolchain  Arduino App Lab
  Author     Tristan Graham
  License    MIT for my code; third-party libraries keep their own licenses.

  Status
    Reference implementation / work in progress. Written against the real
    libraries and the pin map from the wiring diagram. Not yet assembled or run
    on hardware as a whole.

  Planned build order
    1  BNO055 + GC9A01 bring-up, single hard-coded target
    2  gnomonic reticle projection
    3  DRV2605 haptic ramp, lock on true angular separation
    4  LoRa share-a-target
    5  mode state machine, encoder scrubbing, backlight dimming
    6  bridge protocol to the Linux side

  Not done yet / known rough edges
    - SKY list mode renders but the on-device catalog is ~10 entries until the
      Linux side streams the real one.
    - BNO055 heading is off by a couple degrees until the mag calibrates. No
      UI nag for that yet, just the little cal pips.
    - PASS mode shows the countdown but doesn't draw the ground track.
    - Zero power management. Display runs flat out. Do this before quoting a
      battery life number.
    - Encoder is polled in loop(). Should be fine at human speed but will drop
      fast spins -> move to EXTI on A0/A1.
    - The mount/haptic tuning constants are placeholders. They need to be set
      against the real PCB, enclosure and LRA.

  Libraries (Arduino Library Manager)
    Adafruit BNO055, Adafruit Unified Sensor, Adafruit BusIO,
    Adafruit GFX, Adafruit GC9A01A, Adafruit DRV2605, Adafruit BME280,
    TinyGPSPlus, LoRa (sandeepmistry)

  This file is the whole sketch merged into one for the project page. In the
  repo it's split into tabs (PocketSkyTransit.ino + Controls / DisplayUI /
  Environment / Gps / Haptics / Pointing / SkyBridge / SkyLink).

  Parts of this were drafted with AI assistance (Claude) from my design and pin
  map, then reviewed and edited by me. Compiling and hardware bring-up are still
  to do.
*/

#include <Wire.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_GC9A01A.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BNO055.h>
#include <utility/imumaths.h>
#include <Adafruit_DRV2605.h>
#include <Adafruit_BME280.h>
#include <TinyGPSPlus.h>
#include <LoRa.h>

// ===========================================================================
//  CONFIG    pins, bus addresses, and the knobs I actually end up tuning
// ===========================================================================
// Anything with a "// tune" note is a starting guess to set by feel once the
// real hardware exists, not a calculated value.

// ---- Pin map (UNO Q header, Arduino aliases) -------------------------------
#define PIN_TFT_CS     9
#define PIN_TFT_DC     8
#define PIN_TFT_RST    7
#define PIN_TFT_BL     6     // backlight, PWM-capable  night-vision dimming

#define PIN_LORA_NSS   10
#define PIN_LORA_RST   5
#define PIN_LORA_DIO0  2

#define GPS_SERIAL     Serial1
#define GPS_BAUD       9600
#define PIN_GPS_PPS    4     // not used yet, wired for a future timing fix

#define PIN_ENC_A      A0
#define PIN_ENC_B      A1
#define PIN_ENC_SW     A2    // dial press = select / lock
#define PIN_KEY_BACK   A3    // recessed round key
#define PIN_KEY_MODE   3     // raised bar key

// ---- I2C addresses --------------------------------------------------------
#define ADDR_BNO055    0x28
#define ADDR_BME280    0x77
#define ADDR_DRV2605   0x5A
#define ADDR_LTR390    0x53  // UV  read on the Linux side for now

// ---- Radio --------------------------------------------------------------
#define LORA_FREQ_HZ   915000000UL
#define LORA_SYNC_WORD 0x2B          // arbitrary, both units must match
#define LORA_TX_PWR    17            // dBm

// ---- Loop scheduling (ms) ----------------------------------------------
#define IMU_PERIOD_MS      20        // 50 Hz is plenty for a handheld sight
#define HAPTIC_PERIOD_MS   25
#define DRAW_PERIOD_MS     66        // ~15 fps. The round LCD can't do much more
                                     // over SPI without tearing anyway.
#define BRIDGE_POLL_MS     100
#define GPS_STALE_MS       5000

// ---- Pointing / lock behaviour --------------------------------------------
#define LOCK_DEG          1.5f       // "on target" threshold (concept spec)
#define NEAR_DEG          12.0f      // haptics start ramping inside this
#define PROJ_PX_PER_DEG   6.0f       // reticle scale  start so ~20 deg fills the dial
#define SEP_SMOOTH        0.30f      // low-pass on separation, 0..1 (higher = snappier)

// ---- Night-vision palette (RGB565, red channel only) ---------------------
#define NV_BLACK   0x0000
#define NV_FAINT   0x1800
#define NV_DIM     0x4000
#define NV_MID     0x9000
#define NV_BRIGHT  0xF800

// ---- Backlight levels ----------------------------------------------------
#define BL_MIN     8                 // barely visible, fully dark-adapted
#define BL_MAX     220               // still not 255  full white-ish bleed at 255
#define BL_DEFAULT 90

// ---- Modes -------------------------------------------------------------
enum Mode : uint8_t {
  MODE_TARGET = 0,
  MODE_SKY,
  MODE_CONDITIONS,
  MODE_PASS,
  MODE_COMPASS,
  MODE_COUNT
};

// ===========================================================================
//  SHARED STATE
// ===========================================================================
// One struct everything reads from. The bridge and the encoder write azT/elT;
// the IMU task writes azP/elP; the pointing task derives sepDeg + the reticle
// offset. Not guarded by anything  single core, cooperative loop, so it's
// fine as long as nobody blocks.
struct State {
  // pointing (degrees, az 0..360 from true north, el -90..90)
  float azP = 0, elP = 0;
  bool  imuOk = false;

  // target being hunted
  float azT = 0, elT = 0;
  char  targetName[24] = "--";
  bool  haveTarget = false;

  // fix
  bool   haveFix = false;
  double lat = 0, lon = 0;
  float  altM = 0;
  uint32_t gpsAgeMs = 0;

  // BNO055 calibration status, 0..3 each
  uint8_t calSys = 0, calGyro = 0, calAcc = 0, calMag = 0;

  // derived
  float sepDeg = 180.0f;               // angular separation, pointing vs target
  float reticleX = 0, reticleY = 0;    // screen-space offset, px, 0 = centre
  bool  targetBehind = false;
  bool  locked = false;

  // environment (BME280 on our I2C bus; cloud/dew come from Linux)
  float tempC = NAN, pressHpa = NAN;
  int   cloudPct = -1;
  float dewSpreadC = NAN;

  // satellite pass (from Linux)
  int32_t passInSec = -1;
  char    passName[16] = "";

  // UI
  uint8_t mode = MODE_TARGET;
  int8_t  listIdx = 0;
  uint8_t filter = 0;
  uint8_t bright = BL_DEFAULT;
  bool    dimmed = false;
} g;

// task timers
static uint32_t tImu = 0, tHaptic = 0, tDraw = 0, tBridge = 0;

// forward decls  the sections below reference each other freely
void controlsBegin();          void pollControls(uint32_t now);
void imuBegin();               void updatePointing();
void hapticsBegin();           void updateHaptics();
void uiBegin();                void drawScreen();
void skybridgeBegin();         void skybridgePoll();
void skylinkBegin();           void skylinkPoll();  void skylinkShareTarget();
void gpsBegin();               void gpsFeed();
void environmentBegin();       void environmentUpdate(uint32_t now);
int8_t pressureTrend();
float pointingHeat();
void  hapticConfirm();
void  buzzEffect(uint8_t fx);
void  applyDeclination(float declDeg);
void  skybridgeStepTarget(int8_t dir);
void  skybridgeStepPass(int8_t dir);
void  skybridgeSelectFromList(int8_t idx);
void  skybridgePushFix(double lat, double lon, float altM,
                       uint32_t dateVal, uint32_t timeVal, uint32_t sats);
void  onDial(int8_t dir, uint32_t now);
void  onDialPress(uint32_t now);
void  onBack(uint32_t now);
void  onEnterMode();
void  drawTarget();  void drawSkyList();  void drawConditions();
void  drawPass();    void drawCompass();

float wrap180(float deg);
float wrap360(float deg);
void  setBacklight(uint8_t level);
void  bumpBacklight(int8_t dir);
void  cycleMode();

// ===========================================================================
//  MAIN
// ===========================================================================
void setup() {
  Serial.begin(115200);
  // don't wait for Serial  the thing has to work with no USB attached

  pinMode(PIN_TFT_BL, OUTPUT);
  analogWrite(PIN_TFT_BL, 0);        // keep it dark through init, no flash

  Wire.begin();
  Wire.setClock(400000);
  SPI.begin();

  uiBegin();                         // splash first so a slow sensor doesn't look like a hang
  controlsBegin();
  gpsBegin();
  imuBegin();
  environmentBegin();
  hapticsBegin();
  skybridgeBegin();
  skylinkBegin();

  setBacklight(g.bright);
  Serial.println(F("[astra] boot ok"));
}

void loop() {
  const uint32_t now = millis();

  pollControls(now);
  gpsFeed();
  skylinkPoll();

  if (now - tBridge >= BRIDGE_POLL_MS) { tBridge = now; skybridgePoll(); }
  environmentUpdate(now);
  if (now - tImu    >= IMU_PERIOD_MS)  { tImu = now;    updatePointing(); }
  if (now - tHaptic >= HAPTIC_PERIOD_MS){ tHaptic = now; updateHaptics(); }
  if (now - tDraw   >= DRAW_PERIOD_MS) { tDraw = now;   drawScreen(); }
}

// small shared helpers. wrap180 shows up everywhere the moment you touch
// azimuth, so it lives here.
float wrap180(float deg) {
  while (deg > 180.0f)  deg -= 360.0f;
  while (deg < -180.0f) deg += 360.0f;
  return deg;
}

float wrap360(float deg) {
  while (deg >= 360.0f) deg -= 360.0f;
  while (deg < 0.0f)    deg += 360.0f;
  return deg;
}

void setBacklight(uint8_t level) {
  g.bright = level;
  analogWrite(PIN_TFT_BL, level);
}

// nudge brightness in steps, clamped. called on a dial + Back combo.
void bumpBacklight(int8_t dir) {
  int v = (int)g.bright + dir * 18;
  if (v < BL_MIN) v = BL_MIN;
  if (v > BL_MAX) v = BL_MAX;
  setBacklight((uint8_t)v);
}

void cycleMode() {
  g.mode = (g.mode + 1) % MODE_COUNT;
  Serial.print(F("[astra] mode -> ")); Serial.println(g.mode);
}

// ===========================================================================
//  CONTROLS    the dial (encoder + push), the Back key, the Mode key
// ===========================================================================
// Everything the user does goes through here. No screen needed to operate it 
// that's the whole point of the instrument.
//
// Gestures:
//   Mode tap            cycle the mode (TARGET -> SKY -> CONDITIONS -> PASS -> COMPASS)
//   Mode hold           share the current target over LoRa
//   Dial turn           step target / scrub list
//   Dial press          select / lock onto the current target
//   Dial hold           toggle deep dim
//   Back tap            context back
//   Back hold + dial    brightness  (so you never have to look to dim it)

// ---- encoder: cheap polled quadrature decoder ----------------------------
// TODO: this really should be pin-change interrupts. It keeps up with a normal
// hand twist but if you spin the dial fast it drops counts. Good enough to demo.
static uint8_t  encPrev = 0;
static int8_t   encAccum = 0;
static const int8_t QTAB[16] = {              // standard Gray-code transition table
   0, -1,  1,  0,
   1,  0,  0, -1,
  -1,  0,  0,  1,
   0,  1, -1,  0
};

// ---- buttons: millis debounce -----------------------------------------
struct Button {
  uint8_t  pin;
  bool     stable;        // debounced level (true = pressed, active-low)
  bool     lastRead;
  uint32_t tChange;
  uint32_t tPressed;
  bool     handledHold;
};
static Button bMode = { PIN_KEY_MODE, false, false, 0, 0, false };
static Button bBack = { PIN_KEY_BACK, false, false, 0, 0, false };
static Button bDial = { PIN_ENC_SW,   false, false, 0, 0, false };

static const uint16_t DEBOUNCE_MS = 20;
static const uint16_t HOLD_MS     = 500;

void controlsBegin() {
  pinMode(PIN_ENC_A, INPUT_PULLUP);
  pinMode(PIN_ENC_B, INPUT_PULLUP);
  pinMode(PIN_ENC_SW, INPUT_PULLUP);
  pinMode(PIN_KEY_BACK, INPUT_PULLUP);
  pinMode(PIN_KEY_MODE, INPUT_PULLUP);

  encPrev = (digitalRead(PIN_ENC_A) << 1) | digitalRead(PIN_ENC_B);
}

// returns -1 / 0 / +1 detents since last call
static int8_t readEncoder() {
  uint8_t s = (digitalRead(PIN_ENC_A) << 1) | digitalRead(PIN_ENC_B);
  int8_t step = QTAB[(encPrev << 2) | s];
  encPrev = s;
  encAccum += step;

  if (encAccum >= 4)  { encAccum = 0; return +1; }   // 4 transitions per detent on the PEC11
  if (encAccum <= -4) { encAccum = 0; return -1; }
  return 0;
}

static bool debounce(Button &b, uint32_t now) {
  bool raw = (digitalRead(b.pin) == LOW);
  if (raw != b.lastRead) { b.lastRead = raw; b.tChange = now; }
  if (now - b.tChange >= DEBOUNCE_MS && raw != b.stable) {
    b.stable = raw;
    if (b.stable) { b.tPressed = now; b.handledHold = false; }
    return true;                 // state changed
  }
  return false;
}

void pollControls(uint32_t now) {
  // -------- encoder --------
  int8_t d = readEncoder();
  if (d != 0) {
    if (bBack.stable) {           // Back held -> brightness
      bumpBacklight(d);
      bBack.handledHold = true;   // so releasing Back doesn't also fire "back"
    } else {
      onDial(d, now);
    }
  }

  // -------- Mode key --------
  // tap = cycle mode, hold = share the current target over LoRa. The bar key
  // is the easy one to find, and "hold the thing you press to change modes" is
  // a natural way to say "send this".
  if (debounce(bMode, now)) {
    if (!bMode.stable) {                    // released
      if (!bMode.handledHold && (now - bMode.tPressed) < HOLD_MS) {
        cycleMode();
        onEnterMode();
      }
    }
  }
  if (bMode.stable && !bMode.handledHold && (now - bMode.tPressed) >= HOLD_MS) {
    bMode.handledHold = true;
    skylinkShareTarget();
  }

  // -------- Back key --------
  if (debounce(bBack, now)) {
    if (!bBack.stable) {          // released
      if (!bBack.handledHold && (now - bBack.tPressed) < HOLD_MS) onBack(now);
    }
  }

  // -------- Dial press --------
  if (debounce(bDial, now)) {
    if (bDial.stable) onDialPress(now);
  }
  // long-press the dial = toggle deep dim (one-handed, eyes on the sky)
  if (bDial.stable && !bDial.handledHold && (now - bDial.tPressed) >= HOLD_MS) {
    bDial.handledHold = true;
    g.dimmed = !g.dimmed;
    setBacklight(g.dimmed ? BL_MIN : BL_DEFAULT);
    hapticConfirm();
  }
}

// ---- gesture handlers ---------------------------------------------------
void onDial(int8_t dir, uint32_t now) {
  switch (g.mode) {
    case MODE_SKY:
      g.listIdx += dir;
      if (g.listIdx < 0) g.listIdx = 0;
      // upper bound is clamped in drawSkyList() against the real list length
      break;

    case MODE_TARGET:
      skybridgeStepTarget(dir);      // step through the quick-pick targets
      hapticConfirm();
      break;

    case MODE_PASS:
      skybridgeStepPass(dir);        // scrub which upcoming pass we're looking at
      break;

    case MODE_COMPASS:
    case MODE_CONDITIONS:
    default:
      break;   // nothing to scrub here
  }
}

void onDialPress(uint32_t now) {
  if (g.mode == MODE_SKY) {
    skybridgeSelectFromList(g.listIdx);   // ask Linux for that object's az/el
    g.mode = MODE_TARGET;
    hapticConfirm();
  } else if (g.mode == MODE_TARGET && g.haveTarget) {
    // "lock" is really just a UI affordance  we're always chasing azT/elT.
    hapticConfirm();
  }
}

void onBack(uint32_t now) {
  switch (g.mode) {
    case MODE_SKY:
      g.filter = (g.filter + 1) % 5;   // Back in the list cycles the filter
      break;
    default:
      g.mode = MODE_TARGET;
      break;
  }
}

void onEnterMode() {
  if (g.mode == MODE_SKY) g.listIdx = 0;
}

// ===========================================================================
//  DISPLAY UI    round 240x240 LCD, red on black
// ===========================================================================
// The screen is a *confirmation*, not the interface  you aim with the
// notch-and-bead and feel the lock in your hand. So this is deliberately
// sparse: a reticle, a number, a couple of pips. Dumb full-redraw per frame
// at ~15fps; the round LCD over SPI isn't fast enough for anything clever and
// a sparse red UI doesn't flicker much anyway.

static Adafruit_GC9A01A tft(PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_RST);
static const int16_t CX = 120, CY = 120;   // screen centre

// on-device fallback catalog for SKY mode until Linux streams the real one.
// az/el would be meaningless here  names are what matter; the bridge supplies
// real coordinates when you select one.
struct SkyItem { const char *name; uint8_t kind; };   // kind: 0 planet 1 star 2 dso
static const SkyItem FALLBACK_LIST[] = {
  {"Saturn", 0}, {"Jupiter", 0}, {"Mars", 0}, {"Venus", 0},
  {"Vega", 1}, {"Arcturus", 1}, {"Altair", 1}, {"Polaris", 1},
  {"M31 Andromeda", 2}, {"M13 Hercules", 2},
};
static const uint8_t FALLBACK_N = sizeof(FALLBACK_LIST) / sizeof(FALLBACK_LIST[0]);
static const char *FILTERS[5] = {"ALL", "PLANETS", "STARS", "DEEP SKY", "BEST"};

void uiBegin() {
  tft.begin();
  tft.setRotation(0);
  tft.fillScreen(NV_BLACK);

  // splash  held for a beat by the slower sensors coming up behind it
  tft.setTextColor(NV_MID);
  tft.setTextSize(4);
  tft.setCursor(CX - 58, CY - 40);
  tft.print("ASTRA");
  tft.setTextSize(1);
  tft.setTextColor(NV_DIM);
  tft.setCursor(CX - 46, CY + 4);
  tft.print("stellar transit");
  tft.setCursor(CX - 30, CY + 40);
  tft.print("acquiring");
}

static uint8_t skyListLen() { return FALLBACK_N; }   // TODO: bridge list length once streaming

void drawScreen() {
  switch (g.mode) {
    case MODE_TARGET:     drawTarget();     break;
    case MODE_SKY:        drawSkyList();    break;
    case MODE_CONDITIONS: drawConditions(); break;
    case MODE_PASS:       drawPass();       break;
    case MODE_COMPASS:    drawCompass();    break;
  }
}

// ---- shared chrome ---------------------------------------------------
static void drawRim() {
  tft.drawCircle(CX, CY, 119, NV_FAINT);
}

static void drawCalPips() {
  // four little squares top-right, filled as each BNO sub-sensor calibrates
  uint8_t c[4] = { g.calSys, g.calGyro, g.calAcc, g.calMag };
  for (uint8_t i = 0; i < 4; i++) {
    uint16_t col = (c[i] >= 2) ? NV_MID : NV_FAINT;
    tft.fillRect(180 + i * 8, 24, 5, 5, col);
  }
}

static void drawFixDot() {
  bool stale = (millis() - g.gpsAgeMs) > GPS_STALE_MS;
  uint16_t col = (g.haveFix && !stale) ? NV_MID : NV_FAINT;
  tft.fillCircle(44, 28, 3, col);
}

// ---- TARGET: the main view -----------------------------------------------
static float ringPhase = 0;
void drawTarget() {
  tft.fillScreen(NV_BLACK);
  drawRim();
  drawCalPips();
  drawFixDot();

  // fixed boresight crosshair  this is where the sight is pointing
  tft.drawFastHLine(CX - 14, CY, 28, NV_DIM);
  tft.drawFastVLine(CX, CY - 14, 28, NV_DIM);
  tft.drawCircle(CX, CY, 4, NV_DIM);

  if (!g.haveTarget) {
    tft.setTextColor(NV_DIM); tft.setTextSize(1);
    tft.setCursor(CX - 40, CY + 70);
    tft.print("no target");
    return;
  }

  int16_t rx = CX + (int16_t)g.reticleX;
  int16_t ry = CY + (int16_t)g.reticleY;

  if (g.targetBehind) {
    // draw an arrow from centre toward the target instead of a dot
    float a = atan2f(g.reticleY, g.reticleX);
    int16_t ex = CX + (int16_t)(cosf(a) * 104);
    int16_t ey = CY + (int16_t)(sinf(a) * 104);
    tft.drawLine(CX, CY, ex, ey, NV_MID);
    tft.fillCircle(ex, ey, 4, NV_MID);
  } else {
    // "hot / cold" halo grows and brightens as we close in
    float heat = pointingHeat();
    if (heat > 0.02f) {
      ringPhase += 0.5f;
      int16_t rr = 10 + (int16_t)(14 * (1.0f - heat)) + (int16_t)(2 * sinf(ringPhase));
      tft.drawCircle(rx, ry, rr, heat > 0.5f ? NV_MID : NV_DIM);
    }
    tft.fillCircle(rx, ry, 4, NV_BRIGHT);
    if (g.locked) {
      tft.drawCircle(rx, ry, 9, NV_BRIGHT);
      tft.drawCircle(rx, ry, 12, NV_MID);
    }
  }

  // bottom readout bar: name + separation
  tft.setTextSize(2);
  tft.setTextColor(g.locked ? NV_BRIGHT : NV_MID);
  int16_t w = strlen(g.targetName) * 12;
  tft.setCursor(CX - w / 2, 176);
  tft.print(g.targetName);

  tft.setTextSize(1);
  tft.setTextColor(NV_DIM);
  char buf[24];
  if (g.locked) {
    strcpy(buf, "LOCKED");
  } else {
    snprintf(buf, sizeof(buf), "%.1f\xF7", g.sepDeg);   // 0xF7 = degree glyph in GFX font
  }
  tft.setCursor(CX - strlen(buf) * 3, 198);
  tft.print(buf);
}

// ---- SKY: scrollable object list ---------------------------------------
void drawSkyList() {
  tft.fillScreen(NV_BLACK);
  drawRim();

  uint8_t n = skyListLen();
  if (g.listIdx >= n) g.listIdx = n - 1;

  tft.setTextColor(NV_DIM); tft.setTextSize(1);
  tft.setCursor(CX - 30, 40);
  tft.print(FILTERS[g.filter]);

  // window of 5 rows centred on the selection
  tft.setTextSize(2);
  for (int8_t row = -2; row <= 2; row++) {
    int8_t i = g.listIdx + row;
    if (i < 0 || i >= n) continue;
    int16_t y = CY + row * 26 - 8;
    bool sel = (row == 0);
    tft.setTextColor(sel ? NV_BRIGHT : NV_DIM);
    if (sel) tft.drawRect(24, y - 5, 192, 26, NV_DIM);
    int16_t w = strlen(FALLBACK_LIST[i].name) * 12;
    tft.setCursor(CX - w / 2, y);
    tft.print(FALLBACK_LIST[i].name);
  }

  tft.setTextSize(1);
  tft.setTextColor(NV_FAINT);
  tft.setCursor(CX - 48, 206);
  tft.print("press to point");
}

// ---- CONDITIONS ---------------------------------------------------------
void drawConditions() {
  tft.fillScreen(NV_BLACK);
  drawRim();

  tft.setTextColor(NV_DIM); tft.setTextSize(1);
  tft.setCursor(CX - 24, 44); tft.print("CONDITIONS");

  char b[24];
  tft.setTextSize(2);
  tft.setTextColor(NV_MID);

  if (!isnan(g.tempC)) {
    snprintf(b, sizeof(b), "%.1f C", g.tempC);
    tft.setCursor(CX - 40, 88); tft.print(b);
  }
  if (!isnan(g.pressHpa)) {
    snprintf(b, sizeof(b), "%.0f hPa", g.pressHpa);
    tft.setCursor(CX - 52, 116); tft.print(b);
    int8_t tr = pressureTrend();
    if (tr != 0) {
      int16_t ax = CX + 60, ay = 122;
      if (tr > 0) tft.fillTriangle(ax, ay - 5, ax - 4, ay + 3, ax + 4, ay + 3, NV_MID);
      else        tft.fillTriangle(ax, ay + 5, ax - 4, ay - 3, ax + 4, ay - 3, NV_DIM);
    }
  }
  if (g.cloudPct >= 0) {
    snprintf(b, sizeof(b), "cloud %d%%", g.cloudPct);
    tft.setCursor(CX - 52, 144); tft.print(b);
  }
  if (!isnan(g.dewSpreadC)) {
    tft.setTextColor(g.dewSpreadC < 2.0f ? NV_BRIGHT : NV_DIM);
    snprintf(b, sizeof(b), "dew %.1f", g.dewSpreadC);
    tft.setCursor(CX - 36, 172); tft.print(b);
  }
}

// ---- PASS: next satellite pass ---------------------------------------
void drawPass() {
  tft.fillScreen(NV_BLACK);
  drawRim();

  tft.setTextColor(NV_DIM); tft.setTextSize(1);
  tft.setCursor(CX - 36, 50); tft.print("NEXT PASS");

  if (g.passInSec < 0) {
    tft.setTextColor(NV_FAINT);
    tft.setCursor(CX - 30, CY); tft.print("no data");
    return;
  }

  tft.setTextSize(2); tft.setTextColor(NV_BRIGHT);
  int16_t w = strlen(g.passName) * 12;
  tft.setCursor(CX - w / 2, 96); tft.print(g.passName);

  int32_t s = g.passInSec;
  char b[16];
  snprintf(b, sizeof(b), "%ld:%02ld", s / 60, s % 60);
  tft.setTextColor(NV_MID);
  tft.setCursor(CX - strlen(b) * 6, 132); tft.print(b);
}

// ---- COMPASS: plain heading, no target needed -----------------------
void drawCompass() {
  tft.fillScreen(NV_BLACK);
  drawRim();

  // rotating card  just N/E/S/W ticks and a fixed lubber line
  float h = g.azP * DEG_TO_RAD;
  const char *card[4] = { "N", "E", "S", "W" };
  for (uint8_t i = 0; i < 4; i++) {
    float a = i * HALF_PI - h - HALF_PI;   // screen up = current heading
    int16_t x = CX + (int16_t)(cosf(a) * 96);
    int16_t y = CY + (int16_t)(sinf(a) * 96);
    tft.setTextSize(2);
    tft.setTextColor(i == 0 ? NV_BRIGHT : NV_DIM);
    tft.setCursor(x - 6, y - 7);
    tft.print(card[i]);
  }
  tft.drawFastVLine(CX, CY - 108, 16, NV_MID);   // lubber line

  tft.setTextSize(3); tft.setTextColor(NV_MID);
  char b[8];
  snprintf(b, sizeof(b), "%03d", (int)g.azP);
  tft.setCursor(CX - 27, CY - 12);
  tft.print(b);

  tft.setTextSize(1); tft.setTextColor(NV_DIM);
  snprintf(b, sizeof(b), "alt %+d", (int)g.elP);
  tft.setCursor(CX - 18, CY + 20);
  tft.print(b);
}

// ===========================================================================
//  POINTING    BNO055 -> az/el, angular separation, reticle projection
// ===========================================================================
// Axis convention: the sight runs along the device +Y (the top edge, where the
// notch-and-bead is). With the BNO055 mounted flat, component side up, X to the
// right, the fused Euler output from NDOF mode gives:
//     euler.x  -> heading   (0 = magnetic north, increasing clockwise)
//     euler.y  -> pitch     (nose up positive... check the sign, see FIXME)
//     euler.z  -> roll      (unused except to warn on big tilt, later)
//
// FIXME: the pitch sign and the exact heading offset depend on how the board
// ends up seated in the enclosure. MOUNT_YAW_OFFSET / PITCH_SIGN below are
// assumptions for the expected orientation  check them against a phone compass
// and a known star, then do a proper two-point calibration once the real PCB is
// in the shell.

static Adafruit_BNO055 bno(55, ADDR_BNO055, &Wire);

static const float MOUNT_YAW_OFFSET = 0.0f;   // deg, added to heading
static const float PITCH_SIGN       = -1.0f;  // flip if BNO pitch reads nose-down positive
static float       magDeclination   = 0.0f;   // set from the GPS fix

static float sepFilt = 180.0f;                // low-pass so haptics/reticle don't chatter

void imuBegin() {
  if (!bno.begin(OPERATION_MODE_NDOF)) {
    Serial.println(F("[imu] BNO055 not found  pointing will be dead"));
    g.imuOk = false;
    return;
  }
  delay(20);
  bno.setExtCrystalUse(true);   // the Adafruit breakout has the 32k crystal
  g.imuOk = true;

  // TODO: load stored calibration offsets from flash here so we don't make the
  // user wave the thing in a figure-8 every cold start. (Adafruit bno055
  // restore_offsets example.)
  Serial.println(F("[imu] BNO055 ok, NDOF"));
}

void applyDeclination(float declDeg) {
  magDeclination = declDeg;      // written from the bridge when Linux sends WMM
}

void updatePointing() {
  if (!g.imuOk) return;

  sensors_event_t ev;
  bno.getEvent(&ev, Adafruit_BNO055::VECTOR_EULER);

  float heading = ev.orientation.x + MOUNT_YAW_OFFSET + magDeclination;
  float pitch   = ev.orientation.y * PITCH_SIGN;

  g.azP = wrap360(heading);
  g.elP = constrain(pitch, -90.0f, 90.0f);

  bno.getCalibration(&g.calSys, &g.calGyro, &g.calAcc, &g.calMag);

  // ---- separation from the target ------------------------------------
  if (!g.haveTarget) {
    g.sepDeg = 180.0f;
    g.locked = false;
    return;
  }

  const float d2r = DEG_TO_RAD;
  float az0 = g.azP * d2r, el0 = g.elP * d2r;
  float az1 = g.azT * d2r, el1 = g.elT * d2r;
  float daz = az1 - az0;

  // great-circle angle between the two look directions on the sky sphere
  float cosSep = sinf(el0) * sinf(el1) + cosf(el0) * cosf(el1) * cosf(daz);
  cosSep = constrain(cosSep, -1.0f, 1.0f);
  float sep = acosf(cosSep) * RAD_TO_DEG;

  sepFilt += SEP_SMOOTH * (sep - sepFilt);
  g.sepDeg = sepFilt;
  g.locked = (sepFilt < LOCK_DEG);

  // ---- reticle position (gnomonic / tangent-plane projection) --------
  // Project the target onto the plane tangent to the sky at the boresight.
  // x is to the sight's right, y is "up" in the field. cosc <= 0 means the
  // target is behind us  clamp to the rim and draw an arrow instead of a dot.
  float cosc = cosSep;
  g.targetBehind = (cosc <= 0.05f);

  if (g.targetBehind) {
    float ang = atan2f(cosf(el1) * sinf(daz),
                       cosf(el0) * sinf(el1) - sinf(el0) * cosf(el1) * cosf(daz));
    g.reticleX =  sinf(ang) * 120.0f;
    g.reticleY = -cosf(ang) * 120.0f;
    return;
  }

  float x = (cosf(el1) * sinf(daz)) / cosc;
  float y = (cosf(el0) * sinf(el1) - sinf(el0) * cosf(el1) * cosf(daz)) / cosc;

  // radians on the tangent plane -> pixels. screen y is down, so flip.
  g.reticleX =  (x * RAD_TO_DEG) * PROJ_PX_PER_DEG;
  g.reticleY = -(y * RAD_TO_DEG) * PROJ_PX_PER_DEG;

  // keep it on the glass
  float r = sqrtf(g.reticleX * g.reticleX + g.reticleY * g.reticleY);
  if (r > 118.0f) {
    g.reticleX *= 118.0f / r;
    g.reticleY *= 118.0f / r;
    g.targetBehind = true;   // off-screen: treat like "behind" for the arrow
  }

  // ---- debug dump (leave commented unless something's weird) ---------
  // Serial.print("azP "); Serial.print(g.azP,1);
  // Serial.print("  sep "); Serial.print(g.sepDeg,2);
  // Serial.print("  ret "); Serial.print(g.reticleX,0);
  // Serial.print(","); Serial.println(g.reticleY,0);
}

// how "hot" are we, 0 (cold) .. 1 (locked). used by the haptics + the ring.
float pointingHeat() {
  if (!g.haveTarget) return 0.0f;
  if (g.sepDeg >= NEAR_DEG) return 0.0f;
  if (g.sepDeg <= LOCK_DEG) return 1.0f;
  return 1.0f - (g.sepDeg - LOCK_DEG) / (NEAR_DEG - LOCK_DEG);
}

// ===========================================================================
//  HAPTICS    DRV2605L + coin LRA, the "hot / cold" ramp
// ===========================================================================
// As the sight closes on the target the pulses get faster and stronger; on
// lock it's a short alert and then it goes quiet so you're not buzzing forever
// while you hold the thing steady. We use the DRV2605 built-in effect ROM
// rather than real-time PWM  the closed-loop drive keeps the LRA at resonance
// without us measuring it.

static Adafruit_DRV2605 drv;
static bool     drvOk = false;
static uint32_t nextPulseAt = 0;
static bool     lockAnnounced = false;

// DRV2605 ROM effect numbers (datasheet effect list). Starting choices 
// retune against the real LRA and enclosure wall; sharp effects sound clicky.
static const uint8_t FX_TICK_SOFT = 7;    // "Soft Bump - 60%"
static const uint8_t FX_TICK_HARD = 1;    // "Strong Click - 100%"
static const uint8_t FX_LOCK      = 16;   // "1000 ms Alert"

void hapticsBegin() {
  if (!drv.begin()) {
    Serial.println(F("[haptic] DRV2605 not found"));
    return;
  }
  drv.selectLibrary(6);        // 6 = LRA library
  drv.setMode(DRV2605_MODE_INTTRIG);
  drvOk = true;
  Serial.println(F("[haptic] DRV2605 ok"));
  buzzEffect(FX_TICK_SOFT);    // tiny startup tick so you know it's alive
}

void buzzEffect(uint8_t fx) {
  if (!drvOk) return;
  drv.setWaveform(0, fx);
  drv.setWaveform(1, 0);       // terminator
  drv.go();
}

void updateHaptics() {
  if (!drvOk || !g.haveTarget) { lockAnnounced = false; return; }

  const uint32_t now = millis();
  float heat = pointingHeat();

  if (g.locked) {
    if (!lockAnnounced) {
      buzzEffect(FX_LOCK);
      lockAnnounced = true;
      nextPulseAt = now + 100000UL;     // effectively "don't pulse again"
    }
    return;
  }
  lockAnnounced = false;

  if (heat <= 0.0f) return;             // too far away to bother

  // interval sweeps from ~600ms (barely warm) down to ~90ms (almost locked).
  // non-linear on purpose so the last couple of degrees feel urgent.
  uint32_t interval = (uint32_t)(600.0f - 510.0f * (heat * heat));

  if (now >= nextPulseAt) {
    buzzEffect(heat > 0.6f ? FX_TICK_HARD : FX_TICK_SOFT);
    nextPulseAt = now + interval;
  }
}

void hapticConfirm() {
  buzzEffect(FX_TICK_HARD);
}

// ===========================================================================
//  GPS    u-blox NEO-M8N on Serial1, TinyGPS++
// ===========================================================================
// We only need position and a rough altitude here  the *time* the astronomy
// math uses comes from the Linux side (NTP when networked, GPS time relayed by
// us when it isn't). Adapted from the TinyGPS++ "DeviceExample" by Mikal Hart.

static TinyGPSPlus gps;
static uint32_t lastPushMs = 0;

void gpsBegin() {
  GPS_SERIAL.begin(GPS_BAUD);
  pinMode(PIN_GPS_PPS, INPUT);
  Serial.println(F("[gps] serial up"));
}

void gpsFeed() {
  while (GPS_SERIAL.available()) {
    gps.encode(GPS_SERIAL.read());
  }

  if (gps.location.isUpdated() && gps.location.isValid()) {
    g.lat = gps.location.lat();
    g.lon = gps.location.lng();
    g.haveFix = true;
    g.gpsAgeMs = millis();
    if (gps.altitude.isValid()) g.altM = gps.altitude.meters();
  }

  // hand the fix to Linux once a second so it can run Skyfield + look up the
  // magnetic declination for our location
  const uint32_t now = millis();
  if (g.haveFix && now - lastPushMs >= 1000) {
    lastPushMs = now;
    skybridgePushFix(g.lat, g.lon, g.altM,
                     gps.date.isValid() ? gps.date.value() : 0,
                     gps.time.isValid() ? gps.time.value() : 0,
                     gps.satellites.isValid() ? gps.satellites.value() : 0);
  }
}

// ===========================================================================
//  ENVIRONMENT    BME280, local temp / pressure + a trend arrow
// ===========================================================================
// Cloud cover and the dew forecast come from the Linux side (it has a network
// and a weather API); we just measure what's actually at the eyepiece.

static Adafruit_BME280 bme;
static bool     bmeOk = false;
static uint32_t tEnv = 0;

static float    pHist[6] = { NAN, NAN, NAN, NAN, NAN, NAN };   // ~30s spacing
static uint8_t  pHead = 0;
static uint32_t tHist = 0;

void environmentBegin() {
  if (!bme.begin(ADDR_BME280, &Wire)) {
    Serial.println(F("[env] BME280 not found"));
    return;
  }
  bme.setSampling(Adafruit_BME280::MODE_FORCED,
                  Adafruit_BME280::SAMPLING_X1,   // temp
                  Adafruit_BME280::SAMPLING_X1,   // pressure
                  Adafruit_BME280::SAMPLING_X1,   // humidity
                  Adafruit_BME280::FILTER_OFF);
  bmeOk = true;
...

This file has been truncated, please download it to see its full contents.

Credits

Tristan Graham
1 project • 0 followers
Mechanical Engineering Student - Oregon State University - Mechatronics Enthusiast

Comments