M5Stackで天気予報を表示

# はじめに

うちにはAmazon Echoがあるので「アレクサ、天気予報を教えて」とお願いしますが、降水確率を教えてくれなかったり、明日は今日より暑いのか寒いのか教えてくれなかったりで、イマイチ参考になりません。そこで今回は[M5Stack](https://www.switch-science.com/catalog/3647/)を使って、知りたい情報を常時表示してくれる天気予報専用端末を作りました。

# 動作の概要

![20200502_205523.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614257/f4c04ef3-c3ff-1d0b-795e-4b9642d65627.jpeg)

- [Yahoo!天気・災害](https://weather.yahoo.co.jp/weather/)から情報を取得し、今日の天気、明日の天気を交互に表示します。あわせて、気温(最高気温、最低気温)と降水確率も表示します。
- 仕組みとしては、クラウド側のgoogleスプレッドシートおよびGoogle Apps Scriptと、M5Stackが連携しています。
    - クラウド側では、googleスプレッドシートのimportXML関数を使って、Yahoo!天気の情報をスクレイピングしてシートに格納しています。
    - M5stackは、Google Apps Script経由でgoogleスプレッドシートにアクセスし、天気予報の情報をダウンロードして画面に表示しています。

# 準備するもの

- M5stack(https://www.switch-science.com/catalog/3647/)

# コーディング

※ソースコード一式は[github](https://github.com/kz1017/kz1017/tree/master/m5stack_weather)にアップしています。

### googleスプレッドシートのコーディング

- [weather.xlsx](https://github.com/kz1017/kz1017/blob/master/m5stack_weather/resource/weather.xlsx)をダウンロードして、googleドライブにアップロードします。
- メニューから「ファイル」→「googleスプレッドシートとして保存」を実行し、googleスプレッドシートとして保存します。
- C1セルで表示したい地域を選択します。表示したい地域がリストにない場合、M列～O列に追記することで追加可能です。

![weather_xlsx.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614257/e4d20319-f851-29bf-b63e-4e70b7687fe2.jpeg)

### Google Apps Scriptのコーディング

- googleスプレッドシートのメニューから「ツール」→「スクリプトエディタ」を選択し、スクリプトエディタを実行します。
- 下記スクリプトを貼り付けます。
- 「\*\*\*」の部分には、googleスプレッドシートのidを記載します。(googleスプレッドシートのURL「https://docs.google.com/spreadsheets/d/***/edit 」の「***」の部分)
- 作成したら、「リソース」→「ウェブアプリケーションとして導入」で公開します。(アクセス権は「Anyone, even anonymous」とします)
- 更新したら版数(Project Version)を「new」にして改版するのを忘れずに！

```javascript:weather.gs
function doGet(e) {
  // listデータをjsonに変換
  payload = JSON.stringify(get_weather())
  return ContentService.createTextOutput(payload).setMimeType(ContentService.MimeType.JSON);
}

function get_weather(){
  var id = "***"
  var ss = SpreadsheetApp.openById(id)
  var sheet = ss.getSheetByName("シート1")
  var values = sheet.getRange(1, 2, 4, 9).getValues()

  var res = {}
  res[values[0][0]] = values[0][1];
  res[values[0][2]] = values[0][3];
  for(var i=2; i<4; i++){
    res[values[i][0]] = {};
    for(var j=1; j<8; j++){
      res[values[i][0]][values[1][j]] = values[i][j];
    }
  }
  Logger.log(res)
  return res  
}
```

### M5Stack側のコーディング

- [arduino IDE](https://www.arduino.cc/en/main/software)を使います。
- M5stackの初期設定をします。(https://docs.m5stack.com/#/en/arduino/arduino_development)
- arduino IDEのメニューから「ツール」→「ライブラリを管理…」を選択し、「ArduinoJson」のライブラリをダウンロードします。
- SSIDとpasswordはWi-Fi環境に合わせて入力します。
- urlには、Google Apps ScriptのURL「Current web app URL:」を入力します。

```c:m5stack_weather.ino
#include <M5Stack.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>

// GAS設定
const String url = "https://script.google.com/macros/s/***/exec";

// 日本語フォント設定
const char* f24 = "genshin-regular-24pt";

// Wi-Fi設定
const char* ssid = "***";
const char* password = "***";

// NTP設定
#define TIMEZONE_JST  (3600 * 9)  // 日本標準時は、UTC（世界標準時）よりも９時間進んでいる。
#define DAYLIGHTOFFSET_JST  (0)   // 日本は、サマータイム（夏時間）はない。
#define NTP_SERVER1   "ntp.nict.jp"  // NTPサーバー
#define NTP_SERVER2   "ntp.jst.mfeed.ad.jp"   // NTPサーバー
static struct tm timeinfo;
int lasthour;
int lastmin;
String disp;

//ArduinoJson
const size_t capacity = JSON_OBJECT_SIZE(3) + 2*JSON_OBJECT_SIZE(10) + 170;
StaticJsonDocument<capacity> doc;
JsonObject weather;

// setup
void setup() {

  //M5初期化
  M5.begin();
  Serial.begin(9600); //M5.begin()の後にSerialの初期化をしている
  M5.Power.begin();
  dacWrite(25, 0); //ノイズ対策

  // フォント
  M5.Lcd.loadFont(f24, SD); // SDカードからフォント読み込み
  M5.Lcd.fillScreen(WHITE);
  M5.Lcd.setTextColor(BLACK, WHITE); 

  // Wifi接続
  M5.Lcd.println(" Wi-Fi APに接続します。");
  WiFi.begin(ssid, password);  //  Wi-Fi APに接続
  M5.Lcd.print("Wi-Fi APに接続しています");
  while (WiFi.status() != WL_CONNECTED) {  //  Wi-Fi AP接続待ち
    M5.Lcd.print(".");
    delay(100);
  }
  M5.Lcd.println(" Wi-Fi APに接続しました。");
  M5.Lcd.print("IP address: "); M5.Lcd.println(WiFi.localIP());
  
  // NTP設定
  configTime( TIMEZONE_JST, DAYLIGHTOFFSET_JST, NTP_SERVER1, NTP_SERVER2 );

  lasthour = -1;
  lastmin = -1;
  disp = "明日";
}

// loop関数
void loop() {
  String filename;
  char buf[40];
  const char* koumoku[] = {"0-6", "6-12", "12-18", "18-24"};

  // LCDをいったんクリア
  M5.Lcd.fillScreen(WHITE);

  // 10分に1回GASから天気予報を取得
  get_time();
  if(lastmin == -1 || (get_min() % 10 == 0 && lastmin != get_min())){
    lastmin = get_min(); lasthour = get_hour();
    //M5.Lcd.unloadFont();
    weather = getWeather(url);
    //M5.Lcd.loadFont(f24, SD); // SDカードからフォント読み込み
  }

  // (今日 or 明日)の天気は…を表示
  M5.Lcd.setCursor(2, 2);
  if(disp == "明日"){
    disp = "今日";
    String message = "今日の" + weather["地域"].as<String>() + "の天気は…";
    M5.Lcd.println(message);
  }else{
    disp = "明日";
    String message = "明日の" + weather["地域"].as<String>() + "の天気は…";
    M5.Lcd.println(message);
  }

  // 天気を表示
  sprintf(buf, "/weather/%d_day.jpg", weather[disp]["天気"].as<int>());
  M5.Lcd.drawJpgFile(SD, buf, 2, 32);

  // 気温を表示
  M5.Lcd.setTextDatum(0);
  M5.Lcd.setTextColor(RED, WHITE); 
  M5.Lcd.drawString(weather[disp]["最高気温"].as<String>(), 200, 64);
  M5.Lcd.setTextColor(BLUE, WHITE); 
  M5.Lcd.drawString(weather[disp]["最低気温"].as<String>(), 200, 100);


  // 降水確率を表示
  M5.Lcd.drawJpgFile(SD, "/weather/table.jpg", 10, 138);
  M5.Lcd.setTextDatum(4);
  M5.Lcd.setTextColor(BLACK, WHITE); 
  for(int i = 0; i < 4; i++){
    M5.Lcd.drawString(weather[disp][koumoku[i]].as<String>(), 48 + 75 * i, 176);
  }

  // 更新時刻を表示
  M5.Lcd.setCursor(152, 214);
  String message = weather["更新時刻"].as<String>() + "更新";
  M5.Lcd.print(message);

  // 15秒で今日と明日を切り替え
  delay(1000*15);
}

//時刻を取得する
void get_time(){
  if (!getLocalTime(&timeinfo)) {
    Serial.println("Failed to obtain time");
    return;
  }
}

// 時(hour)を取得
int get_hour(){
  return timeinfo.tm_hour;
}

// 分(min)を取得
int get_min(){
  return timeinfo.tm_min;
}

// 天気をGASから取得
JsonObject getWeather(String url) { 
  HTTPClient http;

  // Locationヘッダを取得する準備(リダイレクト先の確認用)
  const char* headers[] = {"Location"};
  http.collectHeaders(headers, 1);

  // getメソッドで情報取得(ここでLocationヘッダも取得)
  http.begin(url);
  http.addHeader("Content-Type", "application/json");
  int httpCode = http.GET();//GETメソッドで接続

  // 200OKの場合
  if (httpCode == HTTP_CODE_OK) {
    // ペイロード(JSON)を取得
    String payload = "";
    payload = http.getString();

    // JSONをオブジェクトに格納
    deserializeJson(doc, payload); //HTTPのレスポンス文字列をJSONオブジェクトに変換
    http.end();
    Serial.println(payload);

    // JsonObjectで返却
    return doc.as<JsonObject>();

  // 302 temporaly movedの場合
  }else if(httpCode == 302){
    http.end();

    // LocationヘッダにURLが格納されているのでそちらに再接続
    return getWeather(http.header(headers[0]));

  // その他
  }else{
    http.end();
    Serial.printf("httpCode: %d", httpCode);
    return doc.as<JsonObject>();
  }
}
```

# 画像ファイル・フォントの準備

- microSDカードにフォントと画像ファイル(天気予報アイコン、降水確率表示用テーブル)を格納します。
- [M5Stackで好きなフォントを使う – Watako-Lab.](https://watako-lab.com/2018/10/31/m5_font/)を参考にフォントファイル(vlwファイル)を作成しました。日本語をフルで入れるとメモリが不足するようなので、漢字は使う文字のみに限定しました。(unicodeコードポイントの特定には[Unicode文字ツール](https://www.marbacka.net/msearch/tool.php)を利用させていただきました)

```c:processingソースコード(抜粋)
static final int[] specificUnicodes = {
  0x4ECA, 0x65E5, 0x306E, 0x5929, 0x6C17, 0x306F, 0x2026, 0x660E, 0x65E5, 0x306E, 0x5929, 0x6C17, 0x306F, 
  0x2026, 0x63A5, 0x7D9A, 0x66F4, 0x65B0, 0x540D, 0x53E4, 0x5C4B, 0x2103, 
  0x672D, 0x5E4C, 0x9752, 0x68EE, 0x76DB, 0x5CA1, 0x4ED9, 0x53F0, 0x79CB, 0x7530, 0x5C71, 0x5F62, 0x798F, 
  0x5CF6, 0x6C34, 0x6238, 0x5B87, 0x90FD, 0x5BAE, 0x524D, 0x6A4B, 0x3055, 0x3044, 0x305F, 0x307E, 0x5343, 
  0x8449, 0x6771, 0x4EAC, 0x6A2A, 0x6D5C, 0x65B0, 0x6F5F, 0x5BCC, 0x5C71, 0x91D1, 0x6CA2, 0x798F, 0x4E95, 
  0x7532, 0x5E9C, 0x9577, 0x91CE, 0x5C90, 0x961C, 0x9759, 0x5CA1, 0x540D, 0x53E4, 0x5C4B, 0x6D25, 0x5927, 
  0x6D25, 0x4EAC, 0x90FD, 0x5927, 0x962A, 0x795E, 0x6238, 0x5948, 0x826F, 0x548C, 0x6B4C, 0x5C71, 0x9CE5, 
  0x53D6, 0x677E, 0x6C5F, 0x5CA1, 0x5C71, 0x5E83, 0x5CF6, 0x5C71, 0x53E3, 0x5FB3, 0x5CF6, 0x9AD8, 0x677E, 
  0x677E, 0x5C71, 0x9AD8, 0x77E5, 0x798F, 0x5CA1, 0x4F50, 0x8CC0, 0x9577, 0x5D0E, 0x718A, 0x672C, 0x5927, 
  0x5206, 0x5BAE, 0x5D0E, 0x9E7F, 0x5150, 0x5CF6, 0x90A3, 0x8987, 0x6642
};
```

- 画像ファイル(降水確率表示用テーブル)は[ここ](https://github.com/kz1017/kz1017/blob/master/m5stack_weather/resource/table.jpg)からダウンロードしてSDカードに格納してください。
- 画像ファイル(天気予報アイコン)は[いらすとや](https://www.irasutoya.com/)さんの素材で作成しました(素材の再配布はNGのようなので、各自作成いただければと思います)。作成した画像はSDカードに格納します。

# できていないこと

- importXMLの読み込みができずにうまく表示できない場合があります。importXMLは多すぎると読み込みがうまくいかない場合があるようですが、そんなにたくさん使っていないはずなので、理由がよくわかりません。
- Yahoo!天気の今日・明日の天気は[1日8回更新される](https://knowledge.support.yahoo-net.jp/PccWeather/s/article/H000007748)とのことですが、なかなかリアルタイムで取得できません。これもimportXML関数の動作によるものかなとは思いますが、まだ調べ切れていません。

# (参考)コードの解説

### googleスプレッドシートの動作

- [【簡単】スプレッドシートのIMPORTXML関数でスクレイピング！実例7個 | たぬハック](https://tanuhack.com/importxml-scraping/)と、[クローラ作成に必須！XPATHの記法まとめ - Qiita](https://qiita.com/rllllho/items/cb1187cec0fb17fc650a)を参考にさせて頂きました。
- スプレッドシートにはimportXML関数、importHTML関数がセットされており、Yahoo!天気から天気予報の情報をスクレイピングしてシート上に反映しています。
- 1～4行目がM5stackに送信する天気予報データのもとになります。
- C1セルで地域を選択できます。地域のリストはM列～O列にあり、デフォルトでは県庁所在地がリスト化されていますが、他の地域も追加できます。

### Google Apps Scriptの動作

- [GASでJSONを返すAPIを作る - Qiita](https://qiita.com/tfuruya/items/3c306ee03d1ac290bcef)を参考にさせて頂きました。
- 応答は次のようなJSONになります。

```javascript:JSON出力
"{今日={12-18=---, 6-12=---, 最低気温=15℃[0], 天気=100, 18-24=0%, 最高気温=30℃[+4], 0-6=---}, 地域=東京, 明日={最低気温=16℃[+1], 天気=114, 18-24=60%, 最高気温=24℃[-6], 0-6=10%, 12-18=20%, 6-12=10%}, 更新時刻=17時00分}"
```

### Google Apps ScriptからのJSONダウンロード

- Google Apps Scriptは、セキュリティ上の理由から、レスポンスはscript.google.comではなく、script.googleusercontent.comのワンタイムURLにリダイレクト(302)されて返却されるようです。([GASにリクエストしたら「Moved Temporarily」が返ってくるときの対処法 - Reasonable Code](https://reasonable-code.com/gas-moved-temporarily/)を参考にさせていただきました)
- HTTPClientで302を処理するためには、下記の手順を踏む必要があります。([HTTPClientのソースコード](https://github.com/espressif/arduino-esp32/blob/master/libraries/HTTPClient/src/HTTPClient.cpp)を参考にさせて頂きました)

```
1. HTTPClientのcollectHeadersメソッドで取得したいヘッダ(Locationヘッダ)を指定
2. GETメソッドでHTTPリクエストを実行
3. 302だった場合にはheaderメソッドでLocationヘッダの内容を取得
4. Locationヘッダに書かれたURLを対象にHTTPリクエストを再度実行
(以降、200 OKの応答があるまで繰り返し)
```

### JSONのデシリアライズ

- [Deserialization tutorial | ArduinoJson 6](https://arduinojson.org/v6/doc/deserialization/)を参考にさせていただきました。HTTPで受け取ったJSONをJSONObjectに格納し、weather["要素"]の形で取り出しています。

###