ソースに絡まるエスカルゴ

貧弱プログラマの外部記憶装置です。

【M5StickC Plus/Arduino】M5StickC Plusでランダムな緯度経度から標高を取得するデバイスを作る

 過去の記事でjsonを取得したり、QRコードを表示させたりしました。

 これらを組み合わせて何か面白いものができないかなと思い、jsonを取得できるサイトなどを調べていったところ国土地理院のページで緯度経度から標高を取得できるAPIが公開されていることがわかりました。

 今回はこのAPIを使って緯度経度から標高を取得するのと同時に、GoogleMapでその地点の地図のURLをQRコードで表示するといったものを作ってみたのでその備忘録です。

 では、始めます。


1:国土地理院の標高取得APIについて
 国土地理院の標高取得APIについてのページは以下になります。

 このページに書いてあることそのままですが、以下のURLを発行することで結果をjsonで取得することができます。

https://cyberjapandata2.gsi.go.jp/general/dem/scripts/getelevation.php?lon=[経度]&lat=[緯度]&outtype=JSON

 ページにあるjsonで取得する例のURLである以下のページを開くと、ちゃんとjsonが取得できます。

・URL
https://cyberjapandata2.gsi.go.jp/general/dem/scripts/getelevation.php?lon=140.08531&lat=36.103543&outtype=JSON

・結果

{"elevation":25.3,"hsrc":"5m\uff08\u30ec\u30fc\u30b6\uff09"}

 結果の「hsrc」が変な値になっているのは日本語文字列が入ってるためです。


2:ArduinoJson Assistantを使ってメモリ容量を計算する
 どのようなjsonを取得できるかわかったので、以下のArduinoJson Assistantを使ってメモリ容量を計算します。

 今回は「文字列のjsonにあるelevationの値だけをM5StickC Plusで使いたい」のでModeのところを「Deserialize and filter」を選択します。
f:id:rikoubou:20210416171153p:plain

 Processorを「ESP32」、Input Typeを「String」にして「Next:JSON」をクリックします。
f:id:rikoubou:20210416171212p:plain

 以下のような画面になるので「Input」に取得したいjsonを入力し、「Filter」には取得したい項目のところをtrueにしたjsonを入力します。「Filtered input」のところにFilterで設定されたものだけが表示されるので、合っているかを確認して「Next:Size」をクリックします。
f:id:rikoubou:20210416171555p:plain

 メモリサイズの計算結果が表示されるので「Next:Program」をクリックしてサンプルコードを表示させます。
f:id:rikoubou:20210416171932p:plain

 以下のようにFilterのサイズが16、jsonを取得するためのDocumentのサイズが48とわかりました。
f:id:rikoubou:20210416172034p:plain


3:GoogleMapへのリンク作成
 緯度経度から高度がわかるので、どうせならその場所を地図で知りたいと思いました。なので緯度経度からGoogleMapのリンクを作成する方法を探してみると、以下の方法でできることがわかりました。

https://maps.google.com/maps?q=[緯度],[経度]

 この他にも建物名などからもリンクが作成できるようなので、詳しくは以下のページ様を参照してください。


4:緯度経度から標高を取得するスケッチ
 1~3の内容がわかったので実際に作成したスケッチが以下になります。SSID_CHARとPASS_CHARは接続したいWiFiの値に各自書き換えてください。

・M5StickCPlus_getElevation.ino

/**
 * ランダムな緯度経度から標高を取得する
 */
#include <M5StickCPlus.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>

const char *SSID_CHAR    = "*******"; // 任意のものに書き換える
const char *PASS_CHAR    = "*******"; // 任意のものに書き換える
const char *URL_CHAR     = "https://cyberjapandata2.gsi.go.jp/general/dem/scripts/getelevation.php?";
const char *MAP_URL_CHAR = "https://maps.google.com/maps?q=";

const int   DIGIT         = 6;     // 小数点以下の桁数
const float NO_ELEVATION  = 0.00f; // 高度が取得できなかった場合の値

// 日本の緯度経度の最大値/最小値を定義
const float MIN_LAT =  31.002786f;
const float MIN_LON = 130.659221f;
const float MAX_LAT =  45.350780f;
const float MAX_LON = 147.930048f;

// 最小値からの千葉のとある地点の傾き
const float CHIBA_LAT =  34.901234f;
const float CHIBA_LON = 139.888185f;
float CHIBA_SLOPE = (CHIBA_LAT - MIN_LAT)/(CHIBA_LON - MIN_LON);

// 最小値からの北海道のとある地点の傾き
const float HOKKAIDO_LAT =  43.373813f;
const float HOKKAIDO_LON = 140.481491f;
float HOKKAIDO_SLOPE = (HOKKAIDO_LAT - MIN_LAT)/(HOKKAIDO_LON - MIN_LON);

