WiFi Geolocation on the M5Stack Core2

Will guide you to build a Wifi Triangulation based Geo Location on the M5Stack Core 2

Every embedded designer hits the same wall: GPS doesn't work where embedded systems actually live. Inside buildings, under metal roofs, in warehouses, basements, tunnels, or dense city streets, a GPS module goes silent or takes minutes to acquire a fix while burning through the battery. A standard GPS receiver costs $5–15, demands a clear sky view, draws 20–100 mA, and needs 30–60 seconds (cold start) before the first coordinate. For a battery node that wakes up once an hour, that's unacceptable. And for a product meant to "just work" plugged into a wall anywhere, waiting for a sky you may not have is a design flaw, not a feature.

 

WiFi Geolocation on the M5Stack Core2

 

There's a smarter way. Everywhere there are people, there are Wi-Fi access points — homes, offices, malls, factories. Each has a permanent, globally unique hardware address (its BSSID). Open community databases have mapped hundreds of millions of these addresses to physical positions. This project shows how an ESP32 can scan those APs, ask a free open database where they are, reverse-geocode the result into a human-readable address — and show it all on a built-in screen with an animated RGB light bar, entirely without a GPS chip.

 

 

This is Wi-Fi fingerprinting / Wi-Fi localization — the same technique your phone uses for "assisted" location indoors — running on a microcontroller you can drop into any embedded product, with the status of the whole state machine painted in light.

 

 

 

Get PCBs for Your Projects Manufactured

You must check out PCBWAY for ordering PCBs online for cheap!

You get 10 good-quality PCBs manufactured and shipped to your doorstep for cheap. You will also get a discount on shipping on your first order. Upload your Gerber files onto PCBWAY to get them manufactured with good quality and quick turnaround time. PCBWay now could provide a complete product solution, from design to enclosure production. Check out their online Gerber viewer function. With reward points, you can get free stuff from their gift shop. Also, check out this useful blog on PCBWay Plugin for KiCad from here. Using this plugin, you can directly order PCBs in just one click after completing your design in KiCad.

 

 

 

Problems this project solves

1. GPS fails indoors. Wi-Fi is strongest exactly where GPS is weakest. This project flips the problem: it uses the infrastructure already around it.

2. GPS is expensive and power-hungry. A $5–15 module drawing 20–100 mA vs. a free radio scan that takes ~3 seconds — on silicon you already own.

3. Proprietary location APIs need accounts and keys. beaconDB is free, open, community-run, and needs no API key, no account, no billing — it is the open successor to the retired Mozilla Location Service, and it speaks the same JSON protocol. This fits an offline-first, no-cloud ethos perfectly.

4. Clouds are not neutral. Sending raw coordinates to a big-tech geolocation API means sending your position to someone else's database. beaconDB's model is community data for community use.

5. Invisible state machines are a debugging nightmare. This build turns the whole location pipeline into a visible, animated status — scan → connect → geolocate → fixed — so anyone can read the device like a mood ring.

6. Configuration-less operation. Plug in, it connects, it locates, it displays. No app, no account, no setup flow.

 

 

 

Things used

  • M5Stack Core2 for AWS IoT Kit (×1) — the ESP32 host unit; full specs in the hardware deep dive below

 

  • M5GO Bottom2 for AWS expansion base (×1) — brings the 10× SK6812 RGB LED bar on GPIO25
  • USB-C data cable (×1) — power, flashing, and the serial console
  • PC with internet (×1) — any OS

Zero jumpers, zero breadboard, zero external wiring — the whole project is the kit itself. The M5GO Bottom2 clicks onto the Core2 like a base station, and the LED bar is pre-wired; the only "hardware integration" this project needed was knowing which GPIO drives the LEDs.

 

 

 

Hardware deep dive — M5Stack Core2 for AWS IoT Kit

Core2 for AWS is a dedicated kit designed for AWS IoT learning projects, combining the M5Stack Core2 main control unit with the M5GO-Bottom for AWS expansion base and a custom ATECC608 Trust&GO hardware encryption chip for secure IoT development.

The Core2 unit is powered by the ESP32-D0WDQ6-V3 processor with dual Xtensa® 32-bit LX6 cores running up to 240MHz, offering WiFi support, 16MB Flash, and 8MB PSRAM, with programs downloadable via a TYPE-C interface. It features a 2.0-inch capacitive touchscreen for smooth interaction, a vibration motor for tactile feedback, an RTC module for precise timing, and the AXP192 chip for efficient power management. Additional components include a TF-card slot, speaker with I2S amplifier for clear audio, power and reset buttons, and three programmable virtual buttons on the touchscreen.

