ATOM Matrixで電子キャンドル(天気予報機能付き)を作る

# おことわり
気象庁ホームページ内で使用されているJSONデータ(通称『気象庁API』で取得したデータ)を利用するにあたっては下記、気象庁ホームページ利用規約に従ってください。
[https://www.jma.go.jp/jma/kishou/info/coment.html](https://www.jma.go.jp/jma/kishou/info/coment.html)

※政府標準利用規約に準拠して利用できると気象庁の担当官から発言があったようですが、これを基にした気象庁ホームページ利用規約に準拠していれば問題ないと思われます。
また、気象庁ホームページの利用規約 1-(3)には気象業務法に関する利用制約の記載もあります。

# 開発の経緯

朝、出かける前に天気予報を確認するのが面倒くさい。
スマホの天気アプリを開いたりスマートスピーカーに話しかけるのでさえ面倒くさい。
出かける前に一目見るだけで天気がわかる何かはないものか。
なければ作ってしまえばいい。(強引)

# 主な用意するもの

* ATOM Matrix
* キャンドルホルダー (今回は無印良品のものを使用)
* USBケーブルとUSB電源アダプタ

# 処理の流れ

1. 気象庁ホームページから天気予報JSONを取ってくる
1. JSONから天気予報のデータを取る
1. データに応じてLEDを光分ける

# 天気予報JSONを取得する

福岡県の天気予報は下記のURLにてJSONが用意されています。
[https://www.jma.go.jp/bosai/forecast/data/forecast/400000.json](https://www.jma.go.jp/bosai/forecast/data/forecast/400000.json)

試しに取得してみましょう。

```json:400000.json
[{"publishingOffice":"福岡管区気象台","reportDatetime":"2022-01-12T17:00:00+09:00","timeSeries":[{"timeDefines":["2022-01-12T17:00:00+09:00","2022-01-13T00:00:00+09:00","2022-01-14T00:00:00+09:00"],"areas":[{"area":{"name":"福岡地方","code":"400010"},"weatherCodes":["111","270","101"],"weathers":["晴れ　夜　くもり","くもり　朝　から　昼過ぎ　雪か雨　所により　雷　を伴う","晴れ　時々　くもり"],"winds":["西の風　海上　では　北西の風　やや強く","西の風　後　北西の風　やや強く　海上　では　北西の風　強く","北西の風　やや強く"],"waves":["２．５メートル　うねり　を伴う","２．５メートル　後　３メートル","３メートル　後　２メートル"]},{"area":{"name":"北九州地方","code":"400020"},"weatherCodes":["111","270","101"],"weathers":["晴れ　夜　くもり","くもり　朝　から　昼過ぎ　雪か雨　所により　雷　を伴う","晴れ　時々　くもり"],"winds":["西の風　海上　では　北西の風　やや強く","西の風　後　北西の風　やや強く　海上　では　北西の風　強く","西の風　やや強く"],"waves":["２．５メートル　うねり　を伴う　ただし　瀬戸内側　では　１メートル","２．５メートル　後　３メートル　ただし　瀬戸内側　では　１メートル　後　１．５メートル","３メートル　後　２メートル　ただし　瀬戸内側　では　１メートル　後　０．５メートル"]},{"area":{"name":"筑豊地方","code":"400030"},"weatherCodes":["111","270","101"],"weathers":["晴れ　夜　くもり","くもり　朝　から　昼過ぎ　雪か雨　所により　雷　を伴う","晴れ　時々　くもり"],"winds":["西の風　後　南西の風","南の風　後　西の風　やや強く","西の風"]},{"area":{"name":"筑後地方","code":"400040"},"weatherCodes":["111","270","101"],"weathers":["晴れ　夜　くもり","くもり　朝　から　昼過ぎ　雪か雨　所により　雷　を伴う","晴れ　時々　くもり"],"winds":["西の風　後　南の風","南の風　後　北西の風　やや強く","西の風　後　北西の風"],"waves":["０．５メートル","０．５メートル　後　１メートル","１メートル　後　０．５メートル"]}]},{"timeDefines":["2022-01-12T18:00:00+09:00","2022-01-13T00:00:00+09:00","2022-01-13T06:00:00+09:00","2022-01-13T12:00:00+09:00","2022-01-13T18:00:00+09:00"],"areas":[{"area":{"name":"福岡地方","code":"400010"},"pops":["10","30","60","50","20"]},{"area":{"name":"北九州地方","code":"400020"},"pops":["10","30","60","50","20"]},{"area":{"name":"筑豊地方","code":"400030"},"pops":["10","30","60","50","20"]},{"area":{"name":"筑後地方","code":"400040"},"pops":["10","30","60","50","20"]}]},{"timeDefines":["2022-01-13T00:00:00+09:00","2022-01-13T09:00:00+09:00"],"areas":[{"area":{"name":"福岡","code":"82182"},"temps":["3","7"]},{"area":{"name":"八幡","code":"82056"},"temps":["2","6"]},{"area":{"name":"飯塚","code":"82136"},"temps":["1","6"]},{"area":{"name":"久留米","code":"82306"},"temps":["1","7"]}]}]},{"publishingOffice":"福岡管区気象台","reportDatetime":"2022-01-12T17:00:00+09:00","timeSeries":[{"timeDefines":["2022-01-13T00:00:00+09:00","2022-01-14T00:00:00+09:00","2022-01-15T00:00:00+09:00","2022-01-16T00:00:00+09:00","2022-01-17T00:00:00+09:00","2022-01-18T00:00:00+09:00","2022-01-19T00:00:00+09:00"],"areas":[{"area":{"name":"福岡県","code":"400000"},"weatherCodes":["270","101","201","200","201","201","201"],"pops":["","10","20","30","20","20","20"],"reliabilities":["","","A","A","A","A","A"]}]},{"timeDefines":["2022-01-13T00:00:00+09:00","2022-01-14T00:00:00+09:00","2022-01-15T00:00:00+09:00","2022-01-16T00:00:00+09:00","2022-01-17T00:00:00+09:00","2022-01-18T00:00:00+09:00","2022-01-19T00:00:00+09:00"],"areas":[{"area":{"name":"福岡","code":"82182"},"tempsMin":["","2","2","5","4","3","2"],"tempsMinUpper":["","3","4","6","6","4","5"],"tempsMinLower":["","0","0","3","1","0","0"],"tempsMax":["","8","10","12","10","9","9"],"tempsMaxUpper":["","9","11","14","13","13","12"],"tempsMaxLower":["","6","8","10","6","6","6"]}]}],"tempAverage":{"areas":[{"area":{"name":"福岡","code":"82182"},"min":"4.0","max":"10.1"}]},"precipAverage":{"areas":[{"area":{"name":"福岡","code":"82182"},"min":"7.6","max":"22.0"}]}}]
```

※ 気象庁HP 福岡県地方天気予報JSONより(2022年1月12日取得)

…うーん、長い。
あとこの長さのせいでArduinoJSONに食べさせると`NoMemory`のエラーが帰ってきます。
というわけで力技で必要なところを取ってきましよう。
ざっと眺めてると`"code":"【エリアコード】"},"weatherCodes":[`のあとに続く3桁の3つのコードを取れれば良さそうですね。
ついでに`"timeDefines":[`に続くいつの予報データかを示す日時データも取っておきましょう。

コードは次の通りです。

```cpp:JmaForecast.hpp
#include <Arduino.h>
#include <time.h>

namespace cibmc {
  class JmaForecast {
    private:
      bool is_valid;
      time_t times[3];
      int codes[3];
      static time_t iso8601strToTime_t(String iso8601_str);
    public:
      JmaForecast();
      JmaForecast(String json_str, String area_code);
      bool isValid();
      bool setData(String json_str, String area_code);
      time_t getTime(const unsigned int time_index);
      int getCode(const unsigned int time_index);
      String toString();
  };
}
```

```cpp:JmaForecast.cpp
#include "JmaForecast.hpp"

namespace cibmc {
  JmaForecast::JmaForecast() {
    this->is_valid = false;
  }

  JmaForecast::JmaForecast(String json_str, String area_code) {
    this->setData(json_str, area_code);
  }

  bool JmaForecast::isValid() {
    return this->is_valid;
  }
  
  bool JmaForecast::setData(String json_str, String area_code) {
    this->is_valid = false;
    String timedefines_search_string = "\"timeDefines\":[\"";
    int time_defines_index = json_str.indexOf("timeDefines", 0);
    if (time_defines_index == -1) return this->is_valid;
    int time_substr_from = time_defines_index + timedefines_search_string.length() -1;
    times[0] = JmaForecast::iso8601strToTime_t(json_str.substring(time_substr_from, time_substr_from + 25));
    time_substr_from = time_substr_from + 28;
    times[1] = JmaForecast::iso8601strToTime_t(json_str.substring(time_substr_from, time_substr_from + 25));
    time_substr_from = time_substr_from + 28;
    times[2] = JmaForecast::iso8601strToTime_t(json_str.substring(time_substr_from, time_substr_from + 25));

    String code_search_string = "\"code\":\"" + area_code + "\"},\"weatherCodes\":[\"";
    
    int area_codes_index = json_str.indexOf(code_search_string, time_defines_index + timedefines_search_string.length());
    if (area_codes_index == -1) return this->is_valid;
    int code_substr_from = area_codes_index + code_search_string.length();
    codes[0] = json_str.substring(code_substr_from, code_substr_from + 3).toInt();
    code_substr_from = code_substr_from + 6;
    codes[1] = json_str.substring(code_substr_from, code_substr_from + 3).toInt();
    code_substr_from = code_substr_from + 6;
    codes[2] = json_str.substring(code_substr_from, code_substr_from + 3).toInt();
  
    this->is_valid = true;
    return this->is_valid;
  }
  time_t JmaForecast::getTime(const unsigned int time_index){
    if (!this->is_valid) return 0;
    if (time_index > 2) return 0;
    return this->times[time_index];
  }
  int JmaForecast::getCode(const unsigned int time_index){
    if (!this->is_valid) return 0;
    if (time_index > 2) return 0;
    return this->codes[time_index];
  }

  String JmaForecast::toString() {
    String str = "isValid: ";
    if (!this->isValid()) {
      str += "false";
      return str;
    }
    str += "true\n";

    for (int i = 0; i < 3; i++) {
      str += "[forecast " + String(i) + "]\n";
      str += "time: ";
      time_t fc_time = this->getTime(i);
      tm *fc_tm = localtime(&fc_time);
      str += String(asctime(fc_tm));
      str += "code: " + String(this->getCode(i)) + "\n";
    }
    
    return str;
  }

  time_t JmaForecast::iso8601strToTime_t(String iso8601_str) {
    struct tm t;
    t.tm_year = iso8601_str.substring(0, 4).toInt() - 1900;
    t.tm_mon  = iso8601_str.substring(5, 7).toInt() - 1;
    t.tm_mday = iso8601_str.substring(8, 10).toInt();
    t.tm_hour = iso8601_str.substring(11, 13).toInt();
    t.tm_min  = iso8601_str.substring(14, 16).toInt();
    t.tm_sec  = iso8601_str.substring(17, 19).toInt();
    t.tm_isdst= -1;
    return mktime(&t);
  }
}
```

# weatherCodeに対応する代表天気を求める

さて天気を表すコードは取得できましたが、はたしてこれは何通りあるのでしょうか…？
あまり多いとカラーLEDの光分けが大変そうです。
早速ですが答えは下記ページJS上の「TELOPS」に格納されている連想配列にあります。
[https://www.jma.go.jp/bosai/](https://www.jma.go.jp/bosai/)
※クリック後にお住まいの地域のパラメータが付いたページにリダイレクトされると思います。

ソース閲覧機能か、各ブラウザの開発者ツールなどで「TELOPS」にて検索していただくと当該部分が出てくるかと思います。
例としていつくかのweatherCodeについて見てみましょう。
(JSONの仕様を満たすよう加工しています。)

```json:TELOPS(一部抜粋・加工)
{
  100:["100.svg","500.svg","100","晴","CLEAR"],
  211:["210.svg","610.svg","200","曇後晴","CLOUDY, CLEAR LATER"],
  213:["212.svg","212.svg","300","曇後時々雨","CLOUDY, FREQUENT SCATTERED SHOWERS LATER"],
  327:["314.svg","314.svg","400","雨夜は雪","RAIN,SNOW IN THE NIGHT"],
  414:["414.svg","414.svg","400","雪後雨","SNOW,RAIN LATER"]
}
```
※ 気象庁HP「気象庁｜あなたの街の防災情報」ページソース(2022年1月12日取得) を基に著者作成

値として格納されている配列の0番目と1番目の値については、気象庁HP上で使うSVG画像のファイル名です。
0番目が昼用で、1番目が夜用に表示するもののようです。
3番目と4番目は見ての通り天気が日本語と英語で書いてあるようです。

2番目の値が謎ですが、その前にweatherCodeそのものを見てみましょう。
コードは118種類ありますが、よく見ていると次の法則があります。

* 100番台：晴から始まる/または含む
* 200番台：曇から始まる/または含む
* 300番台：雨から始まる/または含む
* 400番台：雪から始まる/または含む

じゃあ200番台なら曇用の表示にすればいいかというと、そうもいきません。
上の例にあるコード213は「曇のち時々雨」なので曇の表示をしてそれを信じで出かけると雨に降られるかもしれません。嫌ですね。

ここで2番めの項目に注目してみましょう。300になっていますね。なんか雨っぽそうです。
ついでに他のコードの2番目も見てみると「100/200/300/400」のいずれかになっています。
便宜上この項目を「代表天気」と呼称することにしましょう。
上記の仮定を踏まえると、次のようになっているものと思われます。

## TELOPSの値
|0|1|2|3|4|
|:--|:--|:--|:--|:--|
|昼用SVG画像|夜用SVG画像|代表天気|天気文字列(和文)|天気文字列(英文)|

## 代表天気
|100|200|300|400|
|:--|:--|:--|:--|:--|
|晴|曇|雨|雪|

ちょうど4種類なので、カラーLEDの光分けにも都合がいいですね。

先程例示したTELOPSをもとにプログラムコードを作ってみましょう。
(すべてのweatherCodeに対応したコードを掲載すると権利的に問題がありそうなので、一部の実装のみ掲載)

```cpp:getRepWeatherCode
int getRepWeatherCode(int weatherCode) {
  int repWeatherCode = 0;
  switch(weatherCode) {
    case 100:
    // その他のweatherCodeは省略
      repWeatherCode = 100;
      break;
    case 210:
    // その他のweatherCodeは省略
      repWeatherCode = 200;
      break;
    case 213:
    // その他のweatherCodeは省略
      repWeatherCode = 300;
      break;
    case 327:
    case 414:
    // その他のweatherCodeは省略
      repWeatherCode = 400;
      break;
  }
  return repWeatherCode;
}
```

※ 気象庁HP「気象庁｜あなたの街の防災情報」ページソース(2022年1月12日取得) を基に著者作成

# LEDの光り方として1/fゆらぎを実装する

1/fゆらぎが何なのかの説明は下記ページをご覧ください。(投げっぱなし)

「1/f ゆらぎ」あなたと夜と数学と
[http://simomath.blog.fc2.com/blog-entry-1275.html](http://simomath.blog.fc2.com/blog-entry-1275.html)

そしてこちらのページに1/f ゆらぎの例示として掲載されている数式はこちらです。

```math:
f(x) = \sin2\pi t-3\sin4\pi t+2\cos6\pi t+2\sin10\pi t
\\※ 原数式ママ
```

これを使うとキャンドルの光り方っぽくなりますので、早速これをC++のコードに落とし込みましょう。
※ std::sinとstd::cosは角度の単位としてラジアンを取るので、使用する際はxを小数部分も含めて増やしてください。

```cpp:yuragi
#include <math.h>
float yuragi(float x) {
  return std::sin(2 * M_PI * x) - 3 * std::sin(4 * M_PI * x) + 2 * std::cos(6 * M_PI * x) + 2 * std::sin(10 * M_PI * x);
}
```

では、これらをつなぎ合わせるコードを準備しましょう。

```cpp:JmaLED.ino(前掲部分以外)
#include <math.h>
#include <FastLED.h>
#include <WiFi.h>
#include <WiFiMulti.h>
#include <HTTPClient.h>
#include "JmaForecast.hpp"

// この設定は福岡市の天気予報用です
const char* FORECAST_URL = "https://www.jma.go.jp/bosai/forecast/data/forecast/400000.json";
const char* AREA_CODE = "400010";

const char* rootCACertificate = "HTTPS Clientのスケッチ例などからコピーするなどして設定してください";

const int DATA_PIN = 27;
const int NUM_LEDS = 25;
CRGB leds[NUM_LEDS];

int led_count = 0;
bool led_color_update = true;
CRGB led_color = CRGB::DarkBlue;

WiFiMulti wifi_multi;
const char* WIFI_SSID = "お使いのSSIDを設定してください";
const char* WIFI_PASS = "パスワードを設定してください";

void setup() {
  Serial.begin(9600);
  
  WiFi.mode(WIFI_STA);
  wifi_multi.addAP(WIFI_SSID, WIFI_PASS);
  while (wifi_multi.run() != WL_CONNECTED) {
    delay(100);
  };

  FastLED.addLeds<WS2812, DATA_PIN, GRB>(leds, NUM_LEDS);
  FastLED.setBrightness(20);
  FastLED.clear();
  delay(500);
  FastLED.show();
  xTaskCreatePinnedToCore(taskLED, "taskLED", 1024 * 2, NULL, 1, NULL, 0);
}

void taskLED(void *arg) {
  while(1){
    if(led_color_update) {
      for (int i = 0; i < NUM_LEDS; i++) {
        leds[i] = led_color;
      }
      led_color_update = false;
    }
    float yuragi_float = yuragi((float)led_count / 70.0);
    int yuragi_int = (int)(yuragi_float / 2);
    FastLED.setBrightness(20 + yuragi_int);
    FastLED.show();
    led_count = led_count + 1;
    delay(50);
  }
}

void loop() {
  configTime(9 * 60 * 60, 0, "pool.ntp.org");
  int repWeatherCode = 0;

  HTTPClient https;
  if (https.begin(FORECAST_URL), rootCACertificate) {
    int httpCode = https.GET();
    if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_MOVED_PERMANENTLY) {
      String payload = https.getString();
      cibmc::JmaForecast *jf_obj = new cibmc::JmaForecast(payload, AREA_CODE);
      if (jf_obj->isValid()) repWeatherCode = getRepWeatherCode(jf_obj->getCode(0));
    } else {
      Serial.println(httpCode);
    }
  } else {
    Serial.println("Connection Error");
  }
  switch(repWeatherCode) {
    case 100:
      led_color = CRGB::Orange;
      break;
    case 200:
      led_color = CRGB::DarkGreen;
      break;
    case 300:
      led_color = CRGB::DarkBlue;
      break;
    case 400:
      led_color = CRGB::White;
      break;
    default:
      led_color = CRGB::Red;
      break;
  }
  led_color_update = true;
  Serial.println("Hello");

  https.end();
  delay(20 * 60 * 1000); //30 min
}
```

LEDのゆらぎ処理は頻繁に行う必要があるので、別タスクに分けます。
あとはATOM Matrixに書き込んで。無印良品のキャンドルホルダーを被せれば出来上がりです。

# 成果物

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ATOM Matrix で電子キャンドル（天気予報機能付き）<a href="https://twitter.com/hashtag/M5Stack?src=hash&amp;ref_src=twsrc%5Etfw">#M5Stack</a> <a href="https://t.co/ZkoJST9clk">pic.twitter.com/ZkoJST9clk</a></p>&mdash; Ongstar🏳️‍🌈/Andrei (@ongstar_jp) <a href="https://twitter.com/ongstar_jp/status/1483036987859046401?ref_src=twsrc%5Etfw">January 17, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

# 今後の改善案など

* せっかく予報の対象日時をとっているので、18時以降は必ず明日の予報を反映するようにする
* 人感センサをつけて人がいるときだけ情報を更新するようにする

# さいごに

今日も楽しい1日を！