const int FONT_SIZE = 2;
const int SHOW_X    = 5;
const int SHOW_Y    = 5;

struct GeoData {
  String latVal;    // 緯度
  String lonVal;    // 経度
  float  elevation; // 高度
  bool   errorFlg;  // HTTPでjsonが取得できなかったらtrue/取得できたらfalse
};

GeoData geoData;
TFT_eSprite sprite = TFT_eSprite(&M5.Lcd);

void setup() {
  Serial.begin(115200);
  randomSeed(analogRead(0)); // 未接続ピンのノイズを利用

  M5.begin();
  M5.Axp.ScreenBreath(10); // 画面の明るさ(7-12)
  M5.Lcd.setRotation(0);   // 画面を縦向きに(0-3)

  // スプライト作成
  sprite.createSprite(135, 8*3*FONT_SIZE);

  // 地図データを初期化
  initGeoData();
  Serial.println("setup end");
}

void loop() {
  M5.update();

  // データ内容を表示
  showGeoData();
  sprite.pushSprite(SHOW_X, SHOW_Y);

  // ボタンAが押された時
  if (M5.BtnA.wasPressed()) {
    // 画面を黒く塗りつぶす
    M5.Lcd.fillScreen(TFT_BLACK);
    // 初期化
    initGeoData();
    // WiFi接続
    connectWifFi(SSID_CHAR, PASS_CHAR);
    // 標高取得
    String urlString = createApiUrl(geoData.latVal, geoData.lonVal, URL_CHAR);
    getElevationData(urlString.c_str());
    // WiFi切断
    disconnectWifFi();
    // QRコード表示
    showQRCode();
  }
  delay(100);
}

// 地図データを初期化する関数
void initGeoData() {
  Serial.println("----------");
  String latVal = getRandomLat();
  String lonVal = getRandomLon();

  while (!isInRange(latVal.toFloat(), lonVal.toFloat())) {
    latVal = getRandomLat();
    lonVal = getRandomLon();
  }

  geoData.latVal = latVal;
  geoData.lonVal = lonVal;

  geoData.elevation = -1000.0f;
  geoData.errorFlg = true;

  Serial.print("lat:  ");
  Serial.println(geoData.latVal);
  Serial.print("lon: ");
  Serial.println(geoData.lonVal);
}

// ランダムな緯度を取得する関数
String getRandomLat() {
  String res = String(random(31, 46)) + ".";
  for (int i=0; i < DIGIT; i++) {
    res += String(random(0, 9));
  }
  return res;
}

// ランダムな経度を取得する関数
String getRandomLon() {
  String res = String(random(130, 146)) + ".";
  for (int i=0; i < DIGIT; i++) {
    if (i == 0) {
      res += String(random(6, 10));
    } else {
      res += String(random(0, 10));
    }
  }
  return res;
}

// 緯度と経度が高度を取得できそうな範囲にいるかを判定する関数
bool isInRange(float latVal, float lonVal) {
  // 最大値/最小値を判定
  if ((latVal < MIN_LAT) || (latVal > MAX_LAT)
    || (lonVal < MIN_LON) || (lonVal > MAX_LON)) {
      return false;
  }

  // 傾きを計算
  float slope = (latVal - MIN_LAT)/(lonVal - MIN_LON);

  if ((slope >= CHIBA_SLOPE) && (slope <= HOKKAIDO_SLOPE)) {
    return true; 
  } else {
    return false;
  }
}

// APIのURLを作成する関数
String createApiUrl(String latVal, String lonVal, const char* url) {
  String res = String(url);
  res += "lon="  + lonVal; // 経度
  res += "&lat=" + latVal; // 緯度
  res += "&outtype=JSON";
  Serial.println(res);
  return res;  
}

// WiFiに接続する関数
void connectWifFi(const char *ssid, const char *password) {
  sprite.fillSprite(TFT_BLACK);
  sprite.setCursor(0, 0);
  sprite.setTextFont(1);
  sprite.setTextSize(FONT_SIZE);
  sprite.setTextWrap(true);
  sprite.print("connecting");

  int counter = 0;
  WiFi.begin(ssid, password);
  while (counter < 10) {
    if (WiFi.status() == WL_CONNECTED) {
      sprite.println(" connected!");
      break;
    } else {
      counter++;
      sprite.print(".");
      delay(500);
    }
    sprite.pushSprite(SHOW_X, SHOW_Y);
  }
  delay(1000);
}