The M5GO-Bottom expansion base adds an MPU6886 six-axis motion sensor, digital microphone, 500mAh lithium battery, two HY2.0-4P expansion interfaces, 10 programmable RGB LEDs with frosted diffusion, and a pogo pin magnetic charging interface with TP4057 chip for safe charging and I2C bus exposure. It also integrates magnets and LEGO-compatible holes for versatile mounting. Together, the kit provides a robust platform for IoT learning, sensor integration, and secure communication at the hardware level.

 

 

 

How it works — the location pipeline

1. Scan. The ESP32 radio scans all visible access points (async, so the LED animations keep running). The 10 strongest APs (BSSID + RSSI) are selected — geolocation databases fix better on a small set of strong APs than on 40 weak ones.

2. Geolocate. A single TLS POST to https://api.beacondb.net/v1/geolocate with "considerIp": true returns lat/lng/accuracy. Where the database knows the APs, you get a ~50–150 m fix; where it doesn't, IP fallback gives a 25 km-level fix — your code only ever handles one response shape.

3. Reverse geocode. The coordinates go to OpenStreetMap's Nominatim (with a proper user-agent), which returns a human-readable address string . If Nominatim fails or rate-limits, the firmware auto-falls back to BigDataCloud.

4. Render. The 320×240 screen paints the state: status pill, lat/lng boxes, accuracy + AP count row, the wrapped address, and a footer with uptime, battery, and a countdown to the next fix.

 

 

 

Step 1 — Set up the toolchain

1. Install Arduino IDE 2.x (or arduino-cli).

2. Boards Manager → install esp32 by Espressif Systems (3.x).

3. Install libraries via Library Manager: M5Unified (0.2.x — brings M5GFX), Adafruit NeoPixel.

 

 

 

Step 2 — Configure the credentials

Open M5Core2_WiFi_Location.ino and set your network at the top:

CODE
// ================== USER CONFIG ==================
const char* WIFI_SSID = "YourWiFiName";
const char* WIFI_PASS = "YourWiFiPassword";
// How often to re-locate (ms)
const unsigned long REFRESH_MS = 30000UL;  // 30 seconds

Why 30 s and not faster? One locate cycle does ~15 s of blocking work
(scan + reconnect + TLS + reverse geocode). The 30 s cadence keeps the link
stable and the LED bar alive. For battery designs you'd raise this to minutes
or hours.

CODE
/*
 * AirTo — WiFi Geolocation on M5Stack Core2 for AWS
 * --------------------------------------------------------------------
 *
 * Board:   M5Stack Core2 for AWS   (FQBN: esp32:esp32:m5stack_core2)
 * Serial:  115200 baud (CP2104 on the Core2)
 *
 * LED bar (GPIO25, RMT, GRB 800kHz) — frame-based animations:
 *   rainbow wave sweep  = boot
 *   radar sweep         = scanning WiFi
 *   amber comet         = connecting
 *   cyan/magenta chase  = geolocating
 *   breathing green     = location fix OK
 *   red heartbeat       = no fix (retrying)
 */

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <math.h>
#include <M5Unified.h>
#include <Adafruit_NeoPixel.h>

// ---- SK6812 LED bar (10 LEDs, on the M5GO Bottom2 for AWS via M5-Bus) ----
#define LED_PIN   25
#define LED_COUNT 10
Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);

// ---- LED status modes ----
enum LedMode { LED_BOOT, LED_SCAN, LED_CONNECT, LED_GEO, LED_FIX, LED_NOFIX };
volatile LedMode ledMode = LED_BOOT;

// ================== USER CONFIG ==================
const char* WIFI_SSID = " ";
const char* WIFI_PASS = " ";

// How often to re-locate (ms).
const unsigned long REFRESH_MS = 30000UL;  // 30 seconds
// ================================================

// beaconDB geolocation endpoint (MLS-compatible)
const char* GEO_HOST = "api.beacondb.net";
const char* GEO_PATH = "/v1/geolocate";
const int   GEO_PORT = 443;

// ---- result state ----
struct GeoResult {
  bool   ok = false;
  double lat = 0, lng = 0;
  double accuracy = -1;   // metres
  int    apCount = 0;
};

unsigned long lastRun = 0;
GeoResult lastResult;
bool gotFix = false;
unsigned long lastFixMillis = 0;
String gArea = "";
String gIP = "0.0.0.0";
int gApCount = 0;
volatile bool gScanning = false;

