Breadboard
docs
Example project · Displays

Flight radar (OLED stress test)

A compute-dense radar scope running real compiled Arduino firmware on a 1.5″ 128×128 OLED. A full framebuffer would swallow the Uno's entire 2 KB of SRAM, so the sketch renders the classic embedded way — 16 raster passes per frame into a 128-byte page buffer, each stripe blitted over I²C at 400 kHz — drawing range rings, a rotating sweep and aircraft blips with trails and callsign labels. Watch REAL air traffic: the Flight feed panel opens with this example — pick an airspace and press Start feed to stream live ADS-B aircraft onto the scope. Or feed it yourself over the serial monitor: P,<id>,<x>,<y>,<vx>,<vy>,<label> places or updates an aircraft, R,<id> removes it, and between updates each one dead-reckons along its last heading.

The Flight radar (OLED stress test) circuit as rendered by the simulator

What's on the bench

  • Arduino Uno
  • Battery
  • OLED 1.5″ 128×128

How it's wired

  • Battery · posArduino Uno · 5v
  • Battery · negArduino Uno · gnd
  • OLED 1.5″ 128×128 · vccArduino Uno · 5v
  • OLED 1.5″ 128×128 · gndArduino Uno · gnd2
  • Arduino Uno · sdaOLED 1.5″ 128×128 · sda
  • Arduino Uno · sclOLED 1.5″ 128×128 · scl

The code

This Arduino C++ sketch lives in the Code tab; sign in and press Compile & upload to build real firmware for the emulated board.

// Flight radar — Arduino Uno + SH1107 1.5" 128x128 OLED (I2C).
//
// Draws a radar scope (range rings, rotating sweep, aircraft blips with
// motion trails and 3x5-font callsign labels) on a 128x128 monochrome
// OLED. A full framebuffer would be 128*128/8 = 2048 bytes — the
// ATmega328P's ENTIRE SRAM — so this renders u8g2-style in page mode:
// the scene is rasterized 16 times per frame, once per 8-row stripe,
// into a 128-byte page buffer that is blitted immediately. More compute
// per frame than a framebuffered SSD1306, in less RAM.
//
// Aircraft positions stream in over serial; between updates each target
// is dead-reckoned along its last known velocity.
//
// Serial protocol (115200 baud, newline-terminated ASCII):
//   P,<id>,<x>,<y>,<vx>,<vy>[,<label>]   upsert aircraft <id> (0..7).
//     x,y in screen pixels (0..127); vx,vy in 1/16 px per frame.
//   R,<id>                               remove aircraft <id>.
//   C                                    clear all aircraft (a live data
//     feed sends this first so the boot demo traffic vanishes).
//   L,<text>                             scope label, top-left corner
//     (<=10 chars, A-Z/0-9/space — the live feed sends the airspace's
//     airport code, e.g. LHR). "L," alone clears it.
// Output: F,<frame>,<active>,<ms per frame> — printed when the active
// count changes and as a ~7 s heartbeat, NOT every frame (that would be
// ~10 lines/s of monitor spam).
//
// Built with arduino-cli, core arduino:[email protected] (the compile-service
// pin). No libraries beyond the bundled Wire — the SH1107 is driven raw
// (page addressing, split column pointer, DC-DC via 0xAD — NOT the
// SSD1306's 0x8D) so the whole rasterization + blit path is this
// sketch's own code.

#include <Wire.h>

#define OLED_ADDR 0x3D
#define W 128
#define H 128
#define MAX_AC 8
#define TRAIL_LEN 8

// Page-mode render target: one 8-row stripe of the display.
static uint8_t pageBuf[W];
static uint8_t curPage; // stripe being rasterized, 0..15

struct Aircraft {
  bool active;
  int16_t x, y;   // position, 12.4 fixed point (pixels * 16)
  int16_t vx, vy; // velocity, 1/16 px per frame
  char label[5];
  uint8_t trail_head;
  uint8_t trail_n;
  uint8_t trail_x[TRAIL_LEN]; // past positions, whole pixels
  uint8_t trail_y[TRAIL_LEN];
};
static Aircraft ac[MAX_AC];

