2025年7月20日日曜日

ESP32でGPSラップタイマーを自作してみる

 知り合いがGPSラップタイマーが欲しいけど高いよーとか言っているのでこの前購入したGPSモジュールで試しに作ってみることに。ATGM336Hのスペック上10Hzまで行けるらしいし。

とりあえずESP32でGPSのMNEAセンテンスを読んで、指定している座標Aと座標Bの間を通ったらカウントする感じ。これならタイム測定ポイントを正確に指定できるかなと。最終的にはWeb設定画面を表示して設定変更したり、内部メモリで選択できるようにしたいな。

まずはちゃんと座標間を通ったときにカウントできるかのテスト。最初に通ったらカウントを開始して、2回目通ったらLAPタイムをSerial.printするだけのテスト。M5Atom Liteで動作確認してみた。

#define GPS_RX 32
#define GPS_TX 26

char nmeaLine[128];
int idx = 0;
double LAT0 = 38.14;
double LON0 = 140.77;

HardwareSerial GPS(2);

struct Vec2 {
  double x;
  double y;
  uint64_t time_ms;  // 経過ms
};

struct Line {
  double x1, y1, x2, y2;
};

Vec2 prevPos;
bool firstFix = true;
bool firstLineCross = true;
uint64_t lastLapTime = 0;

Vec2 latlonToXY(double lat, double lon, uint64_t t) {
  Vec2 p;
  p.x = (lon - LON0) * cos(LAT0 * DEG_TO_RAD) * 111320.0;
  p.y = (lat - LAT0) * 110540.0;
  p.time_ms = t;
  return p;
}

double cross(double ax, double ay, double bx, double by) {
  return ax * by - ay * bx;
}

double signedDistance(const Vec2& p, const Line& L) {
  double lx = L.x2 - L.x1;
  double ly = L.y2 - L.y1;
  double px = p.x - L.x1;
  double py = p.y - L.y1;
  return cross(lx, ly, px, py);
}

bool checkLineCross(const Vec2& prev, const Vec2& now, const Line& line, uint64_t& crossTime) {
  double d1 = signedDistance(prev, line);
  double d2 = signedDistance(now, line);
  if (d1 * d2 >= 0) return false;
  // 交点の補間
  double ratio = fabs(d1) / (fabs(d1) + fabs(d2));
  double cx = prev.x + (now.x - prev.x) * ratio;
  double cy = prev.y + (now.y - prev.y) * ratio;
  // 線分範囲内判定
  double minX = fmin(line.x1, line.x2);
  double maxX = fmax(line.x1, line.x2);
  double minY = fmin(line.y1, line.y2);
  double maxY = fmax(line.y1, line.y2);
  if (cx < minX || cx > maxX || cy < minY || cy > maxY)
    return false;  // 線分の延長上なら無視
  crossTime = prev.time_ms + (uint64_t)((now.time_ms - prev.time_ms) * ratio);
  return true;
}
Line startLine;
void setupStartLine() {
  Vec2 A = latlonToXY(38.142650747977115, 140.77813371163867, 0);
  Vec2 B = latlonToXY(38.14193773632026, 140.77804079991844, 0);
  startLine = { A.x, A.y, B.x, B.y };
}

double nmeaToDeg(const char* val, const char* dir) {
  double raw = atof(val);
  int deg = (int)(raw / 100);
  double min = raw - deg * 100;
  double result = deg + min / 60.0;

  if (dir[0] == 'S' || dir[0] == 'W')
    result *= -1;
  return result;
}

void parseRMC(char* line) {
  char* token;
  int field = 0;
  char utc[16] = { 0 };
  char status[2] = { 0 };
  char lat[16] = { 0 }, lon[16] = { 0 };
  char ns[2] = { 0 }, ew[2] = { 0 };
  char date[8] = { 0 };
  token = strtok(line, ",");
  while (token) {
    field++;
    if (field == 2) strcpy(utc, token);
    if (field == 3) strcpy(status, token);
    if (field == 4) strcpy(lat, token);
    if (field == 5) strcpy(ns, token);
    if (field == 6) strcpy(lon, token);
    if (field == 7) strcpy(ew, token);
    if (field == 10) strcpy(date, token);
    token = strtok(NULL, ",");
  }
  if (status[0] != 'A') return;
  double latitude = nmeaToDeg(lat, ns);
  double longitude = nmeaToDeg(lon, ew);
  uint64_t gpsTime = esp_timer_get_time() / 1000;  // μs → ms

  if (firstFix) {
    LAT0 = latitude;
    LON0 = longitude;
    setupStartLine();
    prevPos = latlonToXY(latitude, longitude, gpsTime);
    firstFix = false;
    Serial.println("Fix complete, waiting for START line...");
    return;
  }
  Vec2 pos = latlonToXY(latitude, longitude, gpsTime);
  // ラップ判定
  uint64_t crossTime;
  if (checkLineCross(prevPos, pos, startLine, crossTime)) {
    if (firstLineCross) {
      lastLapTime = crossTime;
      firstLineCross = false;
      Serial.println("START!");
    } else if (crossTime - lastLapTime > 8000) {
      uint64_t lapMs = crossTime - lastLapTime;
      uint32_t minutes = lapMs / 60000;
      uint32_t seconds = (lapMs % 60000) / 1000;
      uint32_t millisec = lapMs % 1000;
      lastLapTime = crossTime;
      Serial.printf("LAP! %02u:%02u.%03u\n", minutes, seconds, millisec);
    }
  }
  prevPos = pos;
}

void readGPS() {
  while (GPS.available()) {
    char c = GPS.read();
    if (c == '\n') {
      nmeaLine[idx] = 0;
      if (strstr(nmeaLine, "GNRMC") || strstr(nmeaLine, "GPRMC")) {
        parseRMC(nmeaLine);
      }
      idx = 0;
    } else if (idx < 127)
      nmeaLine[idx++] = c;
  }
}
void setup() {
  Serial.begin(115200);
  GPS.begin(115200, SERIAL_8N1, GPS_RX, GPS_TX);
  setupStartLine();  // 初期値
  Serial.println("GPS LapTimer");
}
void loop() {
  readGPS();
}

実際にサーキットを走ったりして確認するのは大変なので、まずは先日作ったシリアルロガーで、近所を歩いて一周してきたログをPythonでシリアルポートに流して動作確認をしてみたが、とりあえずは動いてそう。10Hzで取ったログを10倍速とかで流してもちゃんと反応してくれる。(Baudrate的に本当に10倍速になってるかはわからないけど…)
ちなみにMNEAセンテンスは絞ってる。

あとはディスプレイに表示できるようにしないと。屋外でも視認性が良さそうなディスプレイを探さないとな。あとはセクタータイム測定したり、GPSのデータをそのままロギングできたら便利そう。

2025年7月19日土曜日

ESP32でシリアルデータをロギングしてみた。

 先日ATGM336Hの動作確認ができたので、実際にGPSから出ているデータをロギングしてみることに。

今回はESP32とWebSocketを使って、ブラウザ上にデータを表示してそれをログデータとして保存してみようと思う。外でノートパソコンを持ってウロウロするのもアレだし…

#include <WiFi.h>
#include <WebServer.h>
#include <WebSocketsServer.h>

const char* ssid = "ESP32_LOG";
const char* password = "12345678";