// ---------------- screen palette ----------------
// pure function (no M5 object at static-init time)
static uint16_t c565(uint8_t r, uint8_t g, uint8_t b) { return (r >> 3) << 11 | (g >> 2) << 5 | (b >> 3); }

static const uint16_t COLOR_BG    = c565(11, 15, 26);
static const uint16_t COLOR_CARD  = c565(18, 26, 43);
static const uint16_t COLOR_LINE  = c565(34, 48, 74);
static const uint16_t COLOR_TEXT  = c565(230, 236, 245);
static const uint16_t COLOR_DIM   = c565(139, 152, 173);
static const uint16_t COLOR_GREEN = c565(46, 229, 157);
static const uint16_t COLOR_RED   = c565(243, 84, 76);
static const uint16_t COLOR_AMBER = c565(246, 199, 68);
static const uint16_t COLOR_BLUE  = c565(59, 130, 246);

// ---------------- WiFi + beaconDB ----------------

#define MAX_AP_LIST 64
#define MAX_APS 10    // send only the strongest APs — beaconDB fixes better with these

int buildScanBody(String& body) {
  Serial.println("Scanning WiFi...");
  // async scan — LED animation keeps running while WiFi scans
  int n = WiFi.scanNetworks(true /*async*/, true /*show hidden*/);
  unsigned long t0 = millis();
  while (n < 0 && millis() - t0 < 10000) {
    updateLEDs();
    delay(20);
    n = WiFi.scanComplete();
  }
  if (n <= 0) { WiFi.scanDelete(); body = ""; return 0; }

  struct Ap { int rssi; String bssid; String ssid; };
  Ap aps[MAX_AP_LIST];
  int cnt = 0;
  for (int i = 0; i < n && cnt < MAX_AP_LIST; i++) {
    aps[cnt].rssi = WiFi.RSSI(i);
    aps[cnt].bssid = WiFi.BSSIDstr(i);
    aps[cnt].ssid = WiFi.SSID(i);
    cnt++;
  }
  WiFi.scanDelete();

  // sort strongest first
  for (int i = 0; i < cnt; i++)
    for (int j = i + 1; j < cnt; j++)
      if (aps[j].rssi > aps[i].rssi) { Ap t = aps[i]; aps[i] = aps[j]; aps[j] = t; }

  int use = cnt < MAX_APS ? cnt : MAX_APS;
  gApCount = cnt;
  body = "{\"considerIp\":true,\"wifiAccessPoints\":[";
  for (int i = 0; i < use; i++) {
    if (i > 0) body += ",";
    body += "{\"macAddress\":\"";
    body += aps[i].bssid;
    body += "\",\"signalStrength\":";
    body += String(aps[i].rssi);
    body += "}";
  }
  body += "]}";
  Serial.printf("Found %d access points, using strongest %d.\n", cnt, use);
  return use;
}

// Extract a JSON string value that follows a key like "city":  (returns "" if missing).
String jsonString(const String& src, const char* key) {
  int k = src.indexOf(key);
  if (k < 0) return "";
  int c = src.indexOf(':', k);
  if (c < 0) return "";
  int q1 = src.indexOf('"', c);
  if (q1 < 0) return "";
  int q2 = src.indexOf('"', q1 + 1);
  if (q2 <= q1) return "";
  return src.substring(q1 + 1, q2);
}

double jsonNumber(const String& src, const char* key) {
  int k = src.indexOf(key);
  if (k < 0) return NAN;
  int c = src.indexOf(':', k);
  if (c < 0) return NAN;
  int i = c + 1;
  while (i < (int)src.length() && (src[i] == ' ' || src[i] == '\t')) i++;
  int j = i;
  while (j < (int)src.length()) {
    char ch = src[j];
    if ((ch >= '0' && ch <= '9') || ch == '-' || ch == '+' || ch == '.' ||
        ch == 'e' || ch == 'E') j++;
    else break;
  }
  if (j == i) return NAN;
  return src.substring(i, j).toDouble();
}

