メインコンテンツへスキップ
見出し画像

M5Stackで車載用GPSロガーを作る(更新)

    これまでレンタカーで旅をする時には、ルートヒストリーというアプリを使ってGPSログを記録して、運転したルートを振り返るということをしていた。

    ルートヒストリーはシンプルかつ必要十分な機能のGPSロガーアプリなのだが、肝心の人間が記録開始を忘れたまま走り出すこともよくあり…せっかく車買っちゃったしもっとこう、無意識に記録されているのがいいよね、ということで単体のGPSロガーを探していた。

    既存製品や選択肢

    ドライブレコーダーにはGPS内蔵で映像と一緒にGPSログを記録できるものもある。でもついてないやつ買っちゃったし…

    例えば徒歩移動だったらこういうのがあるよね。ガーミンとか、サイクルコンピュータもGPSログは取れる。しかしこういう製品は当然バッテリー搭載であり、このバッテリーというのは真夏の駐車した車内では危険物である。つまり無意識に記録したいのに車から持ち出す手間がかかるのだ。

    車載用のGPS発信機というジャンルもある。これなら車載用だし、GPSログもオンラインで見られるのだが、このオンラインというのが余計である。自分の移動ログなのだから、外から見られる必要はないし、通信費用がかかるのもマイナス。

    AirTagやそれに類するトラッカーという手もある。ただしこの類のトラッカーは現在位置が分かるだけで、ルートの履歴はプライバシー上見られない。旅行のログには向いていない。

    あと今時の車はだいたいコネクテッドサービスがついていて…アプリから駐車位置や運転履歴が見られる。ただこれも(少なくともスズキコネクトは)運転履歴から開始地点と終了地点、時間と距離、燃費は確認できるがルートのログは見られない。

    Googleマップのタイムラインであれば無意識にログを取るには最適かもしれない。ただやっぱ移動ログを常時Googleに渡すのはなんか…ヤダね!

    M5Stack GPSユニット

    わがままだな!もう自作しろよ!

    そう、あります。こういうのが。M5Stack Basicを持っていたので、それに繋げることにした。

    私の持っていたのはV2.6だけど、多分あまり違いはない。M5Stack Basicは底面の薄いユニットがバッテリーになっていて、この部分を外せばバッテリー無しで使える。microSDカードスロットもあるので、保存したGPSログのやり取りもしやすい。

    完成品

    そういうわけで、こういうのができた。

    画像
    Groveケーブルは付属品が長かったので、以前買った短い互換品を使っている。

    プラスチックケースは100円ショップで見つけたものがほぼピッタリだった。家具用クッションフェルトで位置を固定して、Type-Cケーブルが通るところにペンチで雑な穴を開けている。

    画像
    立体駐車場で撮ったので暗く、衛星も4個の信号しかない

    動かすとこんな感じ。電源を入れると(エンジンをかけると)自動で記録を開始する。記録間隔は10秒ごとで、前回記録地点から10m以内は記録しない。経過時間、移動距離、平均時速、何個の衛星から信号を受信しているかを表示する。現在速度の表示もできるけど、あえて機能を外した。

    何も操作をしないと、記録は前回のファイルに追記していく。ファイル形式はGPX、つまりXMLなので終了タグが必要で…Stopボタンを押すと終了タグを書き込み、ファイルを閉じるようになっている。Startボタンを押すと現在記録中のファイルを終了して新しいファイルで記録を開始する。Pauseボタンは記録の一時停止だけど…上記のように停車中はほぼ記録されないのであまり必要はない。

    画像
    ちゃんと記録されてそう

    移動10m以内は記録しないとか、自動で前回のファイルに追記とかはバッテリーのない状態を想定している。つまり、目的地の駐車場に停めてエンジンを切るといきなり電源が落ちるので、停車中はファイル書き込みがなるべく発生しないようにする。そして電源が落ちる時に自動でファイルに終了タグを書き込むことができないので、Stopボタンを押すまでは同じファイルを使用する。

    機能強化

    と、ここまでが最初のバージョンだったんだけど、使用していくうちにもっと便利にしようと思って機能を強化した。

    Wi-Fi検知機能

    M5Stackは…というかESP32はWi-Fi接続機能を持っている。GPSロガーとして記録中はWi-Fi接続は不要だけれど、例えば自宅のWi-FiのSSIDを登録しておけば、簡単なジオフェンス的に使える。つまり、自宅のWi-Fiが受信できていたらまだ記録せず、受信できなくなったタイミングで記録を開始する。もし記録中であれば、自宅のWi-Fiを受信したということは帰宅したということなので、記録を終了する。
    この機能をつけたことで、StartボタンやStopボタンの操作は不要となった。Pauseもどうせ停車中は記録しないので不要と判断して、Stopボタン以外を取っ払った。

    メール送信機能

    最初のバージョンはmicro SDカードにGPXファイルを保存するため、Macでログを見るときはmicro SDカードを取り外して室内に持ってくる必要があった。でも、いちいちそんなことをするのは面倒だし…上記のWi-Fi検知を実装したことで、Wi-Fiを経由した送信もつけちゃおうということになった。
    上記のWi-Fi検知で記録を終了し、その後実際にそのWi-Fiに接続し、メールの添付ファイルとしてGPXファイルを送信する。そのあとはずっとWi-Fi検知状態なので記録は停止しており、いつ電源を切ってもいい。

    バッファ機能

    最初のコードではGPS地点が更新されたらそのままmicro SDカードに書き込んでいるけれど、考えてみたらフラッシュメモリはブロックごとの更新なので、ちまちま書き込むのは効率が悪いし書き換え寿命も消費する。そこで通常はメモリ上に記録していき、ある程度蓄積するかあるいは駐車寸前の速度が低下したタイミングでmicro SDに書き込むようにした。

    画像
    Stopボタン以外を取っ払ってシンプルに
    画像
    自宅のWi-Fiを検知すると記録はしない

    コード

    Platform IOでArduinoライブラリを使用している。ちょっとハマったところとかをメモしておく。というか、最初はChatGPTに全部コードを書いてもらおうとしたんだけどChatGPTくんが間違えてきたところが下記である。

    M5Stack BasicのGroveポート

    M5Stack BasicのGroveポートは、標準ではI2C用である。M5Stack GPS UnitはUARTなので、それ用の設定をしなければならない。具体的にはHardwareSerialの使用ピンを22と21にする。

    HardwareSerial GPS_Serial(2);
    GPS_Serial.begin(115200, SERIAL_8N1, 22, 21, false);

    M5Stack GPS Unit V1.1

    M5Stack GPS UnitはV1.1で通信速度が変わっているらしい。オリジナルは9600bpsだが、V1.1は115200bps。上記はその設定もしている。

    TinyGPSPlus

    M5Stack GPS Unitに限らずGPSユニットの信号文を解析してくれるライブラリなんだけど、M5Stack用のforkがあるのでそちらを使用してみた。

    EmailSender

    電子メールを送信できるライブラリ。GMailのアドレスとアプリパスワードを使うことでArduinoから手軽にメール送信ができる。

    実際のコード

    最初にChatGPTくんが書いてきたものは8割書き直したけど、GPXファイルの書式生成に関するコードはほぼそのまま。

    [env:m5stack-core-esp32]
    platform = espressif32
    board = m5stack-core-esp32
    framework = arduino
    monitor_speed = 115200
    lib_deps =
      m5stack/M5Stack
      https://github.com/m5stack/TinyGPSPlus/
      https://github.com/xreef/EMailSender
    #include <M5Stack.h>
    #include <TinyGPSPlus.h>
    #include <FS.h>
    #include <SD.h>
    #include <SPI.h>
    
    #include <WiFi.h>
    #include <WiFiClient.h>
    #include "EMailSender.h"
    
    #include <Preferences.h>
    
    typedef enum
    {
      StateStopped = 0,
      StateWiFi = 1,
      StateSearching = 2,
      StateLogging = 3
    } LogState;
    
    // グローバル変数定義
    LogState logState = StateStopped;
    HardwareSerial GPS_Serial(2);
    TinyGPSPlus gps;
    
    String gpxFilename = "";
    String gpxBuffer = "";
    bool SDAvailable = false;
    unsigned long lastLogMillis = 0;
    
    double lastLat = 0;
    double lastLng = 0;
    double totalDistance = 0;
    unsigned long startMillis;
    
    String wifi_ssid;
    String wifi_pass;
    String email_receiver;
    String email_accountName;
    String email_pass;
    
    Preferences pref;
    #define PREF_NAME "GPXLogger"
    #define GPX_FILE_KEY "gpxFile"
    
    // 関数プロトタイプ宣言
    String makeTimeString();
    String makeNewFilename();
    void writeGpxHeader();
    void writeGpxFooter();
    void flushGpxLogToFile();
    
    void startLogging();
    void stopLogging();
    
    void drawMessage(String message);
    void drawFooter();
    
    bool checkWiFi();
    void connectWiFiAndSendFile(String filename);
    void connectWiFiAndSendLastFile();
    
    // Arduino setup
    void setup()
    {
      // 初期化処理 GPS_SerialはM5Stack BasicのGroveコネクタ用のためGPIO22, 21を使用
      M5.begin();
      M5.Power.begin();
      M5.Lcd.setTextSize(2);
      Serial.begin(115200);
      GPS_Serial.begin(115200, SERIAL_8N1, 22, 21, false);
    
      // SDカード初期化 もしSDカードがなければロギング無効
      M5.Lcd.println("Initializing SD...");
      if (!SD.begin())
      {
        M5.Lcd.println("SD init failed!");
        SDAvailable = false;
      }
      else
      {
        SDAvailable = true;
    
        // WiFi設定読み込み
        File settingFile = SD.open("/settings.txt");
        if (settingFile)
        {
          M5.Lcd.println("Loading settings...");
          while (settingFile.available() > 0)
          {
            String line = settingFile.readStringUntil('\n');
            if (line.startsWith("//"))
              continue;
            int separatorLocation = line.indexOf(":");
            if (separatorLocation > -1)
            {
              String key = line.substring(0, separatorLocation);
              String content = line.substring(separatorLocation + 1);
    
              // WiFi SSID and paassword
              if (key == "SSID")
                wifi_ssid = content;
              else if (key == "PASS")
                wifi_pass = content;
    
              // EMail settings
              else if (key == "GMailAccount")
                email_accountName = content;
              else if (key == "GMailPassword")
                email_pass = content;
              else if (key == "EMailReceiver")
                email_receiver = content;
            }
          }
          settingFile.close();
        }
      }
    
      // 前回のログファイル名読み込み
      pref.begin(PREF_NAME, true);
      gpxFilename = pref.getString(GPX_FILE_KEY);
      pref.end();
    
      delay(1000);
    
      // GPSユニット待機
      M5.Lcd.println("Waiting GPS unit...");
      while (!GPS_Serial.available())
      {
        delay(100);
        M5.Lcd.print(".");
      }
    
      drawMessage("Ready");
      logState = StateWiFi;
    }
    
    // Arduino loop
    void loop()
    {
      M5.update();
    
      switch (logState)
      {
      // ロギング停止中
      case StateStopped:
      {
        if (M5.BtnC.wasPressed())
        {
          startLogging();
        }
        break;
      }
    
      // WiFi接続確認
      case StateWiFi:
      {
        if (checkWiFi())
        {
          // WiFiが存在
          drawMessage("Home");
          int timeout = 0;
          while (timeout < 100)
          {
            delay(100);
            timeout++;
    
            M5.update();
            if (M5.BtnC.wasPressed())
            {
              connectWiFiAndSendLastFile();
              return;
            }
          }
          return;
        }
        else
        {
          // GPS探索開始
          logState = StateSearching;
          drawMessage("Searching");
        }
        return;
      }
    
      // GPS信号探索中
      case StateSearching:
      {
        // GPSデータ更新
        while (GPS_Serial.available())
        {
          int c = GPS_Serial.read();
          gps.encode(c);
          // Serial.write(c);
        }
        if (gps.location.isValid())
        {
          if (gpxFilename.isEmpty())
          {
            startLogging();
          } else {
            logState = StateLogging;
            drawFooter();
          }
          return;
        }
        else
        {
          delay(1);
          return;
        }
      }
    
      // ロギング中
      case StateLogging:
      {
        // GPSデータ更新
        while (GPS_Serial.available())
        {
          int c = GPS_Serial.read();
          gps.encode(c);
          // Serial.write(c);
        }
        if (!gps.location.isValid())
        {
          return;
        }
    
        // 緯度、経度、標高取得
        double lat = gps.location.lat();
        double lng = gps.location.lng();
        double alt = gps.altitude.meters();
    
        // 以前の位置情報と比較して距離を算出
        if (lastLat == 0 && lastLng == 0)
        {
          lastLat = lat;
          lastLng = lng;
        }
        double distance = TinyGPSPlus::distanceBetween(lastLat, lastLng, lat, lng); // 距離はメートル
        unsigned long now = millis();
    
        // 前回ログから10秒未満ならスキップ
        if (now - lastLogMillis < 10000)
        {
          return;
        }
        // 距離が10m未満かつ10秒経過したら
        if (distance < 10.0)
        {
          if (now - lastLogMillis >= 10000)
          {
            // WiFiをスキャンし、もし接続できたらメール送信
            if (checkWiFi())
            {
              String filename = gpxFilename;
              stopLogging();
              connectWiFiAndSendFile(filename);
            }
          }
          return;
        }
    
        // ログ用時間文字列
        lastLogMillis = now;
        String timestr = makeTimeString();
    
        // 総距離更新
        totalDistance += distance;
        lastLat = lat;
        lastLng = lng;
    
        // 経過時間、平均速度算出
        unsigned long duration = (now - startMillis) / 1000;
        unsigned int hour = duration / 3600;
        unsigned int min = (duration % 3600) / 60;
        unsigned int sec = (duration % 60);
    
        double averageKmph = (totalDistance / 1000) / (duration / 3600.0);
    
        // GPSデータ表示
        M5.Lcd.fillRect(0, 0, 320, 194, BLACK);
        M5.Lcd.setCursor(0, 10);
        M5.Lcd.setTextColor(WHITE, BLACK);
        M5.Lcd.setTextSize(5);
    
        M5.Lcd.printf("%02d:%02d:%02d\n", hour, min, sec); // 経過時間を表示
        M5.Lcd.printf("%.1f km \n", totalDistance / 1000); // 総距離をキロメートル単位で表示する
        M5.Lcd.printf("%.1f km/h\n", averageKmph);         // 平均速度をキロメートル/時単位で表示する
        M5.Lcd.printf("%d sat.", gps.satellites.value());  // 捕捉衛星数を表示
    
        if (SDAvailable)
        {
          // ロギング中であればGPXファイルに書き込み
          char buf[64];
          sprintf(buf, "      <trkpt lat=\"%.6f\" lon=\"%.6f\">\n", lat, lng);
          gpxBuffer += String(buf);
          sprintf(buf, "        <ele>%.2f</ele>\n", alt);
          gpxBuffer += String(buf);
          sprintf(buf, "        <time>%s</time>\n", timestr.c_str());
          gpxBuffer += String(buf);
          gpxBuffer += "      </trkpt>\n";
    
          // 一時ログが1KBを超えたらファイルに追記
          if (gpxBuffer.length() >= 1024)
          {
            flushGpxLogToFile();
          }
        }
    
        if (M5.BtnC.wasPressed())
        {
          stopLogging();
        }
      }
    
      default:
        return;
      }
    }
    
    // ========= 他の関数 ==========
    // メッセージ表示
    void drawMessage(String message)
    {
      M5.Lcd.fillRect(0, 0, 320, 194, BLACK);
      M5.Lcd.setCursor(0, 10);
      M5.Lcd.setTextColor(WHITE, BLACK);
      M5.Lcd.setTextSize(5);
      M5.Lcd.println(message);
    }
    
    // フッター表示
    void drawFooter()
    {
      M5.Lcd.setTextSize(3);
      if (logState == StateLogging)
      {
    
        M5.Lcd.fillRect(213, 195, 107, 45, RED);
        M5.Lcd.setTextColor(BLACK, RED);
        M5.Lcd.drawCentreString("Stop", 266, 200, 1);
      }
      else
      {
        M5.Lcd.fillRect(213, 195, 107, 45, BLACK);
        M5.Lcd.setTextColor(RED, BLACK);
        M5.Lcd.drawCentreString("Stop", 266, 200, 1);
      }
    }
    
    // 現在のUTC時刻(GPX用)
    String makeTimeString()
    {
      if (gps.date.isValid() && gps.time.isValid())
      {
        char buf[32];
        sprintf(buf, "%04d-%02d-%02dT%02d:%02d:%02dZ",
                gps.date.year(), gps.date.month(), gps.date.day(),
                gps.time.hour(), gps.time.minute(), gps.time.second());
        return String(buf);
      }
      else
      {
        return "1970-01-01T00:00:00Z";
      }
    }
    
    // ファイル名生成
    String makeNewFilename()
    {
      char buf[32];
      if (gps.date.isValid() && gps.time.isValid())
      {
        sprintf(buf, "/GPS_%04d%02d%02d_%02d%02d%02d.gpx",
                gps.date.year(), gps.date.month(), gps.date.day(),
                gps.time.hour(), gps.time.minute(), gps.time.second());
      }
      else
      {
        sprintf(buf, "/GPS_%lu.gpx", millis());
      }
      return String(buf);
    }
    
    // GPXヘッダ書き込み
    void writeGpxHeader()
    {
      gpxBuffer = "";
      gpxBuffer += "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
      gpxBuffer += "<gpx version=\"1.1\" creator=\"M5Stack GPX Logger\" xmlns=\"http://www.topografix.com/GPX/1/1\">\n";
      gpxBuffer += "  <trk>\n";
      gpxBuffer += "    <name>M5Stack Track</name>\n";
      gpxBuffer += "    <trkseg>\n";
    }
    
    // GPXフッタ書き込み
    void writeGpxFooter()
    {
      gpxBuffer += "    </trkseg>";
      gpxBuffer += "  </trk>\n";
      gpxBuffer += "</gpx>\n";
    }
    
    // 一時ログをファイルに書き込み
    void flushGpxLogToFile()
    {
      // 一時ログが空、またはSDカードが利用不可なら終了
      if (gpxBuffer.isEmpty() || !SDAvailable)
      {
        gpxBuffer = "";
        return;
      }
    
      // ファイルが開いていなければ開く
      if (gpxFilename.isEmpty())
      {
        pref.begin(PREF_NAME, false);
        gpxFilename = pref.getString(GPX_FILE_KEY);
        if (gpxFilename.isEmpty())
        {
          gpxFilename = makeNewFilename();
          pref.putString(GPX_FILE_KEY, gpxFilename);
        }
        pref.end();
      }
    
      // ファイルが閉じていれば開き直す
      File gpxFile;
      gpxFile = SD.open(gpxFilename.c_str(), FILE_WRITE);
      if (!gpxFile)
      {
        startLogging();
        return;
      }
      gpxFile.seek(gpxFile.size()); // ファイル末尾までシーク
    
      gpxFile.print(gpxBuffer);
      gpxFile.flush();
      gpxBuffer = "";
      gpxFile.close();
    }
    
    // 新規ファイル作成
    void startLogging()
    {
      if (logState == StateLogging)
        return;
      startMillis = millis();
      // 記録中のファイルがなければ新規作成
      if (gpxFilename.isEmpty())
      {
        gpxFilename = makeNewFilename();
        pref.begin(PREF_NAME);
        pref.putString(GPX_FILE_KEY, gpxFilename);
        pref.end();
        writeGpxHeader();
        flushGpxLogToFile();
      }
    
      lastLogMillis = 0;
      logState = StateLogging;
    
      M5.Lcd.clear();
      drawMessage("Start Logging");
      drawFooter();
    }
    
    // 終了
    void stopLogging()
    {
    
      writeGpxFooter();
      flushGpxLogToFile();
    
      // 記録中のファイル名をクリア
      pref.begin(PREF_NAME);
      pref.putString(GPX_FILE_KEY, "");
      pref.end();
      gpxFilename = "";
    
      logState = StateWiFi;
      drawFooter();
    }
    
    // WiFi接続可能か確認
    bool checkWiFi()
    {
      if (wifi_ssid.isEmpty())
      {
        return false;
      }
      WiFi.mode(WIFI_STA);
      WiFi.disconnect();
      delay(100);
      int count = WiFi.scanNetworks();
      for (int i = 0; i < count; i++)
      {
        String ssid = WiFi.SSID(i);
        if (ssid == wifi_ssid)
        {
          WiFi.scanDelete();
          return true;
        }
      }
      WiFi.scanDelete();
      return false;
    }
    
    // WiFi接続とメール送信
    void connectWiFiAndSendFile(String filename)
    {
      drawMessage("Connecting\nWiFi...");
      if (filename.isEmpty())
      {
        drawMessage("No file");
        return;
      }
    
      String mailSubject = "GPX Log";
      if (filename.length() > 13)
      {
        mailSubject = filename.substring(1, 13);
      }
    
      WiFi.begin(wifi_ssid.c_str(), wifi_pass.c_str());
      int timeout = 0;
      while (WiFi.status() != WL_CONNECTED)
      {
        delay(500);
        M5.Lcd.print(".");
        timeout++;
        if (timeout > 20) // Timeout. restart from scanWiFi
        {
          drawMessage("WiFi Connect Timeout");
          return;
        }
      }
    
      drawMessage("Sending\nEmail...");
      EMailSender::FileDescriptior fileDescriptor;
      fileDescriptor.filename = filename.substring(1);
      fileDescriptor.url = filename; // Path on SD card
      fileDescriptor.mime = MIME_TEXT_PLAIN;
      fileDescriptor.encode64 = false; // Base64 encode
      fileDescriptor.storageType = EMailSender::EMAIL_STORAGE_TYPE_SD;
    
      EMailSender::Attachments attachments;
      attachments.number = 1;
      attachments.fileDescriptor = &fileDescriptor;
    
      // Send email with attachment
      if (email_accountName.isEmpty() || email_pass.isEmpty() || email_receiver.isEmpty())
      {
        return;
      }
      EMailSender sender = EMailSender(email_accountName.c_str(), email_pass.c_str());
      EMailSender::EMailMessage message;
      message.subject = mailSubject;
      message.message = "GPX Log attached";
      EMailSender::Response resp = sender.send(email_receiver.c_str(),
                                               message,
                                               attachments);
      drawMessage("Email sent");
    }
    
    void connectWiFiAndSendLastFile()
    {
      if (!SDAvailable)
      {
        drawMessage("No SD Card");
        return;
      }
      String lastFilename = "";
      File root = SD.open("/");
      while (1)
      {
        bool isDir = false;
        String filename = root.getNextFileName(&isDir);
        if (!filename.startsWith("/.") && filename.endsWith(".gpx"))
        {
          lastFilename = filename;
        }
        if (filename == "")
          break; // ファイルがない場合
      }
      root.close();
      if (!lastFilename.isEmpty())
      {
        connectWiFiAndSendFile(lastFilename);
      }
    }

    未来へ…

    本来自作するまでもないはずなんだけど、そういうものでも自作してみると意外によい。というのは、完全に自分用に機能を調整できるからである。

    例えば最初はTinyGPSPlusの機能で現在の移動速度をそのまま表示していたんだけど、車載用でそれをやるとついつい車のメーターと比べてしまう。つまりよそ見をしてしまう。なので表示は運転終了時に見たい情報、つまり運転時間と移動距離、そこから計算される平均時速に絞った。

    この移動距離というのも奥が深いと思っていて、GPSで測定した2地点の距離を計算する関数はTinyGPSPlusにある(地球を球体だと仮定して計算するので誤差がある)。これを使用して測定地点の距離を全部合算していけば移動距離になるわけだけど、あまり細かくすると例えば停車中に位置情報が数mブレ続けるのを全て合算してジリジリ移動距離が伸びていく羽目になる。逆に粗くすると、カーブを描いて移動したのに直線で計算して短くなることも想定できる。おそらく徒歩や自転車など移動速度に合わせたチューニングも必要なのだろう。

    もう夏は終わってしまったので車内もそこまで熱くはならないんだけど、ここまでして次の夏この自作ロガーが溶けちゃったら泣くな。まあ発火よりマシなんだけども…

    あなたへのおすすめ