Community project

Offline WiFi Map Viewer

Jerry Didyk

Published July 25, 2026

ESP320 components3 assembly steps
Remix this project
Photo of Offline WiFi Map Viewer

This project turns an M5Stack Cardputer into a portable offline WiFi access-point map viewer. Load a CSV file of WiFi networks with their locations onto a microSD card, and browse them on the device's built-in display without needing internet connectivity.

The guide provides a complete parts list, microSD card preparation steps, firmware upload instructions, and keyboard controls for panning and zooming the map. Builders will get a wiring diagram, the ready-to-compile Arduino sketch, and a sample CSV format for adding WiFi network data.

Wiring diagram

Interactive · read-only

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Assembly

3 steps
  1. Prepare the microSD card

    Format a microSD card as FAT32 and copy a plain-text file named wifi.csv into the card's root directory. Its first row may be the header SSID,BSSID,RSSI,CHANNEL,LATITUDE,LONGITUDE; each following row must contain those six comma-separated values.

    • Tip: Keep SSIDs free of unescaped commas because this simple CSV format uses commas as field separators.
    • Tip: The firmware reads one line at a time, so large files with thousands of rows are supported.
  2. Insert the card

    With the Cardputer powered off or idle, insert the microSD card into the built-in card slot with the contacts facing away from the screen.

    • Tip: The Cardputer’s built-in SD socket, display, and keyboard need no jumper wires.
    • Do not force the card; remove and rotate it if it does not slide in smoothly.
  3. Power and use the map

    Power the Cardputer from its normal USB-C port, then use Schematik’s Deploy button to flash the firmware. On the map, use the Cardputer arrow-key layer (Fn plus ;, comma, period, or slash) to pan, + or = to zoom in, - to zoom out, and Enter to select the next access point.

    • Tip: Green dots are stronger signals, then yellow/orange/red as signal strength decreases.
    • Tip: The white ring marks the selected access point; its SSID and RSSI appear in the bottom status area.

Firmware

ESP32
CardputerWifiMap.inoDeploy to device
#include <Arduino.h>
/*
  CardputerWifiMap.ino

  Offline Wi-Fi access-point map for M5Stack Cardputer (ESP32-S3).

  Put a FAT32-formatted microSD card in the Cardputer, with this file at:
    /wifi.csv

  CSV columns (a header row is allowed):
    SSID,BSSID,RSSI,CHANNEL,LATITUDE,LONGITUDE

  Controls:
    Fn + ; / , / . / /  : pan up / left / down / right (Cardputer arrows)
    + or =              : zoom in
    -                   : zoom out
    Enter               : select next valid AP

  The CSV is always processed one line at a time.  AP records are never kept
  in RAM: the complete file is rescanned only when the view must be redrawn.
*/

#include <M5Unified.h>     // Required M5 device/platform support
#include <M5Cardputer.h>   // Cardputer keyboard and board-specific support
#include <LovyanGFX.h>     // M5Unified/M5Cardputer Display is a LovyanGFX display
#include <SPI.h>
#include <SD.h>
#include <math.h>

// Cardputer's built-in microSD SPI wiring.

// Hoisted type definitions
struct AccessPoint {
  char ssid[80];
  int rssi;
  int channel;
  float latitude;
  float longitude;
};


// Forward declarations
static void trim(char *s);
static bool splitSixFields(char *line, char *field[6]);
static bool parseFloatStrict(const char *s, float &value);
static bool parseIntStrict(const char *s, int &value);
static bool parseAP(char *line, AccessPoint &ap);
static bool readCsvLine(File &file, char *buffer, size_t bufferSize, bool &tooLong);
static bool scanBoundsAndFirstAP();
static bool selectNextAP();
static uint16_t rssiColor(int rssi);
static int rssiRadius(int rssi);
static bool projectToScreen(float lat, float lon, int &x, int &y);
static void drawGrid();
static void drawTopBar();
static void drawBottomBar();
static void drawAllAPsStreaming();
static void redrawMap();
static void showMessage(const char *line1, const char *line2);
static void pan(float latitudeFraction, float longitudeFraction);
static void zoom(bool in);
static void handleKeyboard();

static constexpr int SD_SCK  = 40;
static constexpr int SD_MISO = 39;
static constexpr int SD_MOSI = 14;
static constexpr int SD_CS   = 12;

static constexpr const char *CSV_PATH = "/wifi.csv";
static constexpr size_t LINE_BUFFER_SIZE = 256; // Long CSV lines are safely skipped.
static constexpr int SCREEN_W = 240;
static constexpr int SCREEN_H = 135;
static constexpr int TOP_BAR_H = 12;
static constexpr int BOTTOM_BAR_H = 24;
static constexpr int MAP_TOP = TOP_BAR_H;
static constexpr int MAP_BOTTOM = SCREEN_H - BOTTOM_BAR_H - 1;
static constexpr int MAP_H = MAP_BOTTOM - MAP_TOP + 1;