// Reverse geocode (lat,lng) -> human-readable area name. Free, no key:
//   1) Nominatim (OpenStreetMap)  2) BigDataCloud fallback
String reverseGeocode(double lat, double lng) {
  String area = "";

  WiFiClientSecure c;
  c.setInsecure();
  c.setTimeout(10);

  // --- Nominatim ---
  const char* host = "nominatim.openstreetmap.org";
  String path = String("/reverse?format=jsonv2&lat=") + String(lat, 6) +
                "&lon=" + String(lng, 6) + "&zoom=16";
  Serial.println("Reverse geocoding (Nominatim)...");
  if (c.connect(host, 443)) {
    c.print(String("GET ") + path + " HTTP/1.1\r\n");
    c.print(String("Host: ") + host + "\r\n");
    c.print("User-Agent: AirTo-GeoTest/1.0 ([email protected])\r\n");
    c.print("Connection: close\r\n\r\n");
    String resp;
    unsigned long t = millis();
    while ((c.connected() || c.available()) && millis() - t < 10000) {
      while (c.available()) { resp += (char)c.read(); t = millis(); }
      updateLEDs();
      delay(10);
    }
    c.stop();
    area = jsonString(resp, "\"display_name\"");
  }

  // --- BigDataCloud fallback ---
  if (area.length() == 0) {
    const char* host2 = "api.bigdatacloud.net";
    String path2 = String("/data/reverse-geocode-client?latitude=") + String(lat, 6) +
                   "&longitude=" + String(lng, 6) + "&localityLanguage=en";
    Serial.println("Nominatim failed, trying BigDataCloud...");
    if (c.connect(host2, 443)) {
      c.print(String("GET ") + path2 + " HTTP/1.1\r\n");
      c.print(String("Host: ") + host2 + "\r\n");
      c.print("User-Agent: AirTo-GeoTest/1.0\r\n");
      c.print("Connection: close\r\n\r\n");
      String resp;
      unsigned long t = millis();
      while ((c.connected() || c.available()) && millis() - t < 10000) {
        while (c.available()) { resp += (char)c.read(); t = millis(); }
        updateLEDs();
        delay(10);
      }
      c.stop();
      String city = jsonString(resp, "\"city\"");
      String sub  = jsonString(resp, "\"principalSubdivision\"");
      String ctry = jsonString(resp, "\"countryName\"");
      if (city.length() > 0) area = city;
      if (sub.length() > 0)  area += (area.length() ? ", " : "") + sub;
      if (ctry.length() > 0) area += (area.length() ? ", " : "") + ctry;
    }
  }

  return area;
}

void locate() {
  GeoResult r;
  lastResult = r;     // default = no fix; filled below on success
  gScanning = true;
  ledMode = LED_SCAN;

  WiFi.mode(WIFI_STA);
  WiFi.disconnect(true, false);
  tickAnim(100);

  String body;
  r.apCount = buildScanBody(body);
  gScanning = false;
  if (r.apCount < 2) {
    Serial.println("Not enough access points for a reliable fix.");
    lastResult = r;
    ledMode = LED_NOFIX;
    return;
  }

  Serial.printf("Connecting to \"%s\"...\n", WIFI_SSID);
  ledMode = LED_CONNECT;
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  unsigned long t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 20000) {
    tickAnim(250);
    Serial.print('.');
  }
  Serial.println();
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("WiFi connect failed.");
    lastResult = r;
    ledMode = LED_NOFIX;
    return;
  }
  Serial.print("IP: "); Serial.println(WiFi.localIP());
  gIP = WiFi.localIP().toString();

  ledMode = LED_GEO;

  WiFiClientSecure client;
  client.setInsecure();
  client.setTimeout(15);
  Serial.printf("POST https://%s%s\n", GEO_HOST, GEO_PATH);
  if (!client.connect(GEO_HOST, GEO_PORT)) {
    Serial.println("TLS connect failed.");
    lastResult = r;
    ledMode = LED_NOFIX;
    return;
  }

  client.print(String("POST ") + GEO_PATH + " HTTP/1.1\r\n");
  client.print(String("Host: ") + GEO_HOST + "\r\n");
  client.print("User-Agent: AirTo/1.0\r\n");
  client.print("Content-Type: application/json\r\n");
  client.print(String("Content-Length: ") + body.length() + "\r\n");
  client.print("Connection: close\r\n\r\n");
  client.print(body);

  String resp;
  unsigned long t = millis();
  while ((client.connected() || client.available()) && millis() - t < 15000) {
    while (client.available()) { resp += (char)client.read(); t = millis(); }
    updateLEDs();
    delay(10);
  }
  client.stop();

  int statusPos = resp.indexOf("HTTP/1.1 ");
  if (statusPos >= 0)
    Serial.println("Server: " + resp.substring(statusPos, resp.indexOf('\r', statusPos)));

  double lat = jsonNumber(resp, "\"lat\"");
  double lng = jsonNumber(resp, "\"lng\"");
  double acc = jsonNumber(resp, "\"accuracy\"");

  if (isnan(lat) || isnan(lng)) {
    Serial.println("No location in response. Body tail:");
    Serial.println(resp.substring(max(0, (int)resp.length() - 200)));
    lastResult = r;
    ledMode = LED_NOFIX;
    return;
  }

  r.ok = true;
  r.lat = lat; r.lng = lng; r.accuracy = acc;

  Serial.println("---- Location fix ----");
  Serial.printf("lat = %.6f\n", r.lat);
  Serial.printf("lng = %.6f\n", r.lng);
  if (!isnan(acc)) Serial.printf("accuracy = %.0f m\n", r.accuracy);

  String area = reverseGeocode(r.lat, r.lng);
  if (area.length() > 0) Serial.println("area = " + area);
  else Serial.println("area = (reverse geocode failed)");

  gArea = area;
  lastFixMillis = millis();
  lastResult = r;
  ledMode = LED_FIX;
}