WebServer server(80);
WebSocketsServer webSocket = WebSocketsServer(8080);

#define RXD2 32
#define TXD2 26 

const char MAIN_page[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>ESP32 Serial2 Logger</title>
<style>
body { font-family: monospace; margin:20px; }
textarea {
  width:100%;
  height:400px;
}
button {
  font-size:18px;
  padding:8px 20px;
}
</style>
</head>
<body>
<h2>ESP32 UART2 Monitor</h2>
<textarea id="log"></textarea><br><br>
<button onclick="saveLog()">保存してダウンロード</button>
<script>
let ws;
function connectWS(){
  ws = new WebSocket("ws://" + location.hostname + ":8080/");
  ws.onmessage = function(event) {
    const ta = document.getElementById("log");
    ta.value += event.data;
    ta.scrollTop = ta.scrollHeight;
  };
  ws.onclose = function(){
    // 自動再接続
    setTimeout(connectWS, 2000);
  };
}
connectWS();
function saveLog() {
  const text = document.getElementById("log").value;
  const blob = new Blob([text], {type: "text/plain"});
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "serial2_log.txt";
  a.click();
  URL.revokeObjectURL(url);
}
</script>
</body>
</html>
)rawliteral";

void setup()
{
  Serial.begin(115200);
  Serial2.begin(115200, SERIAL_8N1, RXD2, TXD2);
  WiFi.softAP(ssid, password);
  Serial.println("AP Started");
  Serial.println(WiFi.softAPIP());
  server.on("/", []() {
    server.send_P(200, "text/html", MAIN_page);
  });
  server.begin();
  webSocket.begin();
}

void loop()
{
  server.handleClient();
  webSocket.loop();

  static String line = "";

  while (Serial2.available())
  {
    char c = Serial2.read();
    //Serial.write(c);
    line += c;
    if (c == '\n')
    {
      webSocket.broadcastTXT(line);
      line = "";
    }
  }
}

こんな感じでとりあえずブラウザのtextareaに淡々と表示し続けて最後にsaveボタンを押す簡単なやつにしてみた。

10Hz設定だと流石に流れるのが早すぎるのか表示が追いついていない気がする。WiFi切ってもしばらく流れてるし。とりあえずiPhoneで保存したやつをPCに送って拡張子を.nmeaに変更。GSXSeeで開くとちゃんと軌跡が表示できた。

2025年7月14日月曜日

ESP32でGPSをLCDに表示させてみる。

 というわけで前回作ったボードにGPSを繋いでみた。

/*
  ============================================================
  ESP32-S3 Hardware Test Menu
  Arduino-ESP32 
  ============================================================

  OLED
    SH1107 128x128
    SDA GPIO1
    SCL GPIO2

  Button ADC
    GPIO4

  GPS
    ATGM336H
    RX GPIO15
    TX GPIO16
    9600 baud

  ICM-42670-P
    SCK  GPIO12
    MISO GPIO13
    MOSI GPIO11
    CS   GPIO10

  ============================================================
  GPS
  ============================================================

  GGA:
    Fix Quality
    Used satellites

  GSA:
    Fix Type
      1 = No Fix
      2 = 2D Fix
      3 = 3D Fix

  GSV:
    Visible satellites

  QZSS:
    PRN 193-199

  ============================================================
*/

#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <WiFi.h>
#include <U8g2lib.h>
#include <HardwareSerial.h>
#include <time.h>
#include <sys/time.h>

// ============================================================
// DEBUG
// ============================================================

#define DEBUG_SERIAL 0

#if DEBUG_SERIAL

  #define DBG_BEGIN(x) Serial.begin(x)
  #define DBG_PRINT(x) Serial.print(x)
  #define DBG_PRINTLN(x) Serial.println(x)
  #define DBG_PRINTF(...) Serial.printf(__VA_ARGS__)

#else

  #define DBG_BEGIN(x)
  #define DBG_PRINT(x)
  #define DBG_PRINTLN(x)
  #define DBG_PRINTF(...)

#endif

// ============================================================
// OLED
// ============================================================

#define OLED_SDA 1
#define OLED_SCL 2

U8G2_SH1107_SEEED_128X128_F_HW_I2C u8g2(
  U8G2_R0,
  U8X8_PIN_NONE
);

// ============================================================
// BUTTON
// ============================================================

#define BUTTON_ADC_PIN 4

enum Button {

  BTN_NONE,
  BTN_UP,
  BTN_DOWN,
  BTN_PUSH
};

Button readButtonRaw() {

  int raw =
    analogRead(
      BUTTON_ADC_PIN
    );

  if (
    raw < 1000
  ) {

    return BTN_PUSH;
  }

  if (
    raw < 1850
  ) {

    return BTN_DOWN;
  }

  if (
    raw < 2800
  ) {

    return BTN_UP;
  }

  return BTN_NONE;
}

Button getButton() {

  static Button lastButton =
    BTN_NONE;

  Button current =
    readButtonRaw();

  if (
    current != BTN_NONE &&
    lastButton == BTN_NONE
  ) {

    delay(20);

    Button check =
      readButtonRaw();

    if (
      check == current
    ) {

      lastButton =
        current;

      return current;
    }
  }

  if (
    current == BTN_NONE
  ) {

    lastButton =
      BTN_NONE;
  }

  return BTN_NONE;
}

// ============================================================
// GPS
// ============================================================

#define GPS_RX_PIN 15
#define GPS_TX_PIN 16
#define GPS_BAUD   9600

HardwareSerial GPS(1);

String gpsLine;

// ============================================================
// LAST NMEA
// ============================================================

String gpsLastLines[6];

void addGPSLine(
  const String &line
) {

  for (
    int i = 5;
    i > 0;
    i--
  ) {

    gpsLastLines[i] =
      gpsLastLines[i - 1];
  }

  gpsLastLines[0] =
    line;
}

// ============================================================
// GPS TIME
// ============================================================

bool gpsTimeValid =
  false;

int gpsHour = 0;
int gpsMinute = 0;
int gpsSecond = 0;

int gpsDay = 0;
int gpsMonth = 0;
int gpsYear = 0;

bool rtcSyncedFromGPS =
  false;

time_t gpsSyncEpoch = 0;

// ============================================================
// GPS FIX
// ============================================================
//
// GGA Fix Quality:
//
// 0 = invalid
// 1 = GPS fix
// 2 = DGPS
// 4 = RTK Fixed
// 5 = RTK Float
//
// GSA Fix Type:
//
// 1 = No Fix
// 2 = 2D
// 3 = 3D
// ============================================================

int gpsFixQuality = 0;
int gpsFixType = 1;

// ============================================================
// FIX TEXT
// ============================================================

const char* getFixTypeText() {

  switch (
    gpsFixType
  ) {

    case 1:
      return "NOFIX";

    case 2:
      return "2D";

    case 3:
      return "3D";

    default:
      return "--";
  }
}

// ============================================================
// GGA FIX TEXT
// ============================================================

const char* getFixQualityText() {

  switch (
    gpsFixQuality
  ) {

    case 0:
      return "NOFIX";

    case 1:
      return "GPS";

    case 2:
      return "DGPS";

    case 4:
      return "RTK";

    case 5:
      return "FLOAT";

    default:
      return "FIX";
  }
}

// ============================================================
// SATELLITE COUNTERS
// ============================================================