// 標高を取得して設定する関数
void getElevationData(const char* url) {
  sprite.fillSprite(TFT_BLACK);
  sprite.setCursor(0, 0);
  sprite.setTextFont(1);
  sprite.setTextSize(FONT_SIZE);
  sprite.setTextWrap(true);
  sprite.print("Please wait... ");
  sprite.pushSprite(SHOW_X, SHOW_Y);

  if(WiFi.status() == WL_CONNECTED) {
    HTTPClient http;
    http.setTimeout(5000); // 5秒でタイムアウト
    http.begin(url);
  
    int httpCode = http.GET();
    if (httpCode == HTTP_CODE_OK) {
      // 正常にGET出来た場合はresponseから高度を設定
      String payload = http.getString();
      setElevation(payload, &(geoData));
    } else {
      geoData.errorFlg = true;
      sprite.println("http GET Error!");
      sprite.pushSprite(SHOW_X, SHOW_Y);
    }
    http.end();
  } else {
    geoData.errorFlg = true;
    sprite.println("Not connected to Wifi.");
    sprite.pushSprite(SHOW_X, SHOW_Y);
  }
  delay(1000);
}

// httpのresponseから標高を設定する関数
void setElevation(String payload, GeoData *gd) {
  // jsonの必要な部分を抽出するためのフィルタ
  StaticJsonDocument<16> filter;
  filter["elevation"] = true;

  // 領域を確保してjsonに変換
  StaticJsonDocument<48> doc;
//  DynamicJsonDocument doc(48);
  DeserializationError error = deserializeJson(doc, payload, DeserializationOption::Filter(filter));

  if (error) {
    // エラーの場合
    Serial.print(F("deserializeJson() failed: "));
    Serial.println(error.f_str());
    sprite.println("http GET Error!");
    sprite.pushSprite(SHOW_X, SHOW_Y);
    gd->errorFlg = true;
  } else {
    // エラーではない場合は標高を取得
    float result = doc["elevation"];
    gd->errorFlg = false;
    gd->elevation = result;
    Serial.print("ele: ");
    Serial.println(result);
    sprite.println("http GET OK.");
    sprite.pushSprite(SHOW_X, SHOW_Y);
  }

  doc.clear(); // メモリ開放
}

// WiFiを切断する関数
void disconnectWifFi() {
  sprite.fillSprite(TFT_BLACK);
  sprite.setCursor(0, 0);
  sprite.setTextFont(1);
  sprite.setTextSize(FONT_SIZE);
  sprite.setTextWrap(true);

  if(WiFi.status() == WL_CONNECTED) {
    WiFi.disconnect(true);
    WiFi.mode(WIFI_OFF);
    sprite.print("disconnected.");
  } else {
    sprite.print("disconnect NG.");
  }
  sprite.pushSprite(SHOW_X, SHOW_Y);
  delay(1000);
}

// 地図データを表示する関数
void showGeoData() {
  sprite.fillSprite(TFT_BLACK);
  sprite.setCursor(0, 0);
  sprite.setTextSize(FONT_SIZE);

  if (geoData.errorFlg) {
    // エラーだった場合
    sprite.setTextWrap(true);
    sprite.println("No data.");
    sprite.println("Push the\nM5 button.");
  } else {
    // 緯度と経度と標高を表示
    sprite.setTextWrap(false);
    sprite.print("lat: ");
    sprite.println(geoData.latVal);
    sprite.print("lon:");
    sprite.println(geoData.lonVal);
    sprite.print("ele:");
    if (NO_ELEVATION == geoData.elevation) {
      sprite.println("No data");
    } else {
      sprite.println(geoData.elevation);
    }
  }
}

// QRコードを表示する関数
void showQRCode() {
  int x = 0;
  int y = 8*3*FONT_SIZE+5;
  int width = 135;
  int versionNum = 3;

  String url_str = String(MAP_URL_CHAR);
  url_str += geoData.latVal + "," + geoData.lonVal;
  Serial.println(url_str);

  if (!geoData.errorFlg) {
    // データが取れた場合はQRコード表示
    M5.Lcd.qrcode(url_str, x, y, width, versionNum);
  }
}

 少し解説するとこのスケッチを書き込んで「M5」のボタンを押すと緯度経度にランダムな値が設定され、WiFiに接続できたらその緯度経度を用いたURLを発行して取得した高度を表示するというような形になっています。画面上のlatが緯度、lonが経度、eleが取得した高度になります。高度を取得するURLを発行した結果、値が取得できなかった場合はeleに「No data」と表示するようにしています。緯度経度の位置を示すGoogleMapのリンクのQRコードも表示されます。

 ちなみに高度が取得できない場合の多くは緯度経度が示す場所が海だったりしていることが多いので、QRコードスマホなどで読み取って地図を表示させてみると色々と面白いです。


 以上がM5StickC Plusでランダムな緯度経度から標高を取得するデバイスを作った備忘録です。

 これが何の役に立つかと言われると自分でもよくわかりませんが、今までの知識を組み合わせてそれなりに遊べるものができたので個人的には割と満足できました。


・参考資料