// ---------------- SK6812 LED bar (frame-based animations) ----------------

#define FRAME_MS 30      // animation tick (~33 fps, smooth motion)

unsigned long lastFrameMs = 0;
int frame = 0;

// HSV -> RGB (h 0..255, s 0..255, v 0..255)
void hsvToRgb(uint8_t h, uint8_t s, uint8_t v, uint8_t& r, uint8_t& g, uint8_t& b) {
  uint8_t region = h / 43, p = (h - region * 43) * 6;
  uint8_t q = v * (255 - s) / 255;
  uint8_t t = v * (255 - ((s * p) / 255)) / 255;
  switch (region) {
    case 0: r = v; g = t; b = q; break;
    case 1: r = q; g = v; b = t; break;
    case 2: r = q; g = t; b = v; break;
    case 3: r = t; g = q; b = v; break;
    case 4: r = v; g = q; b = t; break;
    default: r = v; g = t; b = q; break;
  }
}

// boot: rainbow wave with a bright head bouncing along the bar
void animBoot() {
  int p = (frame / 2) % 18;
  if (p >= 9) p = 18 - p;
  for (int i = 0; i < LED_COUNT; i++) {
    uint8_t h = (uint8_t)(frame * 4 + i * 25);          // rotating rainbow
    int d = abs(i - p);
    uint8_t v = (d == 0) ? 85 : (d == 1) ? 42 : (d == 2) ? 18 : 8;
    uint8_t r, g, b;
    hsvToRgb(h, 255, v, r, g, b);
    strip.setPixelColor(i, r, g, b);
  }
}

// scanning: radar sweep — bright white-blue head + glowing tail, full bar on
void animScan() {
  int p = frame % 18;
  if (p >= 9) p = 18 - p;
  for (int i = 0; i < LED_COUNT; i++) {
    int d = abs(i - p);
    uint8_t v = (d == 0) ? 110 : (d == 1) ? 65 : (d == 2) ? 32 : 12;
    uint8_t r = v / 5, g = v / 2;                        // blue with white-ish head
    strip.setPixelColor(i, r, g, v);
  }
}

// connecting: amber comet orbiting the bar (faster)
void animConnect() {
  int h = (frame / 2) % 10;
  for (int i = 0; i < LED_COUNT; i++) {
    int d = (i - h + 10) % 10;                           // 0 at the head
    uint8_t v = (d == 0) ? 100 : (d == 1) ? 45 : (d == 2) ? 18 : 0;
    strip.setPixelColor(i, v, v * 3 / 4, 0);             // amber (R high, G lower)
  }
}

// geolocating: cyan + magenta comets chasing each other (faster)
void animGeo() {
  int h1 = (frame / 2) % 10;                             // forward
  int h2 = (20 - frame / 2) % 10;                        // backward
  for (int i = 0; i < LED_COUNT; i++) {
    int d1 = (i - h1 + 10) % 10;
    int d2 = (i - h2 + 10) % 10;
    uint8_t c1 = (d1 == 0) ? 90 : (d1 == 1) ? 35 : 0;    // cyan   (g+b)
    uint8_t c2 = (d2 == 0) ? 90 : (d2 == 1) ? 35 : 0;    // magenta (r+b)
    strip.setPixelColor(i, c2, c1, (c1 + c2) / 2);
  }
}

// fix: breathing green with a traveling sparkle + periodic glint
void animFix() {
  uint8_t base = (uint8_t)(22 + 28 * sinf(frame * 0.09f));   // ~2.2 s breath
  if ((frame % 90) < 3) base = 80;                           // glint every ~2.7 s
  int sp = (frame / 2) % 10;                                 // traveling sparkle
  for (int i = 0; i < LED_COUNT; i++) {
    uint8_t v = base + ((i == sp) ? 40 : 0);
    if (v > 100) v = 100;
    strip.setPixelColor(i, 0, v, 0);
  }
}

// no fix: red heartbeat — fast lub-dub, quick rest
void animNoFix() {
  int t = frame % 30;                                   // ~0.9 s cycle
  uint8_t v;
  if      (t < 2)  v = 90;                              // lub
  else if (t < 4)  v = 25;
  else if (t < 6)  v = 90;                              // dub
  else if (t < 9)  v = 25;
  else if (t < 20) v = 12;                              // slow decay
  else             v = 0;
  for (int i = 0; i < LED_COUNT; i++) strip.setPixelColor(i, v, 0, 0);
}

void updateLEDs() {
  unsigned long now = millis();
  if (now - lastFrameMs < FRAME_MS) return;
  lastFrameMs = now;
  frame++;

  switch (ledMode) {
    case LED_BOOT:    animBoot(); break;
    case LED_SCAN:    animScan(); break;
    case LED_CONNECT: animConnect(); break;
    case LED_GEO:     animGeo(); break;
    case LED_FIX:     animFix(); break;
    case LED_NOFIX:   animNoFix(); break;
  }
  strip.show();
}

// keep the LED animation running during a blocking wait
void tickAnim(unsigned long ms) {
  unsigned long t = millis() + ms;
  while (millis() < t) {
    updateLEDs();
    delay(10);
  }
}

// ---------------- screen UI ----------------

// ---------------- screen UI (classic GLCD fonts, original sizes) ----------------

static const auto F_TITLE = &fonts::Font2;
static const auto F_PILL  = &fonts::Font4;
static const auto F_LABEL = &fonts::Font0;
static const auto F_VALUE = &fonts::Font2;
static const auto F_AREA  = &fonts::Font2;
static const auto F_FOOT  = &fonts::Font0;

// wrap text into lines that fit maxW px (maxLines max); appends "…" to the last line when truncated
void wrapText(String& out, const char* text, int maxW, int maxLines) {
  out = "";
  String rest = text;
  int lines = 1;
  bool trunc = false;
  while (rest.length() > 0) {
    int sp = rest.indexOf(' ');
    if (sp < 0) sp = rest.length();
    String word = rest.substring(0, sp);
    String trial = out;
    if (trial.length() > 0 && M5.Display.textWidth(trial + " " + word) > maxW) {
      if (lines >= maxLines) { trunc = true; break; }
      trial += "\n";
      lines++;
    } else if (trial.length() > 0) {
      trial += " ";
    }
    trial += word;
    out = trial;
    rest = rest.substring(sp + 1);
  }
  if (trunc) out += "\u2026";   // ellipsis appended to the last line
}

unsigned long lastScreenMs = 0;
unsigned long bootMs = 0;

// last-drawn state (dirty checks — only repaint what actually changed)
enum PillState { PILL_NONE, PILL_SCAN, PILL_GEO, PILL_FIX };
PillState pillState = PILL_NONE;
String lastLat = "", lastLng = "", lastAcc = "", lastAp = "";
String lastArea = "", lastFootL = "", lastFootR = "", lastIP = "";
bool uiReady = false;

int currentPill() {   // returns a PillState — plain int so the Arduino
  if (gScanning || ledMode == LED_SCAN) return PILL_SCAN;   // auto-prototype
  if (ledMode == LED_CONNECT || ledMode == LED_GEO) return PILL_GEO;  // stays valid
  if (gotFix) return PILL_FIX;
  return PILL_NONE;
}

// static frame — drawn once, never touched again (no panel reset, no flicker)
void drawStatic() {
  M5.Display.setRotation(1);   // landscape 320x240
  M5.Display.fillScreen(COLOR_BG);
  M5.Display.setTextDatum(top_left);

  // header
  M5.Display.setTextColor(COLOR_TEXT, COLOR_BG);
  M5.Display.setFont(F_TITLE);
  M5.Display.drawString("AirTo  WiFi Locator", 12, 8);

  // coordinate boxes + labels
  M5.Display.fillRoundRect(12, 86, 145, 52, 8, COLOR_CARD);
  M5.Display.fillRoundRect(163, 86, 145, 52, 8, COLOR_CARD);
  M5.Display.setTextColor(COLOR_DIM, COLOR_CARD);
  M5.Display.setFont(F_LABEL);
  M5.Display.drawString("LATITUDE", 24, 92);
  M5.Display.drawString("LONGITUDE", 175, 92);

  // area label
  M5.Display.setTextColor(COLOR_DIM, COLOR_BG);
  M5.Display.drawString("AREA", 12, 162);
}

// status pill — only repaints when the state actually changes
void drawPill() {
  PillState ps = (PillState)currentPill();
  if (ps == pillState && uiReady) return;
  pillState = ps;
  uint16_t c;
  const char* t;
  switch (ps) {
    case PILL_SCAN: c = COLOR_BLUE;  t = "SCANNING";       break;
    case PILL_GEO:  c = COLOR_AMBER; t = "GEOLOCATING";    break;
    case PILL_FIX:  c = COLOR_GREEN; t = "LOCATION FIXED"; break;
    default:        c = COLOR_RED;   t = "NO FIX";         break;
  }
  M5.Display.fillRoundRect(12, 34, 296, 40, 10, c);
  M5.Display.setTextColor(COLOR_BG, c);
  M5.Display.setFont(F_PILL);
  M5.Display.drawCenterString(t, 160, 42);
}

// coordinate values — repaint only when the numbers change
void drawCoords() {
  String latS = gotFix ? String(lastResult.lat, 6) : "---";
  String lngS = gotFix ? String(lastResult.lng, 6) : "---";
  if (latS == lastLat && lngS == lastLng && uiReady) return;
  lastLat = latS; lastLng = lngS;
  M5.Display.fillRoundRect(12, 104, 145, 30, 6, COLOR_CARD);
  M5.Display.fillRoundRect(163, 104, 145, 30, 6, COLOR_CARD);
  M5.Display.setTextColor(COLOR_TEXT, COLOR_CARD);
  M5.Display.setFont(F_VALUE);
  M5.Display.drawString(latS, 24, 112);
  M5.Display.drawString(lngS, 175, 112);
}

// accuracy + AP count row
void drawMeta() {
  String accS = (gotFix && !isnan(lastResult.accuracy)) ? String((int)lastResult.accuracy) + " m" : "--";
  String apS = String(gApCount);
  if (accS == lastAcc && apS == lastAp && uiReady) return;
  lastAcc = accS; lastAp = apS;
  M5.Display.setTextColor(COLOR_DIM, COLOR_BG);
  M5.Display.setFont(F_LABEL);
  M5.Display.fillRect(12, 150, 160, 10, COLOR_BG);
  M5.Display.drawString("Accuracy  " + accS, 12, 150);
  M5.Display.fillRect(220, 150, 88, 10, COLOR_BG);
  M5.Display.drawString("APs  " + apS, 320 - 12 - M5.Display.textWidth("APs  " + apS), 150);
}

// area text — max 2 wrapped lines (fixed y, no multi-line spacing surprises),
// ellipsis when cut, always inside the screen
void drawArea() {
  String areaLine = gotFix && gArea.length() > 0 ? gArea : "Waiting for fix...";
  String wrapped;
  wrapText(wrapped, areaLine.c_str(), 296, 2);
  if (wrapped == lastArea && uiReady) return;
  lastArea = wrapped;
  M5.Display.fillRect(12, 178, 296, 36, COLOR_BG);   // clear 2 lines of Font2
  M5.Display.setTextColor(COLOR_TEXT, COLOR_BG);
  M5.Display.setFont(F_AREA);
  int nl = wrapped.indexOf('\n');
  String l1 = (nl < 0) ? wrapped : wrapped.substring(0, nl);
  String l2 = (nl < 0) ? "" : wrapped.substring(nl + 1);
  M5.Display.drawString(l1, 12, 178);
  if (l2.length() > 0) M5.Display.drawString(l2, 12, 196);
}

// IP — repaint only when it changes
void drawIP() {
  if (gIP == lastIP && uiReady) return;
  lastIP = gIP;
  M5.Display.setTextColor(COLOR_DIM, COLOR_BG);
  M5.Display.setFont(F_TITLE);
  M5.Display.fillRect(170, 8, 140, 16, COLOR_BG);
  M5.Display.drawString(gIP, 320 - 12 - M5.Display.textWidth(gIP), 9);
}

// footer: uptime + battery (left), next update countdown (right)
void drawFooter() {
  unsigned long up = (millis() - bootMs) / 1000;
  String upS = String(up / 3600) + "h" + String((up % 3600) / 60) + "m";
  unsigned long remain = (millis() - lastRun >= REFRESH_MS) ? 0 : REFRESH_MS - (millis() - lastRun);
  String nextS = gotFix ? "Next " + String(remain / 1000) + "s" : "Retrying";
  int bat = M5.Power.getBatteryLevel();
  String batS = bat >= 0 ? "Bat " + String(bat) + "%" : "USB";
  String footL = upS + "   " + batS;
  String footR = nextS;
  if (footL == lastFootL && footR == lastFootR && uiReady) return;
  lastFootL = footL; lastFootR = footR;
  M5.Display.setTextColor(COLOR_DIM, COLOR_BG);
  M5.Display.setFont(F_FOOT);
  M5.Display.fillRect(12, 222, 296, 10, COLOR_BG);
  M5.Display.drawString(footL, 12, 222);
  M5.Display.drawString(footR, 320 - 12 - M5.Display.textWidth(footR), 222);
}