// ---------------------------------------------------------------- font ----
// 3x5 font, digits + uppercase, column-major, 3 bytes per glyph (bits 0..4).
static const uint8_t FONT35[] PROGMEM = {
  0x1F, 0x11, 0x1F, // 0
  0x12, 0x1F, 0x10, // 1
  0x1D, 0x15, 0x17, // 2
  0x11, 0x15, 0x1F, // 3
  0x07, 0x04, 0x1F, // 4
  0x17, 0x15, 0x1D, // 5
  0x1F, 0x15, 0x1D, // 6
  0x01, 0x01, 0x1F, // 7
  0x1F, 0x15, 0x1F, // 8
  0x17, 0x15, 0x1F, // 9
  0x1E, 0x05, 0x1E, // A
  0x1F, 0x15, 0x0A, // B
  0x0E, 0x11, 0x11, // C
  0x1F, 0x11, 0x0E, // D
  0x1F, 0x15, 0x11, // E
  0x1F, 0x05, 0x01, // F
  0x0E, 0x11, 0x1D, // G
  0x1F, 0x04, 0x1F, // H
  0x11, 0x1F, 0x11, // I
  0x08, 0x10, 0x0F, // J
  0x1F, 0x04, 0x1B, // K
  0x1F, 0x10, 0x10, // L
  0x1F, 0x02, 0x1F, // M (approx)
  0x1F, 0x01, 0x1E, // N
  0x0E, 0x11, 0x0E, // O
  0x1F, 0x05, 0x02, // P
  0x0E, 0x19, 0x1E, // Q
  0x1F, 0x05, 0x1A, // R
  0x12, 0x15, 0x09, // S
  0x01, 0x1F, 0x01, // T
  0x0F, 0x10, 0x1F, // U
  0x07, 0x18, 0x07, // V
  0x1F, 0x08, 0x1F, // W (approx)
  0x1B, 0x04, 0x1B, // X
  0x03, 0x1C, 0x03, // Y
  0x19, 0x15, 0x13, // Z
};

// 64-entry sine table over the full circle, Q7 (value = sin * 127).
static const int8_t SIN64[] PROGMEM = {
  0, 12, 25, 37, 49, 60, 71, 81, 90, 98, 106, 112, 117, 122, 125, 126,
  127, 126, 125, 122, 117, 112, 106, 98, 90, 81, 71, 60, 49, 37, 25, 12,
  0, -12, -25, -37, -49, -60, -71, -81, -90, -98, -106, -112, -117, -122,
  -125, -126, -127, -126, -125, -122, -117, -112, -106, -98, -90, -81, -71,
  -60, -49, -37, -25, -12,
};
static int8_t isin(uint8_t a) { return (int8_t)pgm_read_byte(&SIN64[a & 63]); }
static int8_t icos(uint8_t a) { return isin(a + 16); }

// ------------------------------------------------------------ raster ------
// All primitives draw through px(), which clips to the CURRENT PAGE's
// 8-row window — the same scene is replayed for every page (u8g2 page
// mode). 16-bit coordinates: screen maths on an 8-bit target overflows
// int8_t silently, and off-screen points must clip, not wrap.
static inline void px(int16_t x, int16_t y) {
  if ((uint16_t)x >= W || (uint16_t)y >= H) return;
  if (((uint8_t)y >> 3) != curPage) return;
  pageBuf[(uint8_t)x] |= (uint8_t)1 << (y & 7);
}

static void line(int16_t x0, int16_t y0, int16_t x1, int16_t y1) {
  int16_t dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
  int16_t dy = -abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
  int16_t err = dx + dy;
  for (;;) {
    px(x0, y0);
    if (x0 == x1 && y0 == y1) break;
    int16_t e2 = 2 * err;
    if (e2 >= dy) { err += dy; x0 += sx; }
    if (e2 <= dx) { err += dx; y0 += sy; }
  }
}

static void circle(int16_t cx, int16_t cy, int16_t r) {
  int16_t x = r, y = 0;
  int16_t err = 1 - r;
  while (x >= y) {
    px(cx + x, cy + y); px(cx - x, cy + y);
    px(cx + x, cy - y); px(cx - x, cy - y);
    px(cx + y, cy + x); px(cx - y, cy + x);
    px(cx + y, cy - x); px(cx - y, cy - x);
    y++;
    if (err < 0) err += 2 * y + 1;
    else { x--; err += 2 * (y - x) + 1; }
  }
}

static void glyph(int16_t x, int16_t y, char c) {
  int8_t idx;
  if (c >= '0' && c <= '9') idx = c - '0';
  else if (c >= 'A' && c <= 'Z') idx = 10 + (c - 'A');
  else return;
  for (int8_t col = 0; col < 3; col++) {
    uint8_t bits = pgm_read_byte(&FONT35[idx * 3 + col]);
    for (int8_t row = 0; row < 5; row++)
      if (bits & (1 << row)) px(x + col, y + row);
  }
}