// GGA
int gpsUsedTotal = 0;

// GSV confirmed values
int gpsVisible = 0;
int qzssVisible = 0;
int beidouVisible = 0;
int glonassVisible = 0;
int galileoVisible = 0;

int gpsGpgsvTotal = 0;

// ============================================================
// USED SATELLITES
// ============================================================

int gpsUsedGPS = 0;
int gpsUsedQZSS = 0;
int gpsUsedBeiDou = 0;
int gpsUsedGLONASS = 0;
int gpsUsedGalileo = 0;

// ============================================================
// QZSS
// ============================================================

#define MAX_QZSS 10

struct QZSSInfo {

  int prn;
  int elevation;
  int azimuth;
  int snr;
  bool used;
};

QZSSInfo qzss[MAX_QZSS];

int qzssCount = 0;

// ============================================================
// GSV TEMP
// ============================================================

int gpgsvTempGPS = 0;
int gpgsvTempQZSS = 0;

int bdgsvTemp = 0;
int glgsvTemp = 0;
int gagsvTemp = 0;

QZSSInfo qzssTemp[MAX_QZSS];

int qzssTempCount = 0;

// ============================================================
// QZSS PRN
// ============================================================

bool isQZSSPRN(
  int prn
) {

  return (
    prn >= 193 &&
    prn <= 199
  );
}

// ============================================================
// NMEA FIELD
// ============================================================

String nmeaField(
  const String &line,
  int field
) {

  int current = 0;

  int start = 0;

  if (
    line.startsWith("$")
  ) {

    start = 1;
  }

  while (true) {

    int comma =
      line.indexOf(
        ',',
        start
      );

    int star =
      line.indexOf(
        '*',
        start
      );

    int end;

    if (
      comma >= 0 &&
      (
        star < 0 ||
        comma < star
      )
    ) {

      end = comma;

    } else if (
      star >= 0
    ) {

      end = star;

    } else {

      end =
        line.length();
    }

    if (
      current == field
    ) {

      return line.substring(
        start,
        end
      );
    }

    if (
      end >=
      line.length()
    ) {

      break;
    }

    start =
      end + 1;

    current++;
  }

  return "";
}

// ============================================================
// GPS UTC -> EPOCH
// ============================================================

time_t gpsToEpoch(
  int year,
  int month,
  int day,
  int hour,
  int minute,
  int second
) {

  int64_t days = 0;

  for (
    int y = 1970;
    y < year;
    y++
  ) {

    bool leap =
      (
        (y % 4 == 0 && y % 100 != 0) ||
        (y % 400 == 0)
      );

    days +=
      leap ? 366 : 365;
  }

  static const int daysBeforeMonth[] = {

    0,
    31,
    59,
    90,
    120,
    151,
    181,
    212,
    243,
    273,
    304,
    334
  };

  days +=
    daysBeforeMonth[
      month - 1
    ];

  days +=
    day - 1;

  bool leap =
    (
      (year % 4 == 0 && year % 100 != 0) ||
      (year % 400 == 0)
    );

  if (
    leap &&
    month > 2
  ) {

    days++;
  }

  int64_t seconds =
    days * 86400LL;

  seconds +=
    hour * 3600LL;

  seconds +=
    minute * 60LL;

  seconds +=
    second;

  return (time_t)seconds;
}

// ============================================================
// GNZDA
// ============================================================

void parseGNZDA(
  const String &line
) {

  if (
    !line.startsWith("$GNZDA")
  ) {

    return;
  }

  String utc =
    nmeaField(
      line,
      1
    );

  String day =
    nmeaField(
      line,
      2
    );

  String month =
    nmeaField(
      line,
      3
    );

  String year =
    nmeaField(
      line,
      4
    );

  if (
    utc.length() < 6 ||
    day.length() == 0 ||
    month.length() == 0 ||
    year.length() == 0
  ) {

    return;
  }

  int h =
    utc.substring(
      0,
      2
    ).toInt();

  int m =
    utc.substring(
      2,
      4
    ).toInt();

  int s =
    utc.substring(
      4,
      6
    ).toInt();

  int d =
    day.toInt();

  int mo =
    month.toInt();

  int y =
    year.toInt();

  if (
    h < 0 || h > 23 ||
    m < 0 || m > 59 ||
    s < 0 || s > 60 ||
    d < 1 || d > 31 ||
    mo < 1 || mo > 12 ||
    y < 2000
  ) {

    return;
  }

  gpsHour = h;
  gpsMinute = m;
  gpsSecond = s;

  gpsDay = d;
  gpsMonth = mo;
  gpsYear = y;

  gpsTimeValid =
    true;

  // 初回だけESP32のシステム時刻を設定

  if (
    !rtcSyncedFromGPS
  ) {

    time_t epoch =
      gpsToEpoch(
        y,
        mo,
        d,
        h,
        m,
        s
      );

    struct timeval tv;

    tv.tv_sec =
      epoch;

    tv.tv_usec =
      0;

    settimeofday(
      &tv,
      nullptr
    );

    gpsSyncEpoch =
      epoch;

    rtcSyncedFromGPS =
      true;

    DBG_PRINTF(
      "GPS time set: %04d/%02d/%02d %02d:%02d:%02d UTC\n",
      y,
      mo,
      d,
      h,
      m,
      s
    );
  }
}

// ============================================================
// GGA
// ============================================================

void parseGGA(
  const String &line
) {

  if (
    !line.startsWith("$GNGGA") &&
    !line.startsWith("$GPGGA")
  ) {

    return;
  }

  int fixQuality =
    nmeaField(
      line,
      6
    ).toInt();

  int satellites =
    nmeaField(
      line,
      7
    ).toInt();

  gpsFixQuality =
    fixQuality;

  if (
    fixQuality > 0
  ) {

    gpsUsedTotal =
      satellites;

  } else {

    gpsUsedTotal =
      0;
  }
}

// ============================================================
// ADD QZSS TEMP
// ============================================================

void addQZSSTemp(
  int prn,
  int elevation,
  int azimuth,
  int snr
) {

  for (
    int i = 0;
    i < qzssTempCount;
    i++
  ) {

    if (
      qzssTemp[i].prn ==
      prn
    ) {

      qzssTemp[i].elevation =
        elevation;

      qzssTemp[i].azimuth =
        azimuth;

      qzssTemp[i].snr =
        snr;

      return;
    }
  }

  if (
    qzssTempCount <
    MAX_QZSS
  ) {

    qzssTemp[
      qzssTempCount
    ].prn =
      prn;

    qzssTemp[
      qzssTempCount
    ].elevation =
      elevation;

    qzssTemp[
      qzssTempCount
    ].azimuth =
      azimuth;

    qzssTemp[
      qzssTempCount
    ].snr =
      snr;

    qzssTemp[
      qzssTempCount
    ].used =
      false;

    qzssTempCount++;
  }
}

// ============================================================
// PUBLISH GPGSV
// ============================================================

void publishGPGSV() {

  gpsVisible =
    gpgsvTempGPS;

  qzssVisible =
    gpgsvTempQZSS;

  gpsGpgsvTotal =
    gpgsvTempGPS +
    gpgsvTempQZSS;

  qzssCount =
    qzssTempCount;

  for (
    int i = 0;
    i < qzssCount;
    i++
  ) {

    qzss[i] =
      qzssTemp[i];
  }

  DBG_PRINTF(
    "GPGSV complete: GPS=%d QZSS=%d TOTAL=%d\n",
    gpsVisible,
    qzssVisible,
    gpsGpgsvTotal
  );
}