static char lineBuffer[LINE_BUFFER_SIZE];
static bool sdReady = false;
static bool haveData = false;
static uint32_t recordCount = 0;
static uint32_t selectedIndex = 0;
static uint32_t selectedOffset = 0;
static uint32_t selectedNextOffset = 0;
static AccessPoint selectedAP;

// Geographic extent found during the first streaming pass.
static float minLat, maxLat, minLon, maxLon;
static float centerLat, centerLon;
static float zoomLevel = 1.0f;

// ---------- Small, allocation-free CSV helpers ----------

static void trim(char *s) {
  char *start = s;
  while (*start == ' ' || *start == '\t' || *start == '\r') ++start;
  if (start != s) {
    char *dst = s;
    while ((*dst++ = *start++) != '\0') {}
  }
  size_t n = strlen(s);
  while (n && (s[n - 1] == ' ' || s[n - 1] == '\t' || s[n - 1] == '\r')) {
    s[--n] = '\0';
  }
}

// Returns pointers to six comma-separated fields, changing commas to NUL.
static bool splitSixFields(char *line, char *field[6]) {
  field[0] = line;
  uint8_t count = 1;
  for (char *p = line; *p; ++p) {
    if (*p == ',') {
      *p = '\0';
      if (count < 6) field[count] = p + 1;
      ++count;
    }
  }
  if (count != 6) return false;
  for (uint8_t i = 0; i < 6; ++i) trim(field[i]);
  return true;
}

static bool parseFloatStrict(const char *s, float &value) {
  char *endPtr;
  value = strtof(s, &endPtr);
  return endPtr != s && *endPtr == '\0' && isfinite(value);
}

static bool parseIntStrict(const char *s, int &value) {
  char *endPtr;
  long temp = strtol(s, &endPtr, 10);
  if (endPtr == s || *endPtr != '\0') return false;
  value = (int)temp;
  return true;
}

static bool parseAP(char *line, AccessPoint &ap) {
  char *field[6];
  if (!splitSixFields(line, field)) return false;

  int rssi, channel;
  float lat, lon;
  if (!parseIntStrict(field[2], rssi) || !parseIntStrict(field[3], channel) ||
      !parseFloatStrict(field[4], lat) || !parseFloatStrict(field[5], lon)) {
    return false; // This also cleanly rejects the header row.
  }
  if (lat < -90.0f || lat > 90.0f || lon < -180.0f || lon > 180.0f) return false;

  strncpy(ap.ssid, field[0], sizeof(ap.ssid) - 1);
  ap.ssid[sizeof(ap.ssid) - 1] = '\0';
  ap.rssi = rssi;
  ap.channel = channel;
  ap.latitude = lat;
  ap.longitude = lon;
  return true;
}

// Reads one line into the fixed buffer. Lines too long for the buffer are
// consumed and rejected, protecting parsing and memory use.
static bool readCsvLine(File &file, char *buffer, size_t bufferSize, bool &tooLong) {
  tooLong = false;
  size_t length = 0;
  while (file.available()) {
    int c = file.read();
    if (c == '\n') {
      buffer[length] = '\0';
      return true;
    }
    if (c == '\r') continue;
    if (length + 1 < bufferSize) {
      buffer[length++] = (char)c;
    } else {
      tooLong = true;
    }
  }
  if (length || tooLong) {
    buffer[length] = '\0';
    return true;
  }
  return false;
}

// ---------- Streaming file operations ----------

static bool scanBoundsAndFirstAP() {
  File file = SD.open(CSV_PATH, FILE_READ);
  if (!file) return false;

  bool found = false;
  bool tooLong;
  AccessPoint ap;
  recordCount = 0;
  selectedOffset = selectedNextOffset = 0;

  while (file.available()) {
    uint32_t offset = file.position();
    if (!readCsvLine(file, lineBuffer, sizeof(lineBuffer), tooLong) || tooLong) continue;
    if (!parseAP(lineBuffer, ap)) continue;

    if (!found) {
      minLat = maxLat = ap.latitude;
      minLon = maxLon = ap.longitude;
      selectedAP = ap;
      selectedOffset = offset;
      selectedNextOffset = file.position();
      found = true;
    } else {
      if (ap.latitude < minLat) minLat = ap.latitude;
      if (ap.latitude > maxLat) maxLat = ap.latitude;
      if (ap.longitude < minLon) minLon = ap.longitude;
      if (ap.longitude > maxLon) maxLon = ap.longitude;
    }
    ++recordCount;
  }
  file.close();

  if (!found) return false;
  // Avoid a zero-sized map if all APs have the same coordinate.
  if (maxLat - minLat < 0.00001f) { minLat -= 0.00005f; maxLat += 0.00005f; }
  if (maxLon - minLon < 0.00001f) { minLon -= 0.00005f; maxLon += 0.00005f; }
  centerLat = (minLat + maxLat) * 0.5f;
  centerLon = (minLon + maxLon) * 0.5f;
  selectedIndex = 0;
  return true;
}

