First time project, a small desktop "radar" that uses Opensky ADS-B data to show airplanes in your chosen area.
My issue is that the letters on the display, left side are half cut off, and there's 2 to 5 columns of pixels on the right that look like they shouldn't be there. My guess is that there's an issue with the code and the display, and it's "wrapping" around or something, but I don't know. I'm using a NodeMCU ESP-12F (esp8266) and a keyestudio XC3728 1.3" 128x64 OLED display.
It's my first project and I'm totally new to this, so the fact I got it to go first try is pretty amazing.
Can anyone shed any light on this?
Welcome to the forum
Please follow the advice given in the link below when posting code, in particular the section entitled 'Posting code and common code problems'
Use code tags (the < CODE/ > icon above the compose window) to make it easier to read and copy for examination
Please post your full sketch, using code tags when you do
Here's the sketch.
/*
* ESP8266 Plane Spotter
* --------------------------------------------------------------------------
* Shows the aircraft currently flying closest to your home on a 0.96" SSD1306
* SPI OLED, plus a bunch of nerdy statistics. Live ADS-B data is pulled from
* the free OpenSky Network REST API.
*
* Board : any ESP8266 (NodeMCU v2/v3, Wemos D1 mini, ...)
* Display : 0.96" OLED 4-wire SPI, SSD1306 128x64 (7 pins:
* GND, VCC, SCK, SDA, RES, DC, CS)
*
* Wiring (default, see README for the full table):
* OLED ESP8266 (NodeMCU label / GPIO)
* GND --> GND
* VCC --> 3V3
* SCK --> D5 / GPIO14 (HW SPI SCLK, fixed)
* SDA --> D7 / GPIO13 (HW SPI MOSI, fixed)
* RES --> D0 / GPIO16
* DC --> D2 / GPIO4
* CS --> D1 / GPIO5
*
* Libraries (install from the Arduino Library Manager):
* - U8g2 by olikraus
* - ArduinoJson by Benoit Blanchon (v6.x)
*
* Copy config.example.h to config.h and fill in your details before flashing.
*/
#include <ESP8266WiFi.h>
#include <WiFiClientSecure.h>
#include <ESP8266HTTPClient.h>
#include <ArduinoJson.h>
#include <U8g2lib.h>
#include <math.h>
#include <time.h>
#include <sys/time.h>
#include "config.h"
// ---------------------------------------------------------------------------
// Display: SSD1306 128x64, 4-wire hardware SPI.
// HW SPI uses the fixed ESP8266 pins SCLK=GPIO14 (D5) and MOSI=GPIO13 (D7);
// only CS / DC / RESET are configurable here.
// ---------------------------------------------------------------------------
#define OLED_CS PIN_OLED_CS
#define OLED_DC PIN_OLED_DC
#define OLED_RST PIN_OLED_RST
U8G2_SSD1306_128X64_NONAME_F_4W_HW_SPI u8g2(U8G2_R0, OLED_CS, OLED_DC, OLED_RST);
// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------
struct Aircraft {
char icao24[8];
char callsign[10];
char country[24];
double lat;
double lon;
float altitudeM; // geometric/barometric altitude in metres
float velocityMs; // ground speed in m/s
float trackDeg; // true track over ground (0 = north)
float vrateMs; // vertical rate in m/s (+climb / -descent)
bool onGround;
int category; // OpenSky emitter category (state index 17, 0 = unknown)
double distanceKm; // great-circle distance from home
double bearingDeg; // bearing from home to aircraft
bool valid;
};
Aircraft nearest;
// Per-aircraft state for the radar, including enough to dead-reckon (estimate)
// the position between data refreshes so the blips creep in real time.
struct Blip { double lat; double lon; float track; float speedMs; };
const uint8_t MAX_BLIPS = 20;
Blip blips[MAX_BLIPS];
uint8_t blipCount = 0;
uint32_t lastDataMs = 0; // millis() of the last successful aircraft fetch
// OpenSky OAuth2 bearer token (when client credentials are configured).
String accessToken;
uint32_t tokenExpiryMs = 0;
// Current weather (from Open-Meteo).
struct Weather {
float tempC;
float windKmh;
int humidity;
int code; // WMO weather code
bool valid = false;
} weather;
uint32_t lastWeatherPoll = 0;
// Short hourly forecast (a few hours ahead).
struct Fcast { int hour; float tempC; int code; };
const uint8_t FC_N = 3;
Fcast fcast[FC_N];
uint8_t fcCount = 0;
// Route / airline / ETA for the current nearest aircraft (from hexdb.io).
struct RouteInfo {
char callsign[10]; // which callsign this data is for
char airline[18];
char dep[6];
char arr[6];
bool haveRoute;
bool haveArrPos;
double arrLat, arrLon;
} routeInfo;
// Runtime statistics (the nerdy bit)
struct Stats {
uint32_t requestsOk = 0;
uint32_t requestsFail = 0;
uint16_t inView = 0; // aircraft inside the search box on last poll
uint16_t maxInView = 0; // session record
double closestEver = 1e9; // closest distance seen this session (km)
uint32_t lastUpdateMs = 0;
} stats;
uint8_t screen = 0; // which screen is showing
uint32_t lastScreenSwap = 0;
uint32_t lastPoll = 0;
bool firstFetchDone = false;
bool firstWeatherDone = false;
const uint8_t NUM_SCREENS = 5;
const uint32_t SCREEN_SWAP_MS = 7000;
// ---------------------------------------------------------------------------
// Geo helpers
// ---------------------------------------------------------------------------
static double deg2rad(double d) { return d * (PI / 180.0); }
static double rad2deg(double r) { return r * (180.0 / PI); }
// Great-circle distance (Haversine) in kilometres.
double haversineKm(double lat1, double lon1, double lat2, double lon2) {
const double R = 6371.0;
double dLat = deg2rad(lat2 - lat1);
double dLon = deg2rad(lon2 - lon1);
double a = sin(dLat / 2) * sin(dLat / 2) +
cos(deg2rad(lat1)) * cos(deg2rad(lat2)) *
sin(dLon / 2) * sin(dLon / 2);
return R * 2 * atan2(sqrt(a), sqrt(1 - a));
}
// Initial bearing from point 1 to point 2, degrees 0..360.
double bearingDeg(double lat1, double lon1, double lat2, double lon2) {
double y = sin(deg2rad(lon2 - lon1)) * cos(deg2rad(lat2));
double x = cos(deg2rad(lat1)) * sin(deg2rad(lat2)) -
sin(deg2rad(lat1)) * cos(deg2rad(lat2)) * cos(deg2rad(lon2 - lon1));
double b = rad2deg(atan2(y, x));
return fmod(b + 360.0, 360.0);
}
const char* compass(double bearing) {
static const char* dirs[] = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"};
return dirs[(int)((bearing + 22.5) / 45.0) % 8];
}
// ---------------------------------------------------------------------------
// WiFi
// ---------------------------------------------------------------------------
void connectWiFi() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
uint8_t dots = 0;
while (WiFi.status() != WL_CONNECTED) {
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_6x12_tr);
u8g2.drawStr(0, 12, "Plane Spotter");
u8g2.drawStr(0, 30, "Connecting WiFi");
u8g2.setCursor(0, 46);
u8g2.print(WIFI_SSID);
u8g2.setCursor(0, 62);
for (uint8_t i = 0; i < (dots % 16) + 1; i++) u8g2.print('.');
u8g2.sendBuffer();
delay(400);
dots++;
}
Serial.printf("\n[wifi] connected SSID=%s IP=%s RSSI=%d\n",
WIFI_SSID, WiFi.localIP().toString().c_str(), WiFi.RSSI());
}
// ---------------------------------------------------------------------------
// OpenSky fetch
// ---------------------------------------------------------------------------
String buildUrl() {
double lamin = HOME_LAT - SEARCH_RADIUS_DEG;
double lamax = HOME_LAT + SEARCH_RADIUS_DEG;
double lomin = HOME_LON - SEARCH_RADIUS_DEG;
double lomax = HOME_LON + SEARCH_RADIUS_DEG;
String url = "https://opensky-network.org/api/states/all?";
url += "lamin=" + String(lamin, 4);
url += "&lomin=" + String(lomin, 4);
url += "&lamax=" + String(lamax, 4);
url += "&lomax=" + String(lomax, 4);
url += "&extended=1"; // include the aircraft category (state index 17)
return url;
}
// Whether OAuth2 client credentials are configured.
bool oauthConfigured() { return strlen(OPENSKY_CLIENT_ID) > 0; }
// Request a fresh OAuth2 access token (client_credentials grant). Returns true
// on success and stores it in `accessToken` with an expiry a minute early.
bool fetchToken() {
WiFiClientSecure client;
client.setInsecure();
client.setBufferSizes(16384, 512);
HTTPClient https;
https.setReuse(false);
if (!https.begin(client,
"https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token")) {
Serial.println("[auth] begin() failed");
return false;
}
https.addHeader("Content-Type", "application/x-www-form-urlencoded");
String body = "grant_type=client_credentials&client_id=";
body += OPENSKY_CLIENT_ID;
body += "&client_secret=";
body += OPENSKY_CLIENT_SECRET;
int code = https.POST(body);
Serial.printf("[auth] token HTTP %d\n", code);
if (code != HTTP_CODE_OK) { https.end(); return false; }
String payload = https.getString();
https.end();
JsonDocument filter;
filter["access_token"] = true;
filter["expires_in"] = true;
JsonDocument doc;
if (deserializeJson(doc, payload, DeserializationOption::Filter(filter))) {
Serial.println("[auth] token JSON parse failed");
return false;
}
const char* tok = doc["access_token"] | "";
if (!tok[0]) return false;
accessToken = tok;
int exp = doc["expires_in"] | 1800;
tokenExpiryMs = millis() + (uint32_t)(exp > 120 ? exp - 60 : exp) * 1000UL;
Serial.printf("[auth] token ok (len=%u, expires in %ds)\n", accessToken.length(), exp);
return true;
}
// Pulls aircraft states, keeps the nearest one. Returns true on success.
bool fetchAircraft() {
// Refresh the OAuth2 token first, before the data client exists, so we never
// hold two 16 KB TLS buffers at once.
if (oauthConfigured() &&
(accessToken.length() == 0 || (int32_t)(millis() - tokenExpiryMs) >= 0)) {
fetchToken();
}
WiFiClientSecure client;
client.setInsecure(); // skip cert validation (read-only data)
// Do NOT shrink the RX buffer below the default 16 KB: OpenSky does not
// negotiate a smaller TLS fragment (no MFLN), so a small RX buffer makes the
// TLS handshake fail and every fetch silently returns "no aircraft".
client.setBufferSizes(16384, 512);
HTTPClient https;
https.setReuse(false);
String url = buildUrl();
Serial.printf("[fetch] heap=%u GET %s\n", ESP.getFreeHeap(), url.c_str());
if (!https.begin(client, url)) {
Serial.println("[fetch] https.begin() failed");
stats.requestsFail++;
return false;
}
if (oauthConfigured() && accessToken.length() > 0) {
https.addHeader("Authorization", "Bearer " + accessToken);
}
int code = https.GET();
Serial.printf("[fetch] HTTP %d\n", code);
if (code != HTTP_CODE_OK) {
if (code == HTTP_CODE_UNAUTHORIZED) accessToken = ""; // force token refresh
https.end();
stats.requestsFail++;
return false;
}
// Filter: keep only the fields we actually use from every state vector.
// OpenSky state indices: 0 icao24, 1 callsign, 2 origin_country,
// 5 longitude, 6 latitude, 7 baro_altitude, 8 on_ground, 9 velocity,
// 10 true_track, 11 vertical_rate, 13 geo_altitude.
JsonDocument filter;
JsonArray el = filter["states"].to<JsonArray>().add<JsonArray>();
for (int i = 0; i <= 17; i++) el[i] = false;
el[0] = el[1] = el[2] = el[5] = el[6] = true;
el[7] = el[8] = el[9] = el[10] = el[11] = el[13] = el[17] = true;
// OpenSky replies with Transfer-Encoding: chunked. getStream() would hand the
// raw chunked bytes (hex length markers) to the parser and yield nothing, so
// we use getString(), which de-chunks the body before we parse it.
String payload = https.getString();
https.end();
Serial.printf("[fetch] payload=%u bytes\n", payload.length());
JsonDocument doc;
DeserializationError err = deserializeJson(
doc, payload, DeserializationOption::Filter(filter));
if (err) {
Serial.printf("[fetch] JSON error: %s\n", err.c_str());
stats.requestsFail++;
return false;
}
JsonArray states = doc["states"].as<JsonArray>();
Aircraft best;
best.valid = false;
best.distanceKm = 1e9;
uint16_t count = 0;
blipCount = 0;
for (JsonArray s : states) {
if (s.isNull() || s[5].isNull() || s[6].isNull()) continue;
double lon = s[5].as<double>();
double lat = s[6].as<double>();
double d = haversineKm(HOME_LAT, HOME_LON, lat, lon);
double brg = bearingDeg(HOME_LAT, HOME_LON, lat, lon);
count++;
if (blipCount < MAX_BLIPS) {
blips[blipCount].lat = lat;
blips[blipCount].lon = lon;
blips[blipCount].track = s[10] | 0.0f;
blips[blipCount].speedMs = (s[8] | false) ? 0.0f : (s[9] | 0.0f);
blipCount++;
}
if (d < best.distanceKm) {
best.distanceKm = d;
best.lat = lat;
best.lon = lon;
best.bearingDeg = brg;
best.onGround = s[8] | false;
best.category = s[17] | 0;
// geo altitude (13) preferred, fall back to barometric (7)
best.altitudeM = s[13].isNull() ? (s[7] | 0.0f) : s[13].as<float>();
best.velocityMs = s[9] | 0.0f;
best.trackDeg = s[10] | 0.0f;
best.vrateMs = s[11] | 0.0f;
const char* cs = s[1] | "";
strncpy(best.callsign, cs, sizeof(best.callsign) - 1);
best.callsign[sizeof(best.callsign) - 1] = '\0';
// trim trailing spaces OpenSky pads callsigns with
for (int i = strlen(best.callsign) - 1; i >= 0 && best.callsign[i] == ' '; i--)
best.callsign[i] = '\0';
if (best.callsign[0] == '\0') strcpy(best.callsign, "(no id)");
const char* ic = s[0] | "";
strncpy(best.icao24, ic, sizeof(best.icao24) - 1);
best.icao24[sizeof(best.icao24) - 1] = '\0';
const char* co = s[2] | "?";
strncpy(best.country, co, sizeof(best.country) - 1);
best.country[sizeof(best.country) - 1] = '\0';
best.valid = true;
}
}
stats.inView = count;
if (count > stats.maxInView) stats.maxInView = count;
stats.requestsOk++;
stats.lastUpdateMs = millis();
lastDataMs = millis();
nearest = best;
if (nearest.valid && nearest.distanceKm < stats.closestEver)
stats.closestEver = nearest.distanceKm;
Serial.printf("[fetch] inView=%u blips=%u nearest=%s cat=%d dist=%.1fkm valid=%d heap=%u\n",
count, blipCount, nearest.valid ? nearest.callsign : "-",
nearest.valid ? nearest.category : -1,
nearest.valid ? nearest.distanceKm : 0.0, nearest.valid,
ESP.getFreeHeap());
return true;
}
// ---------------------------------------------------------------------------
// Weather (Open-Meteo, no API key required)
// ---------------------------------------------------------------------------
bool fetchWeather() {
WiFiClientSecure client;
client.setInsecure();
client.setBufferSizes(16384, 512);
HTTPClient https;
https.setReuse(false);
String url = "https://api.open-meteo.com/v1/forecast?latitude=";
url += String(HOME_LAT, 4);
url += "&longitude=" + String(HOME_LON, 4);
url += "¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m";
url += "&hourly=temperature_2m,weather_code&forecast_hours=8&timezone=auto";
if (!https.begin(client, url)) return false;
int code = https.GET();
Serial.printf("[wx] HTTP %d\n", code);
if (code != HTTP_CODE_OK) { https.end(); return false; }
String payload = https.getString();
https.end();
JsonDocument filter;
filter["current"] = true;
filter["hourly"] = true;
JsonDocument doc;
if (deserializeJson(doc, payload, DeserializationOption::Filter(filter))) return false;
JsonObject c = doc["current"];
if (c.isNull()) return false;
weather.tempC = c["temperature_2m"] | 0.0f;
weather.humidity = c["relative_humidity_2m"] | 0;
weather.code = c["weather_code"] | 0;
weather.windKmh = c["wind_speed_10m"] | 0.0f;
weather.valid = true;
// short forecast: a few hours ahead (index 0 of forecast_hours == now)
JsonArray ht = doc["hourly"]["temperature_2m"].as<JsonArray>();
JsonArray hc = doc["hourly"]["weather_code"].as<JsonArray>();
int nowH = -1;
if (timeReady()) {
time_t t = time(nullptr);
struct tm lt;
localtime_r(&t, <);
nowH = lt.tm_hour;
}
const int offs[FC_N] = {2, 4, 6};
fcCount = 0;
for (uint8_t k = 0; k < FC_N; k++) {
int idx = offs[k];
if ((int)ht.size() > idx) {
fcast[fcCount].tempC = ht[idx] | 0.0f;
fcast[fcCount].code = hc[idx] | 0;
fcast[fcCount].hour = (nowH < 0) ? -1 : ((nowH + idx) % 24);
fcCount++;
}
}
Serial.printf("[wx] %.1fC hum=%d%% wind=%.0f code=%d fc=%u\n",
weather.tempC, weather.humidity, weather.windKmh, weather.code, fcCount);
return true;
}
// Short label and icon-kind (0 sun,1 part,2 cloud,3 fog,4 rain,5 snow,6 storm)
// for a WMO weather code.
const char* wxText(int code) {
if (code == 0) return "CLEAR";
if (code <= 2) return "PARTLY";
if (code == 3) return "OVERCAST";
if (code == 45 || code == 48) return "FOG";
if (code >= 51 && code <= 57) return "DRIZZLE";
if (code >= 61 && code <= 67) return "RAIN";
if (code >= 71 && code <= 77) return "SNOW";
if (code >= 80 && code <= 82) return "SHOWERS";
if (code >= 85 && code <= 86) return "SNOW";
if (code >= 95) return "STORM";
return "WX";
}
int wxKind(int code) {
if (code == 0) return 0;
if (code <= 2) return 1;
if (code == 3) return 2;
if (code == 45 || code == 48) return 3;
if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) return 4;
if ((code >= 71 && code <= 77) || (code >= 85 && code <= 86)) return 5;
if (code >= 95) return 6;
return 2;
}
// ---------------------------------------------------------------------------
// Route / airline / ETA (hexdb.io, free, no key)
// ---------------------------------------------------------------------------
// Generic HTTPS GET into a String. Returns true on HTTP 200.
bool httpGetString(const String& url, String& out) {
WiFiClientSecure client;
client.setInsecure();
client.setBufferSizes(16384, 512);
HTTPClient https;
https.setReuse(false);
if (!https.begin(client, url)) return false;
int code = https.GET();
if (code != HTTP_CODE_OK) { https.end(); return false; }
out = https.getString();
https.end();
return true;
}
// Offline ICAO airline-designator table (first 3 letters of the callsign).
struct Airline { const char* code; const char* name; };
const Airline AIRLINES[] = {
{"ACI","Air Caledonie"}, {"CVA","Air Chathams"}, {"CCA","Air China"}, {"ANZ","Air New Zealand"},
{"THT","Air Tahiti Nui"},{"AVN","Air Vanuatu"}, {"AWK","Airwork"}, {"XAX","AirAsiaX"},
{"CPA","Cathay Pacific"},{"CAL","China"}, {"CES","China Eastern"},{"CSN","China Southern"},
{"UAE","Emirates"}, {"FDX","FedEx"}, {"FJI","Fiji Airways"}, {"CHH","Hainan"},
{"HAL","Hawaiian"}, {"JST","Jetstar"}, {"KAL","Korean"}, {"LAN","LATAM"},
{"MAS","Malaysia"}, {"PAL","Philippine"}, {"QFA","Qantas"}, {"QTR","Qatar"},
{"CSC","Sichuan"}, {"SIA","Singapore"}, {"TMN","Tasman Cargo"}, {"THA","Thai Airways"},
{"GCR","Tianjin"}, {"UAL","United"}, {"VOZ","Virgin Australia"},
};
const char* airlineName(const char* callsign) {
static char fb[4];
if (!callsign || strlen(callsign) < 3) return "GA / Private";
char p[4] = { (char)toupper(callsign[0]), (char)toupper(callsign[1]),
(char)toupper(callsign[2]), 0 };
for (auto& a : AIRLINES) if (strcmp(a.code, p) == 0) return a.name;
strcpy(fb, p);
return fb; // unknown -> show the 3-letter operator code
}
// Look up departure/arrival airports (and arrival coords for ETA) for a
// callsign. Always fills the airline; route/ETA are best-effort.
void fetchRoute(const char* callsign) {
strncpy(routeInfo.airline, airlineName(callsign), sizeof(routeInfo.airline) - 1);
routeInfo.airline[sizeof(routeInfo.airline) - 1] = '\0';
routeInfo.haveRoute = false;
routeInfo.haveArrPos = false;
routeInfo.dep[0] = routeInfo.arr[0] = '\0';
strncpy(routeInfo.callsign, callsign, sizeof(routeInfo.callsign) - 1);
routeInfo.callsign[sizeof(routeInfo.callsign) - 1] = '\0';
String payload;
if (httpGetString(String("https://hexdb.io/api/v1/route/icao/") + callsign, payload)) {
JsonDocument d;
if (!deserializeJson(d, payload)) {
const char* r = d["route"] | "";
const char* dash = strchr(r, '-');
if (r[0] && dash) {
size_t dl = dash - r;
if (dl < sizeof(routeInfo.dep)) {
strncpy(routeInfo.dep, r, dl);
routeInfo.dep[dl] = '\0';
strncpy(routeInfo.arr, dash + 1, sizeof(routeInfo.arr) - 1);
routeInfo.arr[sizeof(routeInfo.arr) - 1] = '\0';
routeInfo.haveRoute = true;
}
}
}
}
if (routeInfo.haveRoute && routeInfo.arr[0]) {
String ap;
if (httpGetString(String("https://hexdb.io/api/v1/airport/icao/") + routeInfo.arr, ap)) {
JsonDocument d;
if (!deserializeJson(d, ap) && !d["latitude"].isNull()) {
routeInfo.arrLat = d["latitude"] | 0.0;
routeInfo.arrLon = d["longitude"] | 0.0;
routeInfo.haveArrPos = true;
}
}
}
Serial.printf("[route] %s %s %s>%s eta=%s\n", callsign, routeInfo.airline,
routeInfo.haveRoute ? routeInfo.dep : "?",
routeInfo.haveRoute ? routeInfo.arr : "?",
routeInfo.haveArrPos ? "yes" : "no");
}
// ---------------------------------------------------------------------------
// Time
// ---------------------------------------------------------------------------
bool timeReady() { return time(nullptr) > 1700000000; }
void fmtClock(char* buf, size_t n, bool withSecs) {
if (!timeReady()) { strncpy(buf, withSecs ? "--:--:--" : "--:--", n); return; }
time_t t = time(nullptr);
struct tm lt;
localtime_r(&t, <);
strftime(buf, n, withSecs ? "%H:%M:%S" : "%H:%M", <);
}
// Forward great-circle position: move (lat,lon) by distM metres along trackDeg.
void projectLatLon(double lat, double lon, float trackDeg, double distM,
double& outLat, double& outLon) {
double dr = distM / 6371000.0;
double b = deg2rad(trackDeg);
double la = deg2rad(lat), lo = deg2rad(lon);
double nla = asin(sin(la) * cos(dr) + cos(la) * sin(dr) * cos(b));
double nlo = lo + atan2(sin(b) * sin(dr) * cos(la), cos(dr) - sin(la) * sin(nla));
outLat = rad2deg(nla);
outLon = rad2deg(nlo);
}
// ---------------------------------------------------------------------------
// Drawing helpers
// ---------------------------------------------------------------------------
// Tactical header: title + screen index on the left, NTP clock on the right.
void drawHeader(const char* title) {
u8g2.setFont(u8g2_font_5x7_tr);
char left[20];
snprintf(left, sizeof(left), "%s %d/%d", title, screen + 1, NUM_SCREENS);
u8g2.drawStr(2, 6, left);
char t[10];
fmtClock(t, sizeof(t), true);
u8g2.drawStr(127 - u8g2.getStrWidth(t), 6, t);
u8g2.drawHLine(0, 8, 128);
u8g2.drawVLine(0, 0, 3); // HUD corner ticks
u8g2.drawVLine(127, 0, 3);
}
// Filled cloud silhouette, left edge near (cx-7), vertically around cy.
void drawCloud(int cx, int cy) {
u8g2.drawDisc(cx - 5, cy, 4);
u8g2.drawDisc(cx + 1, cy - 3, 5);
u8g2.drawDisc(cx + 6, cy, 4);
u8g2.drawBox(cx - 5, cy, 12, 5);
}
void drawWeatherIcon(int cx, int cy, int kind) {
switch (kind) {
case 0: { // sun
u8g2.drawDisc(cx, cy, 5);
for (int a = 0; a < 360; a += 45) {
double r = deg2rad(a);
u8g2.drawLine(cx + (int)(cos(r) * 7), cy + (int)(sin(r) * 7),
cx + (int)(cos(r) * 9), cy + (int)(sin(r) * 9));
}
break;
}
case 1: // partly cloudy
u8g2.drawDisc(cx - 3, cy - 4, 4);
drawCloud(cx + 2, cy + 2);
break;
case 3: // fog
drawCloud(cx, cy - 2);
for (int i = 0; i < 3; i++) u8g2.drawHLine(cx - 7, cy + 5 + i * 2, 15);
break;
case 4: // rain
drawCloud(cx, cy - 2);
for (int i = -4; i <= 6; i += 5) u8g2.drawLine(cx + i, cy + 4, cx + i - 2, cy + 8);
break;
case 5: // snow
drawCloud(cx, cy - 2);
for (int i = -4; i <= 6; i += 5) {
u8g2.drawPixel(cx + i, cy + 6);
u8g2.drawHLine(cx + i - 1, cy + 6, 3);
u8g2.drawVLine(cx + i, cy + 5, 3);
}
break;
case 6: // storm
drawCloud(cx, cy - 2);
u8g2.drawLine(cx, cy + 4, cx - 3, cy + 7);
u8g2.drawLine(cx - 3, cy + 7, cx + 1, cy + 7);
u8g2.drawLine(cx + 1, cy + 7, cx - 2, cy + 10);
break;
default: // overcast / generic cloud
drawCloud(cx, cy);
break;
}
}
// Arrow pointing toward `angle` (0 = up/north), centred at (cx,cy).
void drawArrow(int cx, int cy, int r, double angleDeg) {
double a = deg2rad(angleDeg);
// tip
int tx = cx + (int)(sin(a) * r);
int ty = cy - (int)(cos(a) * r);
// tail
int bx = cx - (int)(sin(a) * r);
int by = cy + (int)(cos(a) * r);
u8g2.drawLine(bx, by, tx, ty);
// arrow head
double left = a + deg2rad(150);
double right = a - deg2rad(150);
u8g2.drawLine(tx, ty, tx + (int)(sin(left) * (r / 2)), ty - (int)(cos(left) * (r / 2)));
u8g2.drawLine(tx, ty, tx + (int)(sin(right) * (r / 2)), ty - (int)(cos(right) * (r / 2)));
}
// Short label for an OpenSky emitter category.
const char* typeName(int cat) {
switch (cat) {
case 2: return "Light";
case 3: return "Small";
case 4: return "Airliner";
case 5: return "Heavy";
case 6: return "Heavy";
case 7: return "Jet";
case 8: return "Heli";
case 9: return "Glider";
case 10: return "Balloon";
case 14: return "Drone";
default: return "Aircraft";
}
}
// Icon (~16x12) for an aircraft type, centred at (cx,cy).
void drawTypeIcon(int cx, int cy, int cat) {
switch (cat) {
case 8: // helicopter
u8g2.drawDisc(cx - 1, cy, 2);
u8g2.drawHLine(cx - 7, cy - 3, 15); // main rotor
u8g2.drawLine(cx + 1, cy, cx + 7, cy + 1); // tail boom
u8g2.drawVLine(cx + 7, cy - 2, 5); // tail rotor
break;
case 9: // glider (long slim wings)
u8g2.drawHLine(cx - 8, cy, 17);
u8g2.drawVLine(cx, cy - 2, 7);
u8g2.drawHLine(cx - 2, cy + 5, 5);
break;
case 10: // balloon / lighter-than-air
u8g2.drawCircle(cx, cy - 2, 4);
u8g2.drawLine(cx - 3, cy + 1, cx - 1, cy + 5);
u8g2.drawLine(cx + 3, cy + 1, cx + 1, cy + 5);
u8g2.drawFrame(cx - 1, cy + 5, 3, 2);
break;
case 14: // drone (quadcopter)
u8g2.drawBox(cx - 1, cy - 1, 3, 3);
u8g2.drawLine(cx - 5, cy - 4, cx + 5, cy + 4);
u8g2.drawLine(cx + 5, cy - 4, cx - 5, cy + 4);
u8g2.drawCircle(cx - 5, cy - 4, 2);
u8g2.drawCircle(cx + 5, cy - 4, 2);
u8g2.drawCircle(cx - 5, cy + 4, 2);
u8g2.drawCircle(cx + 5, cy + 4, 2);
break;
case 2:
case 3: // light / small plane (straight wings)
u8g2.drawVLine(cx, cy - 4, 10);
u8g2.drawHLine(cx - 5, cy - 1, 11);
u8g2.drawHLine(cx - 2, cy + 4, 5);
break;
default: // airliner / generic (swept wings, top view)
u8g2.drawVLine(cx, cy - 5, 12);
u8g2.drawTriangle(cx, cy - 1, cx - 7, cy + 3, cx - 1, cy + 1);
u8g2.drawTriangle(cx, cy - 1, cx + 7, cy + 3, cx + 1, cy + 1);
u8g2.drawTriangle(cx, cy + 4, cx - 3, cy + 6, cx - 1, cy + 5);
u8g2.drawTriangle(cx, cy + 4, cx + 3, cy + 6, cx + 1, cy + 5);
break;
}
}
// WiFi signal bars (0..4), bottom-aligned at baseline y, growing right.
void drawSignalBars(int x, int y, int rssi) {
int bars = 0;
if (rssi >= -55) bars = 4;
else if (rssi >= -65) bars = 3;
else if (rssi >= -75) bars = 2;
else if (rssi >= -85) bars = 1;
for (int i = 0; i < 4; i++) {
int h = 2 + i * 2;
if (i < bars) u8g2.drawBox(x + i * 3, y - h, 2, h);
else u8g2.drawFrame(x + i * 3, y - h, 2, h);
}
}
// OpenSky often leaves the emitter category at 0 (unknown). When that happens
// we make a rough guess from altitude + ground speed so the icon still varies.
// Real category data always wins. Estimated types are flagged with '~' on screen.
int effectiveCategory(const Aircraft& a) {
if (a.category > 0) return a.category; // real data
if (a.onGround) return 0;
float kmh = a.velocityMs * 3.6f;
float m = a.altitudeM;
if (kmh < 120 && m < 2200) return 8; // slow & low -> guess helicopter
if (kmh < 300 && m < 5000) return 3; // medium -> guess small plane
return 4; // fast / high -> airliner
}
bool isEstimatedType(const Aircraft& a) { return a.category == 0 && !a.onGround; }
void screenNearest() {
drawHeader("TARGET");
if (!nearest.valid) {
u8g2.setFont(u8g2_font_6x12_tr);
u8g2.drawStr(0, 30, "NO TARGET");
u8g2.drawStr(0, 44, "in range.");
return;
}
u8g2.setFont(u8g2_font_7x14B_tr);
u8g2.drawStr(0, 24, nearest.callsign);
// aircraft-type icon, between the callsign and the heading arrow
drawTypeIcon(82, 16, effectiveCategory(nearest));
u8g2.setFont(u8g2_font_6x12_tr);
char line[24];
snprintf(line, sizeof(line), "%.1f km %s", nearest.distanceKm, compass(nearest.bearingDeg));
u8g2.drawStr(0, 40, line);
if (nearest.onGround) {
u8g2.drawStr(0, 54, "on ground");
} else {
snprintf(line, sizeof(line), "%.0f m / FL%03.0f",
nearest.altitudeM, nearest.altitudeM * 3.28084 / 100.0);
u8g2.drawStr(0, 54, line);
}
// heading arrow + speed on the right
drawArrow(110, 34, 11, nearest.trackDeg);
snprintf(line, sizeof(line), "%.0f", nearest.velocityMs * 3.6); // km/h
u8g2.setFont(u8g2_font_4x6_tr);
u8g2.drawStr(98, 56, line);
u8g2.drawStr(98, 63, "km/h");
// altitude gauge on the far-right column (0..FL400)
const int gT = 14, gB = 50;
u8g2.drawFrame(125, gT, 3, gB - gT);
if (!nearest.onGround) {
float fl = nearest.altitudeM * 3.28084f / 100.0f;
float fr = fl / 400.0f;
if (fr > 1) fr = 1;
if (fr < 0) fr = 0;
int fh = (int)((gB - gT - 2) * fr);
u8g2.drawBox(126, gB - 1 - fh, 1, fh);
}
}
void screenDetails() {
drawHeader("INTEL");
if (!nearest.valid) {
u8g2.setFont(u8g2_font_5x7_tr);
u8g2.drawStr(0, 30, "NO INTEL");
return;
}
bool haveRoute = (strcmp(routeInfo.callsign, nearest.callsign) == 0);
char line[32];
u8g2.setFont(u8g2_font_6x12_tr);
u8g2.drawStr(0, 20, nearest.callsign);
u8g2.setFont(u8g2_font_5x7_tr);
snprintf(line, sizeof(line), "LINE %s", haveRoute ? routeInfo.airline : airlineName(nearest.callsign));
u8g2.drawStr(0, 31, line);
if (haveRoute && routeInfo.haveRoute)
snprintf(line, sizeof(line), "RTE %s > %s", routeInfo.dep, routeInfo.arr);
else
snprintf(line, sizeof(line), "RTE unknown");
u8g2.drawStr(0, 41, line);
if (haveRoute && routeInfo.haveArrPos && nearest.velocityMs > 20) {
double dk = haversineKm(nearest.lat, nearest.lon, routeInfo.arrLat, routeInfo.arrLon);
int min = (int)(dk / (nearest.velocityMs * 3.6) * 60.0);
snprintf(line, sizeof(line), "ETA %dh%02dm %.0fkm", min / 60, min % 60, dk);
} else {
snprintf(line, sizeof(line), "ETA --");
}
u8g2.drawStr(0, 51, line);
snprintf(line, sizeof(line), "ID %s HDG %03.0f", nearest.icao24, nearest.trackDeg);
u8g2.drawStr(0, 61, line);
}
// North-up radar (PPI). Home at the centre, range rings (outer = 120 km), a
// rotating sweep, and a blip per aircraft. Blips are dead-reckoned from their
// last track+speed so they creep in real time between data refreshes, and use
// radar persistence: bright just after the sweep passes, then a faint dot.
void screenRadar() {
drawHeader("RADAR");
const int cx = 31, cy = 37, R = 23;
const float MAX_KM = 120.0f;
float elapsed = (millis() - lastDataMs) / 1000.0f; // s since last fetch
// rings + axes
u8g2.drawCircle(cx, cy, R);
u8g2.drawCircle(cx, cy, (R * 2) / 3);
u8g2.drawCircle(cx, cy, R / 3);
u8g2.drawHLine(cx - R, cy, 2 * R + 1);
u8g2.drawVLine(cx, cy - R, 2 * R + 1);
u8g2.drawDisc(cx, cy, 1);
u8g2.setFont(u8g2_font_4x6_tr);
u8g2.drawStr(cx - 1, cy - R - 1, "N");
// wall-facing tick just outside the ring
{
double a = deg2rad((double)WALL_HEADING_DEG);
u8g2.drawDisc(cx + (int)(sin(a) * (R + 2)), cy - (int)(cos(a) * (R + 2)), 1);
}
// rotating sweep (~4 s/turn, clockwise)
float sweepDeg = fmodf(millis() / 12.0f, 360.0f);
double sw = deg2rad(sweepDeg);
u8g2.drawLine(cx, cy, cx + (int)(sin(sw) * R), cy - (int)(cos(sw) * R));
// blips, dead-reckoned + persistence
int nearIdx = -1;
float nearD = 1e9;
for (uint8_t i = 0; i < blipCount; i++) {
double la = blips[i].lat, lo = blips[i].lon;
if (blips[i].speedMs > 0 && elapsed > 0)
projectLatLon(blips[i].lat, blips[i].lon, blips[i].track,
blips[i].speedMs * elapsed, la, lo);
double dist = haversineKm(HOME_LAT, HOME_LON, la, lo);
if (dist < nearD) { nearD = dist; nearIdx = i; }
float fr = (float)(dist / MAX_KM);
if (fr > 1) continue;
int rr = (int)(fr * R);
double brg = bearingDeg(HOME_LAT, HOME_LON, la, lo);
int bx = cx + (int)(sin(deg2rad(brg)) * rr);
int by = cy - (int)(cos(deg2rad(brg)) * rr);
float behind = fmodf(sweepDeg - (float)brg + 360.0f, 360.0f);
if (behind < 50) u8g2.drawDisc(bx, by, 1); // freshly swept
else u8g2.drawPixel(bx, by); // fading
}
// highlight the closest live contact
if (nearIdx >= 0) {
double la = blips[nearIdx].lat, lo = blips[nearIdx].lon;
if (blips[nearIdx].speedMs > 0 && elapsed > 0)
projectLatLon(blips[nearIdx].lat, blips[nearIdx].lon, blips[nearIdx].track,
blips[nearIdx].speedMs * elapsed, la, lo);
double dist = haversineKm(HOME_LAT, HOME_LON, la, lo);
double brg = bearingDeg(HOME_LAT, HOME_LON, la, lo);
float fr = (float)(dist / MAX_KM);
if (fr <= 1) {
int rr = (int)(fr * R);
int bx = cx + (int)(sin(deg2rad(brg)) * rr);
int by = cy - (int)(cos(deg2rad(brg)) * rr);
u8g2.drawCircle(bx, by, 3);
u8g2.drawDisc(bx, by, 1);
}
}
// side info panel
const int px = 62;
if (!nearest.valid) {
u8g2.setFont(u8g2_font_5x7_tr);
u8g2.drawStr(px, 32, "NO CONTACT");
return;
}
int ec = effectiveCategory(nearest);
drawTypeIcon(px + 7, 19, ec);
u8g2.setFont(u8g2_font_5x7_tr);
char tname[12];
snprintf(tname, sizeof(tname), "%s%s", isEstimatedType(nearest) ? "~" : "", typeName(ec));
u8g2.drawStr(px + 18, 20, tname);
u8g2.drawStr(px, 32, nearest.callsign);
char l[20];
snprintf(l, sizeof(l), "RNG %.0fkm", nearest.distanceKm);
u8g2.drawStr(px, 43, l);
snprintf(l, sizeof(l), "BRG %03.0f %s", nearest.bearingDeg, compass(nearest.bearingDeg));
u8g2.drawStr(px, 54, l);
u8g2.setFont(u8g2_font_4x6_tr);
snprintf(l, sizeof(l), "%u CONTACTS", blipCount);
u8g2.drawStr(px, 63, l);
}
void screenWeather() {
drawHeader("WX");
if (!weather.valid) {
u8g2.setFont(u8g2_font_6x12_tr);
u8g2.drawStr(18, 38, "no wx data");
return;
}
drawWeatherIcon(18, 30, wxKind(weather.code));
// big temperature, with a real degree glyph aligned to the baseline
char t[8];
snprintf(t, sizeof(t), "%.0f", weather.tempC);
u8g2.setFont(u8g2_font_logisoso16_tn);
u8g2.drawStr(40, 34, t);
int w = u8g2.getStrWidth(t);
u8g2.setFont(u8g2_font_9x15_tf);
u8g2.drawStr(40 + w + 2, 34, "\xB0" "C");
// condition + current humidity/wind
u8g2.setFont(u8g2_font_5x7_tr);
u8g2.drawStr(40, 45, wxText(weather.code));
u8g2.setFont(u8g2_font_4x6_tr);
char l[26];
snprintf(l, sizeof(l), "HUM %d%% WIND %.0fkm/h", weather.humidity, weather.windKmh);
u8g2.drawStr(2, 53, l);
// minimal next-hours forecast strip
u8g2.drawHLine(0, 55, 128);
int fx = 2;
for (uint8_t k = 0; k < fcCount; k++) {
char fb[12];
if (fcast[k].hour >= 0) snprintf(fb, sizeof(fb), "%02dh %.0fc", fcast[k].hour, fcast[k].tempC);
else snprintf(fb, sizeof(fb), "+%dh %.0fc", (k + 1) * 2, fcast[k].tempC);
u8g2.drawStr(fx, 63, fb);
fx += 44;
}
}
void screenSystem() {
drawHeader("SYSTEM");
// big clock HH:MM:SS
char clk[12];
fmtClock(clk, sizeof(clk), true);
u8g2.setFont(u8g2_font_logisoso16_tn);
int w = u8g2.getStrWidth(clk);
int x0 = 4;
u8g2.drawStr(x0, 30, clk);
// fast-updating milliseconds
struct timeval tv;
gettimeofday(&tv, nullptr);
char msb[6];
snprintf(msb, sizeof(msb), ".%03d", (int)(tv.tv_usec / 1000));
u8g2.setFont(u8g2_font_6x12_tr);
u8g2.drawStr(x0 + w + 2, 30, msb);
// date
u8g2.setFont(u8g2_font_5x7_tr);
char dl[24];
if (timeReady()) {
time_t tt = time(nullptr);
struct tm lt;
localtime_r(&tt, <);
strftime(dl, sizeof(dl), "%a %Y-%m-%d", <);
} else {
strcpy(dl, "SYNCING NTP...");
}
u8g2.drawStr(2, 42, dl);
// uptime + target count
uint32_t up = millis() / 1000;
char l[30];
snprintf(l, sizeof(l), "UP %02lu:%02lu:%02lu TGT %u",
up / 3600, (up % 3600) / 60, up % 60, stats.inView);
u8g2.drawStr(2, 52, l);
// link status: signal bars + details
drawSignalBars(2, 62, WiFi.RSSI());
u8g2.setFont(u8g2_font_4x6_tr);
snprintf(l, sizeof(l), "%ddBm RAM%dk REQ%lu/%lu",
WiFi.RSSI(), ESP.getFreeHeap() / 1024, stats.requestsOk, stats.requestsFail);
u8g2.drawStr(18, 62, l);
}
void render() {
u8g2.clearBuffer();
switch (screen) {
case 0: screenNearest(); break;
case 1: screenDetails(); break;
case 2: screenRadar(); break;
case 3: screenWeather(); break;
case 4: screenSystem(); break;
}
u8g2.sendBuffer();
}
// ---------------------------------------------------------------------------
// Arduino entry points
// ---------------------------------------------------------------------------
void setup() {
Serial.begin(115200);
u8g2.begin();
u8g2.setContrast(180);
// splash
u8g2.clearBuffer();
u8g2.drawFrame(0, 0, 128, 64);
u8g2.setFont(u8g2_font_7x14B_tr);
u8g2.drawStr(8, 28, "PLANE SPOTTER");
u8g2.setFont(u8g2_font_5x7_tr);
u8g2.drawStr(14, 46, "TACTICAL ADS-B v2");
u8g2.sendBuffer();
delay(1500);
connectWiFi();
// NTP time (timezone from config). Non-blocking; screens show --:-- until set.
configTime(TIMEZONE, "pool.ntp.org", "time.google.com");
nearest.valid = false;
}
void loop() {
uint32_t now = millis();
if (!firstFetchDone || now - lastPoll >= UPDATE_INTERVAL_MS) {
if (WiFi.status() != WL_CONNECTED) connectWiFi();
fetchAircraft();
if (nearest.valid && strcmp(routeInfo.callsign, nearest.callsign) != 0)
fetchRoute(nearest.callsign);
lastPoll = now;
firstFetchDone = true;
}
if (!firstWeatherDone || now - lastWeatherPoll >= WEATHER_INTERVAL_MS) {
if (WiFi.status() == WL_CONNECTED) fetchWeather();
lastWeatherPoll = now;
firstWeatherDone = true;
}
if (now - lastScreenSwap >= SCREEN_SWAP_MS) {
screen = (screen + 1) % NUM_SCREENS;
lastScreenSwap = now;
}
render();
delay(33); // ~30 fps: smooth radar sweep + fast-ticking clock
}
I updated the airline callsigns on lines 505 to 512 to local carriers. There's also a config.h file that has the location, time zone data, and WiFi settings to connect to the internet.
The sketch itself seems to work fine, so it looks to me like it's more of a compatibility issue with the hardware? Maybe?
The Arduino IDE is Version: 2.3.10, Date: 2026-06-09T16:44:01.600Z, CLI Version: 1.5.1
Likely the wrong display type in U8g2. Try something like the SH1106, if I recall using that display type when I actually have the SSD1306 shifts the image to the right a few pixels.
That sounds like a reasonable explanation.
How/where do I do that?
Thanks for that! I changed the display (type? name?) in line 49.
U8G2_SH1106_128X64_NONAME_F_4W_HW_SPI u8g2(U8G2_R0, OLED_CS, OLED_DC, OLED_RST);
Second group from SSD1306 to SH1106. It just seemed like the one to try and I gave it a go, and it worked!
Thanks!
BTW: This looks like a classic SH1106/SSD1306 mismatch.
It was a mismatch, but how do I know that? There's nothing on the display I'm using or the package or the manufacturer's website to tell me that. It seems like a fairly common issue, but if you don't know, how do you know?
This link says:
Internal driver chip: SSD1306
I found that page with a Google search for "keyestudio XC3728", but the page doesn't seem to mention "XC3728". Maybe it's a hidden part number that's embedded in the page's HTML code.
Found it. Tell me if this is inconvenient.
Start at the manufacturer's page for the display (the link you have above). Go to Documents. Link to the Wiki tutorial page. At the bottom is a link to Code, that opens Dropbox. Open the .pdf and scroll to page 9 at the bottom, where at least it's highlighted, and it's a SH1106.
"XC3728" must only be their part number.
The solution belongs to @david_2018: post #4