// ============================================================
// PUBLISH OTHER GSV
// ============================================================

void publishBDGSV() {

  beidouVisible =
    bdgsvTemp;
}

void publishGLGSV() {

  glonassVisible =
    glgsvTemp;
}

void publishGAGSV() {

  galileoVisible =
    gagsvTemp;
}

// ============================================================
// GSV
// ============================================================

void parseGSV(
  const String &line
) {

  bool isGPS =
    line.startsWith("$GPGSV");

  bool isBeiDou =
    line.startsWith("$BDGSV");

  bool isGLONASS =
    line.startsWith("$GLGSV");

  bool isGalileo =
    line.startsWith("$GAGSV");

  bool isQZSS =
    line.startsWith("$QZGSV");

  if (
    !isGPS &&
    !isBeiDou &&
    !isGLONASS &&
    !isGalileo &&
    !isQZSS
  ) {

    return;
  }

  int totalMessages =
    nmeaField(
      line,
      1
    ).toInt();

  int messageNumber =
    nmeaField(
      line,
      2
    ).toInt();

  // ==========================================================
  // GPS / QZSS
  // ==========================================================

  if (
    isGPS
  ) {

    if (
      messageNumber == 1
    ) {

      gpgsvTempGPS = 0;
      gpgsvTempQZSS = 0;

      qzssTempCount = 0;
    }

    for (
      int field = 4;
      field <= 20;
      field += 4
    ) {

      String prnString =
        nmeaField(
          line,
          field
        );

      if (
        prnString.length() == 0
      ) {

        continue;
      }

      int prn =
        prnString.toInt();

      if (
        prn <= 0
      ) {

        continue;
      }

      int elevation =
        nmeaField(
          line,
          field + 1
        ).toInt();

      int azimuth =
        nmeaField(
          line,
          field + 2
        ).toInt();

      int snr =
        nmeaField(
          line,
          field + 3
        ).toInt();

      if (
        isQZSSPRN(prn)
      ) {

        gpgsvTempQZSS++;

        addQZSSTemp(
          prn,
          elevation,
          azimuth,
          snr
        );

      } else {

        gpgsvTempGPS++;
      }
    }

    if (
      messageNumber ==
      totalMessages
    ) {

      publishGPGSV();
    }

    return;
  }

  // ==========================================================
  // BeiDou
  // ==========================================================

  if (
    isBeiDou
  ) {

    if (
      messageNumber == 1
    ) {

      bdgsvTemp = 0;
    }

    for (
      int field = 4;
      field <= 20;
      field += 4
    ) {

      int prn =
        nmeaField(
          line,
          field
        ).toInt();

      if (
        prn > 0
      ) {

        bdgsvTemp++;
      }
    }

    if (
      messageNumber ==
      totalMessages
    ) {

      publishBDGSV();
    }

    return;
  }

  // ==========================================================
  // GLONASS
  // ==========================================================

  if (
    isGLONASS
  ) {

    if (
      messageNumber == 1
    ) {

      glgsvTemp = 0;
    }

    for (
      int field = 4;
      field <= 20;
      field += 4
    ) {

      int prn =
        nmeaField(
          line,
          field
        ).toInt();

      if (
        prn > 0
      ) {

        glgsvTemp++;
      }
    }

    if (
      messageNumber ==
      totalMessages
    ) {

      publishGLGSV();
    }

    return;
  }

  // ==========================================================
  // Galileo
  // ==========================================================

  if (
    isGalileo
  ) {

    if (
      messageNumber == 1
    ) {

      gagsvTemp = 0;
    }

    for (
      int field = 4;
      field <= 20;
      field += 4
    ) {

      int prn =
        nmeaField(
          line,
          field
        ).toInt();

      if (
        prn > 0
      ) {

        gagsvTemp++;
      }
    }

    if (
      messageNumber ==
      totalMessages
    ) {

      publishGAGSV();
    }

    return;
  }

  // ==========================================================
  // QZSS GSV
  // ==========================================================

  if (
    isQZSS
  ) {

    if (
      messageNumber == 1
    ) {

      qzssTempCount = 0;
    }

    for (
      int field = 4;
      field <= 20;
      field += 4
    ) {

      int prn =
        nmeaField(
          line,
          field
        ).toInt();

      if (
        prn <= 0
      ) {

        continue;
      }

      int elevation =
        nmeaField(
          line,
          field + 1
        ).toInt();

      int azimuth =
        nmeaField(
          line,
          field + 2
        ).toInt();

      int snr =
        nmeaField(
          line,
          field + 3
        ).toInt();

      addQZSSTemp(
        prn,
        elevation,
        azimuth,
        snr
      );
    }

    if (
      messageNumber ==
      totalMessages
    ) {

      qzssCount =
        qzssTempCount;

      qzssVisible =
        qzssTempCount;

      for (
        int i = 0;
        i < qzssCount;
        i++
      ) {

        qzss[i] =
          qzssTemp[i];
      }
    }
  }
}

// ============================================================
// GSA
// ============================================================
//
// 重要:
//
// GSAを受信するたびに ++ しない。
// 1つのGSA文を全部解析してから、その結果で置換する。
// ============================================================

void parseGSA(
  const String &line
) {

  if (
    !line.startsWith("$GNGSA")
  ) {

    return;
  }

  // ----------------------------------------------------------
  // GSA:
  //
  // $GNGSA,A,3,...,1
  //             ↑
  //          Fix Type
  //
  // 最後:
  // 1 = GPS
  // 2 = GLONASS
  // 3 = Galileo
  // 4 = BeiDou
  // ----------------------------------------------------------

  int fixType =
    nmeaField(
      line,
      2
    ).toInt();

  int systemId =
    nmeaField(
      line,
      18
    ).toInt();

  // ----------------------------------------------------------
  // GPS/QZSSのFix Typeを保存
  // ----------------------------------------------------------

  if (
    systemId == 1
  ) {

    gpsFixType =
      fixType;
  }

  // ----------------------------------------------------------
  // このGSA文専用の一時カウンタ
  // ----------------------------------------------------------

  int usedGPS = 0;
  int usedQZSS = 0;
  int usedBeiDou = 0;
  int usedGLONASS = 0;
  int usedGalileo = 0;

  // ----------------------------------------------------------
  // GPS/QZSS USEフラグを一旦クリア
  // ----------------------------------------------------------

  if (
    systemId == 1
  ) {

    for (
      int i = 0;
      i < qzssCount;
      i++
    ) {

      qzss[i].used =
        false;
    }
  }

  // ----------------------------------------------------------
  // GSA satellite IDs
  //
  // field 3~14
  // ----------------------------------------------------------

  for (
    int field = 3;
    field <= 14;
    field++
  ) {

    String sv =
      nmeaField(
        line,
        field
      );

    if (
      sv.length() == 0
    ) {

      continue;
    }

    int prn =
      sv.toInt();

    if (
      prn <= 0
    ) {

      continue;
    }

    // GPS / QZSS

    if (
      systemId == 1
    ) {

      if (
        isQZSSPRN(prn)
      ) {

        usedQZSS++;

        for (
          int i = 0;
          i < qzssCount;
          i++
        ) {

          if (
            qzss[i].prn ==
            prn
          ) {

            qzss[i].used =
              true;

            break;
          }
        }

      } else {

        usedGPS++;
      }

    // GLONASS

    } else if (
      systemId == 2
    ) {

      usedGLONASS++;

    // Galileo

    } else if (
      systemId == 3
    ) {

      usedGalileo++;

    // BeiDou

    } else if (
      systemId == 4
    ) {

      usedBeiDou++;
    }
  }

  // ----------------------------------------------------------
  // 結果を一括反映
  // ----------------------------------------------------------

  if (
    systemId == 1
  ) {

    gpsUsedGPS =
      usedGPS;

    gpsUsedQZSS =
      usedQZSS;

  } else if (
    systemId == 2
  ) {

    gpsUsedGLONASS =
      usedGLONASS;

  } else if (
    systemId == 3
  ) {

    gpsUsedGalileo =
      usedGalileo;

  } else if (
    systemId == 4
  ) {

    gpsUsedBeiDou =
      usedBeiDou;
  }
}

// ============================================================
// GPS UPDATE
// ============================================================

void updateGPS() {

  while (
    GPS.available()
  ) {

    char c =
      GPS.read();

    if (
      c == '\n'
    ) {

      gpsLine.trim();

      if (
        gpsLine.length() > 0
      ) {

        addGPSLine(
          gpsLine
        );

#if DEBUG_SERIAL
        Serial.println(
          gpsLine
        );
#endif

        parseGNZDA(
          gpsLine
        );

        parseGGA(
          gpsLine
        );

        parseGSV(
          gpsLine
        );

        parseGSA(
          gpsLine
        );
      }

      gpsLine = "";

    } else if (
      c != '\r'
    ) {

      if (
        gpsLine.length() < 200
      ) {

        gpsLine += c;
      }
    }
  }
}

// ============================================================
// ICM-42670-P
// ============================================================

#define ICM_SCK   12
#define ICM_MISO  13
#define ICM_MOSI  11
#define ICM_CS    10

#define ICM_SPI_FREQ 1000000

#define ICM_REG_TEMP_DATA1      0x09
#define ICM_REG_ACCEL_DATA_X1   0x0B
#define ICM_REG_GYRO_DATA_X1    0x11
#define ICM_REG_PWR_MGMT0       0x1F
#define ICM_REG_WHO_AM_I        0x75

#define ICM_WHO_AM_I_VALUE      0x67

SPIClass ICM_SPI(
  FSPI
);

bool icmDetected =
  false;

struct ICMData {

  int16_t temp;

  int16_t ax;
  int16_t ay;
  int16_t az;

  int16_t gx;
  int16_t gy;
  int16_t gz;

  float temperature;

  float gyroX_dps;
  float gyroY_dps;
  float gyroZ_dps;

  float gyroAngleX;
  float gyroAngleY;
  float gyroAngleZ;

  float accelRoll;
  float accelPitch;
};

ICMData icmData = {};

uint32_t icmLastMicros = 0;

// ============================================================
// ICM READ REG
// ============================================================

uint8_t icmReadReg(
  uint8_t reg
) {

  ICM_SPI.beginTransaction(
    SPISettings(
      ICM_SPI_FREQ,
      MSBFIRST,
      SPI_MODE0
    )
  );

  digitalWrite(
    ICM_CS,
    LOW
  );

  ICM_SPI.transfer(
    reg | 0x80
  );

  uint8_t value =
    ICM_SPI.transfer(
      0
    );

  digitalWrite(
    ICM_CS,
    HIGH
  );

  ICM_SPI.endTransaction();

  return value;
}

// ============================================================
// ICM WRITE
// ============================================================

void icmWriteReg(
  uint8_t reg,
  uint8_t value
) {

  ICM_SPI.beginTransaction(
    SPISettings(
      ICM_SPI_FREQ,
      MSBFIRST,
      SPI_MODE0
    )
  );

  digitalWrite(
    ICM_CS,
    LOW
  );

  ICM_SPI.transfer(
    reg & 0x7F
  );

  ICM_SPI.transfer(
    value
  );

  digitalWrite(
    ICM_CS,
    HIGH
  );

  ICM_SPI.endTransaction();
}

// ============================================================
// ICM BURST
// ============================================================

void icmReadRegs(
  uint8_t reg,
  uint8_t *data,
  size_t len
) {

  ICM_SPI.beginTransaction(
    SPISettings(
      ICM_SPI_FREQ,
      MSBFIRST,
      SPI_MODE0
    )
  );

  digitalWrite(
    ICM_CS,
    LOW
  );

  ICM_SPI.transfer(
    reg | 0x80
  );

  for (
    size_t i = 0;
    i < len;
    i++
  ) {

    data[i] =
      ICM_SPI.transfer(
        0
      );
  }

  digitalWrite(
    ICM_CS,
    HIGH
  );

  ICM_SPI.endTransaction();
}

// ============================================================
// INT16
// ============================================================

int16_t makeInt16(
  uint8_t hi,
  uint8_t lo
) {

  return (int16_t)(
    ((uint16_t)hi << 8) |
    lo
  );
}

// ============================================================
// ICM INIT
// ============================================================

void initICM() {

  pinMode(
    ICM_CS,
    OUTPUT
  );

  digitalWrite(
    ICM_CS,
    HIGH
  );

  ICM_SPI.begin(
    ICM_SCK,
    ICM_MISO,
    ICM_MOSI,
    ICM_CS
  );

  delay(20);

  uint8_t who =
    icmReadReg(
      ICM_REG_WHO_AM_I
    );

  DBG_PRINTF(
    "ICM WHO_AM_I = 0x%02X\n",
    who
  );

  if (
    who ==
    ICM_WHO_AM_I_VALUE
  ) {

    icmDetected =
      true;

    icmWriteReg(
      ICM_REG_PWR_MGMT0,
      0x0F
    );

    delay(50);

  } else {

    icmDetected =
      false;
  }
}

// ============================================================
// ICM DATA
// ============================================================

void readICMData() {

  if (
    !icmDetected
  ) {

    return;
  }

  uint8_t data[14];

  icmReadRegs(
    ICM_REG_TEMP_DATA1,
    data,
    sizeof(data)
  );

  icmData.temp =
    makeInt16(
      data[0],
      data[1]
    );

  icmData.ax =
    makeInt16(
      data[2],
      data[3]
    );

  icmData.ay =
    makeInt16(
      data[4],
      data[5]
    );

  icmData.az =
    makeInt16(
      data[6],
      data[7]
    );

  icmData.gx =
    makeInt16(
      data[8],
      data[9]
    );

  icmData.gy =
    makeInt16(
      data[10],
      data[11]
    );

  icmData.gz =
    makeInt16(
      data[12],
      data[13]
    );

  icmData.temperature =
    ((float)icmData.temp / 128.0f)
    + 25.0f;

  icmData.gyroX_dps =
    (float)icmData.gx /
    16.4f;

  icmData.gyroY_dps =
    (float)icmData.gy /
    16.4f;

  icmData.gyroZ_dps =
    (float)icmData.gz /
    16.4f;

  uint32_t now =
    micros();

  if (
    icmLastMicros != 0
  ) {

    float dt =
      (float)(
        now -
        icmLastMicros
      ) / 1000000.0f;

    if (
      dt > 0.0f &&
      dt < 0.2f
    ) {

      icmData.gyroAngleX +=
        icmData.gyroX_dps *
        dt;

      icmData.gyroAngleY +=
        icmData.gyroY_dps *
        dt;

      icmData.gyroAngleZ +=
        icmData.gyroZ_dps *
        dt;
    }
  }

  icmLastMicros =
    now;

  float ax =
    (float)icmData.ax;

  float ay =
    (float)icmData.ay;

  float az =
    (float)icmData.az;

  icmData.accelRoll =
    atan2f(
      ay,
      az
    ) *
    180.0f /
    PI;

  icmData.accelPitch =
    atan2f(
      -ax,
      sqrtf(
        ay * ay +
        az * az
      )
    ) *
    180.0f /
    PI;
}

// ============================================================
// WIFI
// ============================================================

int wifiCount = 0;
int wifiSelected = 0;

void startWiFiScan() {

  WiFi.mode(
    WIFI_STA
  );

  WiFi.disconnect();

  delay(50);

  wifiCount =
    WiFi.scanNetworks(
      false,
      true
    );

  if (
    wifiCount < 0
  ) {

    wifiCount = 0;
  }

  wifiSelected = 0;
}

// ============================================================
// SCREEN
// ============================================================

enum Screen {

  SCREEN_MENU,
  SCREEN_GPS,
  SCREEN_TIME,
  SCREEN_WIFI,
  SCREEN_ICM
};

Screen screen =
  SCREEN_MENU;

// ============================================================
// MENU
// ============================================================

const char *menuItems[] = {

  "GPS Monitor",
  "Current Time",
  "WiFi Scan",
  "ICM-42670",
  "Camera",
  "Resolution",
  "Brightness",
  "Settings",
  "About"
};

const int MENU_COUNT =
  sizeof(menuItems) /
  sizeof(menuItems[0]);

int menuIndex = 0;

// ============================================================
// TIME PAGE
// ============================================================

int timePage = 0;

#define TIME_PAGE_COUNT 3

// ============================================================
// HEADER
// ============================================================

void drawHeader(
  const char *title
) {

  u8g2.setFont(
    u8g2_font_6x10_tf
  );

  u8g2.drawStr(
    0,
    10,
    title
  );

  u8g2.drawHLine(
    0,
    12,
    128
  );
}

// ============================================================
// MENU
// ============================================================

void drawMenu() {

  u8g2.clearBuffer();

  drawHeader(
    "TEST MENU"
  );

  u8g2.setFont(
    u8g2_font_6x10_tf
  );

  int first =
    menuIndex - 2;

  if (
    first < 0
  ) {

    first = 0;
  }

  if (
    first >
    MENU_COUNT - 6
  ) {

    first =
      max(
        0,
        MENU_COUNT - 6
      );
  }

  for (
    int i = 0;
    i < 6;
    i++
  ) {

    int index =
      first + i;

    if (
      index >= MENU_COUNT
    ) {

      break;
    }

    int y =
      25 + i * 15;

    if (
      index == menuIndex
    ) {

      u8g2.drawBox(
        0,
        y - 8,
        128,
        11
      );

      u8g2.setDrawColor(
        0
      );

      u8g2.drawStr(
        3,
        y,
        menuItems[index]
      );

      u8g2.setDrawColor(
        1
      );

    } else {

      u8g2.drawStr(
        3,
        y,
        menuItems[index]
      );
    }
  }

  u8g2.sendBuffer();
}

// ============================================================
// GPS SCREEN
// ============================================================

void drawGPSScreen() {

  u8g2.clearBuffer();

  drawHeader(
    "GPS MONITOR"
  );

  u8g2.setFont(
    u8g2_font_5x8_tf
  );

  for (
    int i = 0;
    i < 6;
    i++
  ) {

    if (
      gpsLastLines[i].length() == 0
    ) {

      continue;
    }

    String s =
      gpsLastLines[i];

    if (
      s.length() > 21
    ) {

      s =
        s.substring(
          0,
          21
        );
    }

    u8g2.drawStr(
      0,
      24 + i * 15,
      s.c_str()
    );
  }

  u8g2.sendBuffer();
}

// ============================================================
// TIME PAGE 1
// ============================================================

void drawTimeClockPage() {

  u8g2.setFont(
    u8g2_font_5x8_tf
  );

  time_t now =
    time(nullptr);

  if (
    !rtcSyncedFromGPS ||
    now < 1000000000
  ) {

    u8g2.drawStr(
      0,
      35,
      "RTC NOT SET"
    );

    u8g2.drawStr(
      0,
      52,
      "Waiting GPS..."
    );

    return;
  }

  time_t jstEpoch =
    now +
    9 * 3600;

  struct tm jst;

  gmtime_r(
    &jstEpoch,
    &jst
  );

  char dateBuf[32];

  snprintf(
    dateBuf,
    sizeof(dateBuf),
    "%04d/%02d/%02d",
    jst.tm_year + 1900,
    jst.tm_mon + 1,
    jst.tm_mday
  );

  u8g2.drawStr(
    5,
    37,
    dateBuf
  );

  char timeBuf[32];

  snprintf(
    timeBuf,
    sizeof(timeBuf),
    "%02d:%02d:%02d",
    jst.tm_hour,
    jst.tm_min,
    jst.tm_sec
  );

  u8g2.setFont(
    u8g2_font_10x20_tf
  );

  u8g2.drawStr(
    8,
    71,
    timeBuf
  );

  u8g2.setFont(
    u8g2_font_5x8_tf
  );

  u8g2.drawStr(
    5,
    90,
    "JST UTC+9"
  );

  u8g2.drawStr(
    5,
    104,
    "GPS -> RTC OK"
  );
}

// ============================================================
// TIME PAGE 2
// ============================================================

void drawTimeGPSPage() {

  u8g2.setFont(
    u8g2_font_5x8_tf
  );

  char line[40];

  snprintf(
    line,
    sizeof(line),
    "USED TOTAL   %2d",
    gpsUsedTotal
  );

  u8g2.drawStr(
    0,
    25,
    line
  );

  snprintf(
    line,
    sizeof(line),
    "GPS  VIEW    %2d",
    gpsVisible
  );

  u8g2.drawStr(
    0,
    39,
    line
  );

  snprintf(
    line,
    sizeof(line),
    "QZSS VIEW    %2d",
    qzssVisible
  );

  u8g2.drawStr(
    0,
    53,
    line
  );

  snprintf(
    line,
    sizeof(line),
    "BDS  VIEW    %2d",
    beidouVisible
  );

  u8g2.drawStr(
    0,
    67,
    line
  );

  snprintf(
    line,
    sizeof(line),
    "GLO  VIEW    %2d",
    glonassVisible
  );

  u8g2.drawStr(
    0,
    81,
    line
  );

  snprintf(
    line,
    sizeof(line),
    "GAL  VIEW    %2d",
    galileoVisible
  );

  u8g2.drawStr(
    0,
    95,
    line
  );

  snprintf(
    line,
    sizeof(line),
    "USE G%02d Q%02d B%02d",
    gpsUsedGPS,
    gpsUsedQZSS,
    gpsUsedBeiDou
  );

  u8g2.drawStr(
    0,
    109,
    line
  );
}

// ============================================================
// TIME PAGE 3
// ============================================================

void drawTimeQZSSPage() {

  u8g2.setFont(
    u8g2_font_5x8_tf
  );

  u8g2.drawStr(
    0,
    21,
    "QZSS / MICHIBIKI"
  );

  if (
    qzssCount == 0
  ) {

    u8g2.drawStr(
      0,
      42,
      "No QZSS"
    );

  } else {

    for (
      int i = 0;
      i < qzssCount &&
      i < 5;
      i++
    ) {

      char line[40];

      snprintf(
        line,
        sizeof(line),
        "%3d EL%02d SNR%02d %s",
        qzss[i].prn,
        qzss[i].elevation,
        qzss[i].snr,
        qzss[i].used
          ? "USE"
          : ""
      );

      u8g2.drawStr(
        0,
        38 + i * 14,
        line
      );
    }
  }

  u8g2.drawStr(
    0,
    108,
    "193-199 = QZSS"
  );
}

// ============================================================
// TIME SCREEN
// ============================================================

void drawTimeScreen() {

  u8g2.clearBuffer();

  char title[32];

  // ==========================================================
  // ページ番号 + 2D/3D FIX
  // ==========================================================

  snprintf(
    title,
    sizeof(title),
    "TIME %d/3 %s",
    timePage + 1,
    getFixTypeText()
  );

  drawHeader(
    title
  );

  if (
    timePage == 0
  ) {

    drawTimeClockPage();

  } else if (
    timePage == 1
  ) {

    drawTimeGPSPage();

  } else {

    drawTimeQZSSPage();
  }

  u8g2.setFont(
    u8g2_font_5x8_tf
  );

  u8g2.drawStr(
    0,
    119,
    "UP/DN Page PUSH Back"
  );

  u8g2.sendBuffer();
}

// ============================================================
// WIFI SCREEN
// ============================================================

void drawWiFiScreen() {

  u8g2.clearBuffer();

  drawHeader(
    "WIFI SCAN"
  );

  u8g2.setFont(
    u8g2_font_5x8_tf
  );

  if (
    wifiCount == 0
  ) {

    u8g2.drawStr(
      0,
      35,
      "No networks"
    );

  } else {

    int first =
      wifiSelected;

    if (
      first >
      wifiCount - 4
    ) {

      first =
        max(
          0,
          wifiCount - 4
        );
    }

    for (
      int i = 0;
      i < 4;
      i++
    ) {

      int index =
        first + i;

      if (
        index >= wifiCount
      ) {

        break;
      }

      int y =
        27 + i * 20;

      String ssid =
        WiFi.SSID(
          index
        );

      if (
        ssid.length() == 0
      ) {

        ssid =
          "<hidden>";
      }

      if (
        ssid.length() > 15
      ) {

        ssid =
          ssid.substring(
            0,
            15
          );
      }

      char line[32];

      snprintf(
        line,
        sizeof(line),
        "%s %ld",
        ssid.c_str(),
        (long)WiFi.RSSI(index)
      );

      if (
        index == wifiSelected
      ) {

        u8g2.drawBox(
          0,
          y - 8,
          128,
          17
        );

        u8g2.setDrawColor(
          0
        );

        u8g2.drawStr(
          2,
          y,
          line
        );

        u8g2.setDrawColor(
          1
        );

      } else {

        u8g2.drawStr(
          2,
          y,
          line
        );
      }

      char ch[12];

      snprintf(
        ch,
        sizeof(ch),
        "C%ld",
        (long)WiFi.channel(index)
      );

      u8g2.drawStr(
        105,
        y,
        ch
      );
    }
  }

  u8g2.drawStr(
    0,
    119,
    "UP/DN Scroll PUSH Back"
  );

  u8g2.sendBuffer();
}

// ============================================================
// ICM SCREEN
// ============================================================

void drawICMScreen() {

  u8g2.clearBuffer();

  drawHeader(
    "ICM-42670-P"
  );

  u8g2.setFont(
    u8g2_font_5x8_tf
  );

  uint8_t who =
    icmReadReg(
      ICM_REG_WHO_AM_I
    );

  char line[40];

  snprintf(
    line,
    sizeof(line),
    "WHO_AM_I: %02X",
    who
  );

  u8g2.drawStr(
    0,
    23,
    line
  );

  if (
    who !=
    ICM_WHO_AM_I_VALUE
  ) {

    u8g2.drawStr(
      0,
      42,
      "NOT DETECTED"
    );

  } else {

    readICMData();

    snprintf(
      line,
      sizeof(line),
      "TEMP %5.1f C",
      icmData.temperature
    );

    u8g2.drawStr(
      65,
      23,
      line
    );

    snprintf(
      line,
      sizeof(line),
      "R %7.1f",
      icmData.accelRoll
    );

    u8g2.drawStr(
      0,
      39,
      line
    );

    snprintf(
      line,
      sizeof(line),
      "P %7.1f",
      icmData.accelPitch
    );

    u8g2.drawStr(
      0,
      51,
      line
    );

    snprintf(
      line,
      sizeof(line),
      "GX %6.1f d/s",
      icmData.gyroX_dps
    );

    u8g2.drawStr(
      0,
      65,
      line
    );

    snprintf(
      line,
      sizeof(line),
      "GY %6.1f d/s",
      icmData.gyroY_dps
    );

    u8g2.drawStr(
      0,
      77,
      line
    );

    snprintf(
      line,
      sizeof(line),
      "GZ %6.1f d/s",
      icmData.gyroZ_dps
    );

    u8g2.drawStr(
      0,
      89,
      line
    );

    snprintf(
      line,
      sizeof(line),
      "A %.1f %.1f %.1f",
      icmData.gyroAngleX,
      icmData.gyroAngleY,
      icmData.gyroAngleZ
    );

    u8g2.drawStr(
      0,
      103,
      line
    );
  }

  u8g2.drawStr(
    0,
    119,
    "PUSH: Back"
  );

  u8g2.sendBuffer();
}

// ============================================================
// MENU ENTER
// ============================================================

void enterMenuItem() {

  switch (
    menuIndex
  ) {

    case 0:

      screen =
        SCREEN_GPS;

      break;

    case 1:

      timePage =
        0;

      screen =
        SCREEN_TIME;

      break;

    case 2:

      startWiFiScan();

      screen =
        SCREEN_WIFI;

      break;

    case 3:

      screen =
        SCREEN_ICM;

      break;

    case 4:
      break;

    case 5:
      break;

    case 6:
      break;

    case 7:
      break;

    case 8:
      break;
  }
}

// ============================================================
// MENU HANDLER
// ============================================================

void handleMenu() {

  Button b =
    getButton();

  if (
    b == BTN_UP
  ) {

    menuIndex--;

    if (
      menuIndex < 0
    ) {

      menuIndex =
        MENU_COUNT - 1;
    }
  }

  if (
    b == BTN_DOWN
  ) {

    menuIndex++;

    if (
      menuIndex >=
      MENU_COUNT
    ) {

      menuIndex = 0;
    }
  }

  if (
    b == BTN_PUSH
  ) {

    enterMenuItem();
  }
}

// ============================================================
// GPS HANDLER
// ============================================================

void handleGPSScreen() {

  Button b =
    getButton();

  if (
    b == BTN_PUSH
  ) {

    screen =
      SCREEN_MENU;
  }
}

// ============================================================
// TIME HANDLER
// ============================================================

void handleTimeScreen() {

  Button b =
    getButton();

  if (
    b == BTN_UP
  ) {

    timePage--;

    if (
      timePage < 0
    ) {

      timePage =
        TIME_PAGE_COUNT - 1;
    }
  }

  if (
    b == BTN_DOWN
  ) {

    timePage++;

    if (
      timePage >=
      TIME_PAGE_COUNT
    ) {

      timePage = 0;
    }
  }

  if (
    b == BTN_PUSH
  ) {

    screen =
      SCREEN_MENU;
  }
}

// ============================================================
// WIFI HANDLER
// ============================================================

void handleWiFiScreen() {

  Button b =
    getButton();

  if (
    b == BTN_UP
  ) {

    wifiSelected--;

    if (
      wifiSelected < 0
    ) {

      wifiSelected = 0;
    }
  }

  if (
    b == BTN_DOWN
  ) {

    wifiSelected++;

    if (
      wifiSelected >=
      wifiCount
    ) {

      wifiSelected =
        max(
          0,
          wifiCount - 1
        );
    }
  }

  if (
    b == BTN_PUSH
  ) {

    WiFi.scanDelete();

    screen =
      SCREEN_MENU;
  }
}

// ============================================================
// ICM HANDLER
// ============================================================

void handleICMScreen() {

  Button b =
    getButton();

  if (
    b == BTN_PUSH
  ) {

    screen =
      SCREEN_MENU;
  }
}

// ============================================================
// SETUP
// ============================================================

void setup() {

  DBG_BEGIN(115200);

#if DEBUG_SERIAL
  Serial.setTxTimeoutMs(0);
#endif

  delay(100);

  // ----------------------------------------------------------
  // ADC
  // ----------------------------------------------------------

  analogReadResolution(
    12
  );

  analogSetPinAttenuation(
    BUTTON_ADC_PIN,
    ADC_11db
  );

  // ----------------------------------------------------------
  // OLED
  // ----------------------------------------------------------

  Wire.setPins(
    OLED_SDA,
    OLED_SCL
  );

  Wire.begin();

  u8g2.begin();

  u8g2.clearBuffer();

  u8g2.setFont(
    u8g2_font_6x10_tf
  );

  u8g2.drawStr(
    0,
    30,
    "ESP32-S3"
  );

  u8g2.drawStr(
    0,
    48,
    "Hardware Test"
  );

  u8g2.drawStr(
    0,
    66,
    "ATGM336H GPS"
  );

  u8g2.sendBuffer();

  delay(800);

  // ----------------------------------------------------------
  // GPS
  // ----------------------------------------------------------

  GPS.begin(
    GPS_BAUD,
    SERIAL_8N1,
    GPS_RX_PIN,
    GPS_TX_PIN
  );

  // ----------------------------------------------------------
  // ICM
  // ----------------------------------------------------------

  initICM();

  // ----------------------------------------------------------
  // WiFi
  // ----------------------------------------------------------

  WiFi.mode(
    WIFI_STA
  );

  WiFi.disconnect();
}

// ============================================================
// LOOP
// ============================================================

void loop() {

  // GPSは画面に関係なく常時処理

  updateGPS();

  switch (
    screen
  ) {

    case SCREEN_MENU:

      handleMenu();
      drawMenu();

      break;

    case SCREEN_GPS:

      handleGPSScreen();
      drawGPSScreen();

      break;

    case SCREEN_TIME:

      handleTimeScreen();
      drawTimeScreen();

      break;

    case SCREEN_WIFI:

      handleWiFiScreen();
      drawWiFiScreen();

      break;

    case SCREEN_ICM:

      handleICMScreen();
      drawICMScreen();

      break;
  }

  delay(30);
}

書き込み中

2025年7月13日日曜日

ATGM336HなGPSモジュールを買ってみた

 アリエクで安いGPSモジュールがあったので購入してみた。ATGM336HっていうシリーズのGPS。580円だった。AT6558が使われてるモジュールらしい。

とりあえずアンテナは小さいやつがついているのにしてみたけども、感度はどうなんだろう?

裏面にはホットスタート用のバッテリとEEPROM(24C32A)がついている。以前UBX-G7020のGPSモジュールを購入したときはEEPROMがついてなくて設定が保存できなかったので設定が保存できるのは便利そう。あと3.3VレギュレータのRT9193-33GBが付いているのでこれならVCCに5V入れても大丈夫そう。ただしTXとRXは3.3Vレベルなので注意。
アンテナのコネクタとバッテリが近いのでアンテナのコネクタがバッテリーに触らないように注意かも。テープでも貼っておくか…
VCC_RFからアンテナに給電するのにデータシートでは47nHがつながっているけどこのボードでは10Ωになっていた。コスト削減なのかもしれないけどちゃんとRFインダクタに交換したら感度上がったりしないかな。

とりあえず動作確認をしてみた。適当なシリアル変換アダプタに接続して、GnssToolKit3で動作確認ができる。室内だと窓際で1個補足できたぐらいでFIXすることはできなかった。

$GPTXT,01,01,02,MA=CASIC*27
$GPTXT,01,01,02,IC=AT6558F-5N-32-1C580900*06
$GPTXT,01,01,02,SW=URANUS5,V5.3.0.0*1D
$GPTXT,01,01,02,TB=2020-03-26,13:25:12*4B
$GPTXT,01,01,02,MO=GB*77

起動時にはこんな感じでバージョン情報とかが出る模様。

デフォルトのBaudrateは9600なんだけど、10Hzとかで動かしたら足りなくなるかもしれないということでBaudrateを変更してみた。

$PCAS01,5*19

をArduino IDEとかのシリアルコンソールからCR+CF付きで送信すると即座にBaudrateが115200に変更できた。もとに戻すには

$PCAS01,1*1D

を送ると9600に戻る。電源を切っても保持されるっぽいので設定時に一回送ればOK。

次にアップデートレートの変更だけども、10Hzに変更するには

$PCAS02,100*1E

のようにコマンドを送れば10Hz(100ms周期)でデータが送られてくる。1Hzに戻すには

$PCAS02,1000*2E

とすればOK。他にも色々変更できるっぽいけど、設定は先程のGnssToolKit3を使ったほうが便利かもしれない。GUIで色々いじれる。View→Configurationで設定画面が開ける。

バイク用にバンク角もログれるGPSロガーがほしいという知り合いがいたのでとりあえずNMEAのGPSなのであとは独自のセンテンスを追加してやれば一緒にログれそうな気がする。

MNEAセンテンスをロギング用にGGA + RMC + ZDAだけに絞るには

$PCAS03,1,0,0,0,1,0,1,0,0,0,,,0,0*03

このコマンドでおk。もとに戻す場合は

$PCAS03,1,1,1,1,1,1,1,1,0,1,,,1,0*02

こんな感じでもとに戻った気がする。そういえばSaveコマンドとか送らなくても全部保存されているようで、再起動してもそのままだった。

あとはツール側でそれを表示できるかどうか?とりあえずはGPXSeeっていうフリーソフトで試してみようかな。
まずは適当にログれるようなやつをM5 Atomあたりで作ってみようかな。