static void text(int16_t x, int16_t y, const char *s) {
  for (; *s; s++, x += 4) glyph(x, y, *s);
}

// ------------------------------------------------------------- oled -------
static void cmd(uint8_t c) {
  Wire.beginTransmission(OLED_ADDR);
  Wire.write((uint8_t)0x00);
  Wire.write(c);
  Wire.endTransmission();
}

static void oledInit() {
  static const uint8_t INIT[] PROGMEM = {
    0xAE,       // display off
    0xDC, 0x00, // display start line 0 (SH1107: takes an argument)
    0x81, 0x2F, // contrast
    0x20,       // page addressing mode (STANDALONE on SH1107)
    0xA0,       // segment remap normal
    0xC0,       // COM scan normal
    0xA8, 0x7F, // multiplex 128
    0xD3, 0x00, // display offset
    0xD5, 0x50, // clock
    0xD9, 0x22, // precharge
    0xDB, 0x35, // VCOM detect
    0xAD, 0x8A, // DC-DC on (SH1107's charge pump — NOT the SSD1306 0x8D)
    0xA4,       // resume from RAM
    0xA6,       // normal (non-inverted)
    0xAF,       // display on
  };
  for (uint8_t i = 0; i < sizeof(INIT); i++) cmd(pgm_read_byte(&INIT[i]));
}

/** Blit the current page buffer to display page `curPage`. */
static void blitPage() {
  cmd(0xB0 | curPage); // page address
  cmd(0x00);           // column low nibble = 0
  cmd(0x10);           // column high nibble = 0
  for (uint16_t i = 0; i < W; i += 16) {
    Wire.beginTransmission(OLED_ADDR);
    Wire.write((uint8_t)0x40);
    Wire.write(&pageBuf[i], 16);
    Wire.endTransmission();
  }
}

// ------------------------------------------------------------ traffic -----
static int16_t clampi(int16_t v, int16_t lo, int16_t hi) {
  return v < lo ? lo : (v > hi ? hi : v);
}

static void upsert(uint8_t id, int16_t x, int16_t y, int16_t vx, int16_t vy,
                   const char *label) {
  if (id >= MAX_AC) return;
  Aircraft &a = ac[id];
  if (!a.active) { a.trail_head = 0; a.trail_n = 0; a.label[0] = 0; }
  a.active = true;
  a.x = clampi(x, 0, W - 1) << 4;
  a.y = clampi(y, 0, H - 1) << 4;
  a.vx = clampi(vx, -160, 160); // |v| <= 10 px/frame keeps vectors on scope
  a.vy = clampi(vy, -160, 160);
  if (label && *label) {
    uint8_t i = 0;
    for (; i < 4 && label[i]; i++) a.label[i] = label[i];
    a.label[i] = 0;
  }
}

static void seedDemo() {
  upsert(0, 30, 24, 22, 9, "BA12");
  upsert(1, 100, 110, -18, -6, "AF33");
  upsert(2, 64, 12, 4, 14, "KL7");
  upsert(3, 20, 100, 16, -12, "EZY9");
  upsert(4, 110, 40, -12, 11, "DL04");
  upsert(5, 48, 70, 14, 10, "RY88");
}

static void stepTraffic() {
  for (uint8_t i = 0; i < MAX_AC; i++) {
    Aircraft &a = ac[i];
    if (!a.active) continue;
    // record trail point (whole pixels) before moving
    a.trail_x[a.trail_head] = (uint8_t)(a.x >> 4);
    a.trail_y[a.trail_head] = (uint8_t)(a.y >> 4);
    a.trail_head = (a.trail_head + 1) & (TRAIL_LEN - 1);
    if (a.trail_n < TRAIL_LEN) a.trail_n++;
    a.x += a.vx;
    a.y += a.vy;
    // bounce off scope edges so demo traffic stays on screen
    if (a.x < 0)            { a.x = 0;            a.vx = -a.vx; }
    if (a.x > (W - 1) << 4) { a.x = (W - 1) << 4; a.vx = -a.vx; }
    if (a.y < 0)            { a.y = 0;            a.vy = -a.vy; }
    if (a.y > (H - 1) << 4) { a.y = (H - 1) << 4; a.vy = -a.vy; }
  }
}

// ------------------------------------------------------------- serial -----
static char rxLine[32];
static uint8_t rxLen = 0;
static char scopeLabel[11];

static void handleLine(char *s) {
  if (s[0] == 'C' && s[1] == 0) {
    for (uint8_t i = 0; i < MAX_AC; i++) ac[i].active = false;
    return;
  }
  if (s[0] == 'L' && s[1] == ',') {
    uint8_t i = 0;
    for (; i < sizeof(scopeLabel) - 1 && s[2 + i]; i++) scopeLabel[i] = s[2 + i];
    scopeLabel[i] = 0;
    return;
  }
  if (s[0] == 'R' && s[1] == ',') {
    uint8_t id = (uint8_t)atoi(s + 2);
    if (id < MAX_AC) ac[id].active = false;
    return;
  }
  if (s[0] != 'P' || s[1] != ',') return;
  // P,<id>,<x>,<y>,<vx>,<vy>[,<label>]
  int16_t v[5];
  char *p = s + 2;
  for (uint8_t i = 0; i < 5; i++) {
    v[i] = (int16_t)atoi(p);
    char *c = strchr(p, ',');
    if (!c) { if (i < 4) return; p = NULL; break; }
    p = c + 1;
  }
  upsert((uint8_t)v[0], v[1], v[2], v[3], v[4], p);
}

static void pollSerial() {
  while (Serial.available()) {
    char c = (char)Serial.read();
    if (c == '\n' || c == '\r') {
      if (rxLen) { rxLine[rxLen] = 0; handleLine(rxLine); rxLen = 0; }
    } else if (rxLen < sizeof(rxLine) - 1) {
      rxLine[rxLen++] = c;
    }
  }
}

// -------------------------------------------------------------- frame -----
static uint8_t sweep = 0;
static uint32_t frame = 0;
static uint32_t lastMs = 0;

/** Rasterize the whole scene into the CURRENT page's window. */
static void renderPage() {
  memset(pageBuf, 0, sizeof(pageBuf));

  // scope: crosshair + range rings centred mid-screen
  const int16_t cx = W / 2, cy = H / 2;
  for (int16_t x = 0; x < W; x += 4) px(x, cy);
  for (int16_t y = 0; y < H; y += 4) px(cx, y);
  circle(cx, cy, 20);
  circle(cx, cy, 40);
  circle(cx, cy, 60);

  // rotating sweep line (square panel → a true circle sweep)
  int16_t sx = ((int16_t)icos(sweep) * 60) >> 7;
  int16_t sy = ((int16_t)isin(sweep) * 60) >> 7;
  line(cx, cy, cx + sx, cy + sy);

  // scope label (live feed's airspace code), top-left corner
  if (scopeLabel[0]) text(1, 1, scopeLabel);

  for (uint8_t i = 0; i < MAX_AC; i++) {
    Aircraft &a = ac[i];
    if (!a.active) continue;
    int16_t x = a.x >> 4, y = a.y >> 4;
    for (uint8_t t = 0; t < a.trail_n; t++)
      px(a.trail_x[(a.trail_head - 1 - t) & (TRAIL_LEN - 1)],
         a.trail_y[(a.trail_head - 1 - t) & (TRAIL_LEN - 1)]);
    // aircraft blip: 3x3 diamond + velocity vector
    px(x, y); px(x - 1, y); px(x + 1, y); px(x, y - 1); px(x, y + 1);
    line(x, y, x + (a.vx >> 3), y + (a.vy >> 3));
    text(x + 3, y - 6, a.label);
  }
}

static void render() {
  // u8g2-style page loop: rasterize the scene 16 times, one 8-row
  // stripe at a time, blitting each stripe as it completes.
  for (curPage = 0; curPage < H / 8; curPage++) {
    renderPage();
    blitPage();
  }
  sweep++;

  uint8_t active = 0;
  for (uint8_t i = 0; i < MAX_AC; i++)
    if (ac[i].active) active++;

  // Stats only when something changed (plus a slow heartbeat) — a
  // per-frame print would flood the serial monitor at ~10 lines/s.
  static uint8_t lastActive = 255;
  uint32_t now = millis();
  frame++;
  if (active != lastActive || (frame & 63) == 0) {
    lastActive = active;
    Serial.print(F("F,"));
    Serial.print(frame);
    Serial.print(',');
    Serial.print(active);
    Serial.print(',');
    Serial.println(now - lastMs);
  }
  lastMs = now;
}

void setup() {
  Serial.begin(115200);
  Wire.begin();
  Wire.setClock(400000L);
  oledInit();
  seedDemo();
  Serial.println(F("FLIGHT RADAR READY"));
}

void loop() {
  pollSerial();
  stepTraffic();
  render();
}

Related projects