static bool selectNextAP() {
  File file = SD.open(CSV_PATH, FILE_READ);
  if (!file) return false;

  // Search from immediately after the current selected record.
  uint32_t start = selectedNextOffset;
  bool wrapped = false;
  bool tooLong;
  AccessPoint ap;

  while (true) {
    if (file.position() >= file.size()) {
      if (wrapped) break;
      file.seek(0);
      wrapped = true;
    }
    uint32_t offset = file.position();
    if (!readCsvLine(file, lineBuffer, sizeof(lineBuffer), tooLong)) {
      if (wrapped) break;
      file.seek(0);
      wrapped = true;
      continue;
    }
    if (tooLong || !parseAP(lineBuffer, ap)) continue;

    // Do not select the same record after wrapping a one-record file.
    if (wrapped && offset == selectedOffset) break;
    selectedAP = ap;
    selectedOffset = offset;
    selectedNextOffset = file.position();
    selectedIndex = (selectedIndex + 1) % recordCount;
    file.close();
    return true;
  }
  file.close();
  return true;
}

// ---------- Map drawing ----------

static uint16_t rssiColor(int rssi) {
  if (rssi >= -55) return TFT_GREEN;
  if (rssi >= -70) return TFT_YELLOW;
  if (rssi >= -82) return TFT_ORANGE;
  return TFT_RED;
}

static int rssiRadius(int rssi) {
  if (rssi >= -55) return 3;
  if (rssi >= -70) return 2;
  return 1;
}

static bool projectToScreen(float lat, float lon, int &x, int &y) {
  const float rangeLon = maxLon - minLon;
  const float rangeLat = maxLat - minLat;
  const float pixelsPerLon = (float)(SCREEN_W - 8) / rangeLon * zoomLevel;
  const float pixelsPerLat = (float)(MAP_H - 8) / rangeLat * zoomLevel;
  x = SCREEN_W / 2 + (int)((lon - centerLon) * pixelsPerLon);
  y = MAP_TOP + MAP_H / 2 - (int)((lat - centerLat) * pixelsPerLat);
  return x >= 0 && x < SCREEN_W && y >= MAP_TOP && y <= MAP_BOTTOM;
}

static void drawGrid() {
  M5Cardputer.Display.fillRect(0, MAP_TOP, SCREEN_W, MAP_H, TFT_DARKGREY);
  M5Cardputer.Display.drawRect(0, MAP_TOP, SCREEN_W, MAP_H, TFT_LIGHTGREY);
  for (int x = 20; x < SCREEN_W; x += 20)
    M5Cardputer.Display.drawFastVLine(x, MAP_TOP + 1, MAP_H - 2, 0x39E7);
  for (int y = MAP_TOP + 16; y < MAP_BOTTOM; y += 16)
    M5Cardputer.Display.drawFastHLine(1, y, SCREEN_W - 2, 0x39E7);
  M5Cardputer.Display.drawFastHLine(0, MAP_TOP + MAP_H / 2, SCREEN_W, 0x7BEF);
  M5Cardputer.Display.drawFastVLine(SCREEN_W / 2, MAP_TOP, MAP_H, 0x7BEF);
}

static void drawTopBar() {
  M5Cardputer.Display.fillRect(0, 0, SCREEN_W, TOP_BAR_H, TFT_NAVY);
  M5Cardputer.Display.setTextColor(TFT_WHITE, TFT_NAVY);
  M5Cardputer.Display.setTextSize(1);
  M5Cardputer.Display.setCursor(2, 2);
  M5Cardputer.Display.printf("AP %lu/%lu  zoom %.1fx", (unsigned long)(selectedIndex + 1),
                             (unsigned long)recordCount, zoomLevel);
}

static void drawBottomBar() {
  M5Cardputer.Display.fillRect(0, MAP_BOTTOM + 1, SCREEN_W, BOTTOM_BAR_H, TFT_BLACK);
  M5Cardputer.Display.setTextColor(rssiColor(selectedAP.rssi), TFT_BLACK);
  M5Cardputer.Display.setTextSize(1);
  M5Cardputer.Display.setCursor(2, MAP_BOTTOM + 3);
  M5Cardputer.Display.printf("%s", selectedAP.ssid[0] ? selectedAP.ssid : "<hidden SSID>");
  M5Cardputer.Display.setCursor(2, MAP_BOTTOM + 13);
  M5Cardputer.Display.setTextColor(TFT_WHITE, TFT_BLACK);
  M5Cardputer.Display.printf("%d dBm  ch %d  Enter=next", selectedAP.rssi, selectedAP.channel);
}