// paint every dirty region (called from the 1 s tick)
void refreshUI() {
  drawPill();
  drawCoords();
  drawMeta();
  drawArea();
  drawIP();
  drawFooter();
  uiReady = true;
}

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

void setup() {
  Serial.begin(115200);
  delay(100);
  Serial.println("\nAirTo WiFi Geolocation (beaconDB) — M5Stack Core2 for AWS");

  auto cfg = M5.config();
  M5.begin(cfg);
  bootMs = millis();
  M5.Power.setLed(12);            // soft glow on the base green LED
  strip.begin();
  strip.clear();
  strip.show();
  ledMode = LED_BOOT;
  drawStatic();
  refreshUI();

  // show scanning state while the (blocking) first locate() runs
  gScanning = true;
  ledMode = LED_SCAN;
  refreshUI();
  locate();
  gotFix = lastResult.ok;
  lastRun = millis();
  refreshUI();
}

void loop() {
  M5.update();
  updateLEDs();

  unsigned long interval = gotFix ? REFRESH_MS : 10000UL;  // retry fast until we get a fix
  if (millis() - lastRun >= interval) {
    gScanning = true;
    ledMode = LED_SCAN;
    refreshUI();
    locate();
    gotFix = lastResult.ok;
    lastRun = millis();
    refreshUI();
  }

  if (millis() - lastScreenMs >= 1000) {   // refresh countdown etc. once per second
    lastScreenMs = millis();
    refreshUI();
  }
  delay(10);
}

Step 3 — Flash and watch

1. Plug the Core2 in (M5GO Bottom2 base attached) via USB-C.

2. Upload. The LED bar sweeps a rainbow — boot animation.

 

3. Serial Monitor at 115200 baud:

On the screen: the pill flips green ("LOCATION FIXED"), coordinates land in the boxes, the area wraps to two lines, and the LED bar settles into the breathing green sparkle. Every 30 s it quietly re-locates.

 

Use cases — where this actually matters

  • Indoor asset tracking (warehouses, hospitals) — GPS is dead indoors; APs are everywhere
  • Sensor nodes in smart buildings — self-locating nodes; no manual coordinate mapping after install
  • Rooftop / solar IoT monitors — cheaper + lower power than GPS for coarse location
  • Museum / retail guide beacons — zone-level positioning without extra infrastructure
  • Security & camera devices — report "which site" instantly; no GPS acquisition delay
  • Logistics crates & pallets — country/region-level fix is enough; GPS is overkill
  • Off-grid / no-cellular deployments — no SIM, no subscription; just Wi-Fi + internet
  • Battery-powered beacons — no always-on GPS drain; a scan takes ~3 seconds
  • Demo / education hardware — a visible, animated state machine on real hardware

The core motivation: replace a costly, power-hungry, sky-limited GPS module with a free, fast, works-anywhere-with-Wi-Fi alternative — at the cost of lower precision (50–100 m in well-mapped areas, IP-level fallback elsewhere), which is perfectly adequate for a huge slice of embedded applications.

 

 

Next steps

  • Battery + deep sleep — the Core2 has a LiPo; wake every 15 minutes, scan, locate, render, sleep
  • Community map sharing — round coordinates to 0.1° (~10 km) and post hourly to a cloud worker; anonymous, no personal data
  • BLE beacon fusion — add BLE scanning for room-level indoor position
  • Multi-device — several locators sharing the same screen via MQTT
  • Touch input — the Core2's touchscreen is idle; add tap-to-refresh or a "share now" button

 

 

 

Conclusion

This project showcases a practical embedded-systems approach that replaces a costly, power-hungry GPS module with a free, instant, and indoor-capable Wi-Fi-based alternative—while making every machine state visible in real time. Using a single click-together kit with no external wiring and just a few hundred lines of C++, it achieves a location fix indoors where GPS fails, displaying results on a physical screen. A 10-LED RGB bar transforms the entire pipeline into a dynamic, visual state machine, while a flicker-free user interface built on dirty-checking ensures smooth animations even during blocking network calls. Altogether, the firmware architecture is robust, scalable, and ready for battery-powered production hardware, proving that simplicity and efficiency can coexist beautifully in modern IoT design.

License
All Rights
Reserved
licensBg
0