static void drawAllAPsStreaming() {
  File file = SD.open(CSV_PATH, FILE_READ);
  if (!file) return;
  bool tooLong;
  AccessPoint ap;
  while (file.available()) {
    uint32_t offset = file.position();
    if (!readCsvLine(file, lineBuffer, sizeof(lineBuffer), tooLong) || tooLong) continue;
    if (!parseAP(lineBuffer, ap)) continue;
    int x, y;
    if (projectToScreen(ap.latitude, ap.longitude, x, y)) {
      int radius = rssiRadius(ap.rssi);
      uint16_t color = rssiColor(ap.rssi);
      M5Cardputer.Display.fillCircle(x, y, radius, color);
      if (offset == selectedOffset) {
        M5Cardputer.Display.drawCircle(x, y, radius + 2, TFT_WHITE);
      }
    }
  }
  file.close();
}

static void redrawMap() {
  drawGrid();
  drawAllAPsStreaming();
  drawTopBar();
  drawBottomBar();
}

static void showMessage(const char *line1, const char *line2) {
  M5Cardputer.Display.fillScreen(TFT_BLACK);
  M5Cardputer.Display.setTextColor(TFT_WHITE, TFT_BLACK);
  M5Cardputer.Display.setTextSize(1);
  M5Cardputer.Display.setCursor(8, 42);
  M5Cardputer.Display.print(line1);
  M5Cardputer.Display.setCursor(8, 58);
  M5Cardputer.Display.print(line2);
}

static void pan(float latitudeFraction, float longitudeFraction) {
  // A fraction of the currently visible geographical span gives predictable pan.
  centerLat += (maxLat - minLat) * latitudeFraction / zoomLevel;
  centerLon += (maxLon - minLon) * longitudeFraction / zoomLevel;
  redrawMap();
}

static void zoom(bool in) {
  zoomLevel = in ? zoomLevel * 1.35f : zoomLevel / 1.35f;
  if (zoomLevel < 0.35f) zoomLevel = 0.35f;
  if (zoomLevel > 30.0f) zoomLevel = 30.0f;
  redrawMap();
}

static void handleKeyboard() {
  M5Cardputer.update();
  if (!M5Cardputer.Keyboard.isChange() || !M5Cardputer.Keyboard.isPressed()) return;

  // The Cardputer's arrow layer is Fn + ; , . /.  HID values are used so this
  // works regardless of keyboard layout. (0x33 ;, 0x36 ,, 0x37 ., 0x38 /)
  const auto &keys = M5Cardputer.Keyboard.keysState();
  if (keys.fn) {
    for (auto hid : keys.hid_keys) {
      if (hid == 0x33) { pan(+0.20f, 0.0f); return; } // Up
      if (hid == 0x36) { pan(0.0f, -0.20f); return; } // Left
      if (hid == 0x37) { pan(-0.20f, 0.0f); return; } // Down
      if (hid == 0x38) { pan(0.0f, +0.20f); return; } // Right
    }
  }

  // word is the resolved printable character(s), with no heap allocation here.
  const char *word = keys.word.c_str();
  if (strchr(word, '+') || strchr(word, '=')) {
    zoom(true);
  } else if (strchr(word, '-')) {
    zoom(false);
  } else {
    for (auto hid : keys.hid_keys) {
      if (hid == KEY_ENTER || hid == 0x28) {
        selectNextAP();
        redrawMap();
        return;
      }
    }
  }
}

void setup() {
  auto cfg = M5.config();
  M5Cardputer.begin(cfg, true); // true enables the built-in keyboard
  M5Cardputer.Display.setRotation(1);
  M5Cardputer.Display.setTextWrap(false);
  M5Cardputer.Display.setTextSize(1);
  showMessage("Offline WiFi map", "Starting SD card...");

  SPI.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
  sdReady = SD.begin(SD_CS, SPI, 25000000);
  if (!sdReady) {
    showMessage("microSD init failed", "Insert FAT32 card and restart");
    return;
  }
  if (!SD.exists(CSV_PATH)) {
    showMessage("/wifi.csv not found", "Place it in card root");
    return;
  }

  showMessage("Scanning CSV...", "Streaming bounds only");
  haveData = scanBoundsAndFirstAP();
  if (!haveData) {
    showMessage("No valid AP rows", "Check CSV columns/coordinates");
    return;
  }
  redrawMap();
}

void loop() {
  if (sdReady && haveData) handleKeyboard();
  delay(5); // Keeps keyboard polling responsive without a busy loop.
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik