お問い合わせ
トップ » SORACOM IoT DIY レシピ » IoTで外部データを表示する情報端末

SORACOM IoT レシピ:IoTで外部データを表示する情報端末

M5Stackと3G拡張ボードで作る「IoT情報表示システム」

公開日: 2020年4月、更新日: 2021年6月

レシピ難易度:★★★★☆

IoTシステムでは現場の情報を取得するだけでなく、手軽に見える状態にすることが重要です。このレシピでは、液晶ディスプレイ付きプロトタイピング向けマイコンキット “M5Stack” を使って、情報表示システムの構築を体験します。

このレシピではWifi環境の無い場所に “M5Stack” を置いても情報表示ができるよう、 “3G 拡張モジュール” を組み合わせてモバイルデータ通信機能を加えます。モバイルデータ通信に SORACOM IoT SIM をデバイスとクラウドへのデータ転送支援に SORACOM Beam を使いますので、ソラコムの基本サービスを体験する目的にもピッタリなレシピです。

本レシピを行うのに必要な時間、概算費用

本レシピは以下の通りです。

  • 必要な時間: 約150分
  • 概算費用: 約14,800円

※ 概算費用: ハードウェアや SORACOM を始めとした各種サービスの概ねの費用 (送料などの付帯費用や無料枠適用は考慮しないものとしています)

このコンテンツの進め方

上から内容を読み進みながら作業を行っていきます。また左サイドに追従する目次からページ内の移動が可能です。

本コンテンツは現状のままで提供され、株式会社ソラコムは、誤りがないことの保証を含め、明示であると黙示であるとを問わず、本コンテンツの記載内容につき、いかなる種類の表明も保証も行いません。

掲載情報の閲覧及び利用により、利用者自身、もしくは第三者が被った損害に対して、直接的、間接的を問わず、株式会社ソラコムは責任を負いかねます。

本コンテンツを実践する中で用意された機器、利用されたサービスについてのご質問は、それぞれの機器やサービスの提供元にお問い合わせをお願いします。機器やサービスの仕様は、本コンテンツ作成当時のものです。

株式会社ソラコムが提供する機器・サービスについてのご質問はフォームで受け付けております。機器・サービスご利用前の導入相談は https://soracom.jp/contact/ に、機器・サービスご利用開始後のサポートは、SORACOMユーザーコンソール内のサポートサイトから「リクエストを送信」(要ログイン)にてお問い合わせください。

Copyright (c) 2023 SORACOM, INC.

材料(必要なもの)

本レシピを行うためには以下のものをご用意ください。

ハードウェア

品名数量価格備考
M5Stack Basic 3G 拡張ボード セット112,980円セットの中には以下のものが含まれています。それぞれを個別に準備しても構いません。
・M5Stack Basic x 1台
M5Stack 用 3G 拡張ボード x 1台
※注意: M5Stack 用 3G 拡張ボードが対応している M5Stack は Basic と Gray の2モデルです。M5Stack FIRE は非対応ですのでご注意ください。
SORACOM 特定地域向け IoT SIM (plan-D ナノサイズ)1844.8円M5Stack用3G拡張ボードが対応しているSIMサイズはナノサイズです。金額は1枚あたりで、決済時に商品の合計金額が1円未満の金額については繰り上げとなります。
対辺1.5mm 六角レンチ(ドライバー)1約300円M5Stack 用 3G 拡張ボードへ SIM を挿す際にボードの取り付け・取り外しに使用します。
microSDカード1500〜1000円M5Stack が取得するデータや画面描画に使用するフォントファイルの保存用に利用します。
大容量である必要はありませんので、安価なものやお手持ちのものを利用いただいてもかまいません。
開発用パソコン1インターネット接続が可能でサイトへの接続が自由であること。USB Type-A ポートが最低1つ以上用意出来ること。USB ポートからの電力供給が1A以上であること。OS は macOS(10.11 El Capitan 以上) もしくは Windows(8.1 以上)。管理者権限を有しており、アプリケーションやドライバソフトウェアのインストールが自由であること。
Arduino IDE と M5Stack 開発環境が整っていること (セットアップ方法は 【SORACOM ハンズオン】M5Stack 開発環境セットアップ (Windows / macOS 共通) (全体で約90分) をご覧ください)
(必要な方のみ)USB 変換アダプタ1パソコンに USB Type-A ポートがない場合に準備してください。1A 以上の電力が供給できるものを利用してください。(USB 3.0以上に対応していれば概ね安心です)
(必要な方のみ)USB Type-C ↔ Type-C ケーブル1パソコンに USB Type-A ポートが用意できず、また、USB 変換アダプタも用意できない場合に準備してください。
(必要な方のみ)SDカードリーダライター1パソコンに SD カードを読み書きするポートがない場合に準備してください。

※ 金額はレシピ作成時となります。金額は税込み・送料別です。その他は参考価格となります。

ご購入について

ハードウェアは以下よりご購入いただけます。

ご購入先にはSORACOM IoT ストア以外のショッピングサイトも含まれています。ご購入先や在庫、動作保証をするものではありませんのでご留意ください。

アカウント

必要なもの費用作成方法など
SORACOM アカウント無料※SORACOM アカウントの作成 (JP)
OpenWeatherMap アカウント無料※「OpenWeatherMap API の API キーを取得」セクションを参考に、アカウントを登録(サインアップ)してください。

※ アカウント作成・維持の費用の料金です。

準備

M5Stack 用 3G 拡張ボードに SIM を取り付ける

M5Stack 用 3G 拡張ボード(以下、3G 拡張ボード) には SIM スロットが備わっており、ここに SIM を入れることで 3G 通信が可能となります。 SIM の取り付け・取り外しは 3G 拡張ボードをケースから取り外す必要があります。

3G 拡張ボードをケースから取り外す

3G 拡張ボードの四隅にあるネジを取り外します。ネジは紛失しないようにしてください。

SIM を取り付ける(取り外し方法含む)

SIM のサイズは nano です。取り付けは SIM をスロットに挿入したら「カチッ」と音が鳴るまで押し込みます。音が鳴ったら完了です。取り外しは SIM を奥まで押し込み「カチッ」と音が鳴れば SIM が出てきますので取り外しできます。

3G 拡張ボードをケースに取り付ける

再度 3G 拡張ボードをケースに取り付けます。取り付け向きはピンが外側 (ケースから飛び出るように) します。逆向き (ピンがケースの内側を向いてしまっている) には取り付け内でください。

最後はネジで固定します。

M5Stack ボードの取り付け向き
M5Stack は正方形であるためボードとケースの取り付け方角がわかりづらいのですが、ボード上のピン( M5-BUS と呼ばれる)の辺と、ケース側面の切れ込みの辺を合わせるようです。

重ねる

取り付け終わったら一番下から「 BOTTOM 」「 3G 拡張ボード」「 Core※ 」と重ねていきます。

※ Core = M5Stack の LCD(モニター)やボタンがついているモジュール

以上で 3G 拡張ボードへの SIM 取り付け作業は完了です。

ライブラリのインストール

ライブラリマネージャからのインストール

ここでは、3G 拡張ボードで利用できる通信ライブラリ( TinyGSM )のインストールを例に、ライブラリマネージャから必要なライブラリのインストールを説明します。

Arduino IDE のメニューバーから [ツール]→[ライブラリを管理…]をクリックします。

※ 画面は macOS ですが、Windows も同様です。

検索ボックスに「 TinyGSM 」と入力し、「TinyGSM ( by Volodymyr Shymanskyy ) 」の項目にある [インストール] ボタンをクリックします。

同様の手順で、このレシピで使用する他のライブラリをインストールします。ライブラリ名を以下に示します。

ライブラリ名動作確認済みのバージョン
(2021年5月時点)
備考
TinyGSM0.11.3バージョン 0.11.2 ではコンパイルエラーが発生する場合があります。
ArduinoHTTPClient0.4.0
ArduinoJSON6.18.0

.ZIPファイルからのインストール

一部のライブラリはライブラリマネージャからではなく、公開されているリポジトリから ZIP ファイルをダウンロードして手動でインストールする必要があります。ここでは、M5Stack で TTF フォントを表示するために必要な M5FontRender を例にインストール手順を説明します。

リポジトリのページから [Code] → [Download ZIP] をクリックし、ZIP ファイルをダウンロードします。

Arduino IDE から [スケッチ] → [ライブラリをインクルード] → [ .ZIP 形式のライブラリをインストール] をクリックし、ダウンロードしたライブラリの ZIP ファイルを選択します。

手動でインストールしたライブラリとの衝突(コンフリクト)
手動で .ZIP 形式のライブラリをインポート後に Arduino IDE のライブラリマネージャを利用してアップデートを実施すると、スケッチのコンパイル時に「複数のライブラリが見つかりました」という旨のエラーが表示される場合があります。
上記のようなエラーが発生した場合は、Arduino のライブラリフォルダから当該ライブラリ名の末尾に「 -master 」が付いたフォルダを削除することで、ライブラリマネージャが管理するフォルダのみが残り、正常にコンパイルできるようになります。
(ライブラリフォルダは、Windowsの場合は「 C:\Users\(ユーザ名)\Documents\Arduino\Libraries 」、Macの場合は「 /Users/(ユーザ名)/Documents/Arduino/libraries」になります)

OpenWeatherMap API の API キーを取得

天気データは OpenWeatherMap (以下 OWM) API を利用して取得します。天気に関するさまざまな情報を取得できますが、今回は無料で現在の天気情報を取得できる Current weather data API を利用します。

サインアップ

Sign up ページに必要な項目を入力し、「 Create Account 」ボタンをクリックして登録を完了します。

サインアップ時に送られてくる API キーを確認する

OWM のサインアップが完了すると API キーとそれを利用した天気データ取得のサンプル API コールの URL がメールで送られてきます。

なお、API キーは OWM の自分の API キー一覧ページ(右上の自分のアカウントが書かれているメニューをクリック→「 My API Keys 」)からも確認や追加発行できます。

ブラウザでテストする

先ほど入手した API キー を使って、まずはブラウザで天気データの JSON を取得してみます。以下の URL をブラウザのアドレスバーに入力してください。

その際、 <YOUR_API_KEY> を先ほど入手した API キー に置き換えてください。

http://api.openweathermap.org/data/2.5/weather?q=Tokyo&appid=<YOUR_API_KEY>&lang=ja&units=metric

以下のように天気データの JSON が表示されれば成功です(表示したときの天気の状況によってレスポンスの内容は異なります)。

microSD カードへのデータ配置

取得できた JSON レスポンスはただのデータでしかありません。このレシピでは取得した天気データをテキストや画像を用いて視覚的に分かりやすい表示をするため、microSD カードにあらかじめ描画に利用するファイルやフォルダを配置します。

microSD カードのフォーマット

M5Stack 用に利用する microSD カードは、あらかじめ FAT32 形式でフォーマットしておきます(フォーマット方法はご利用の PC の OS によって異なります)。フォーマットを実施すると、すでに書き込まれていた microSD 内のデータはすべて消去されますので注意してください。

TTF フォントファイルのダウンロード

文字の描画に利用する TTF フォントファイルをダウンロードします。このレシピでは Google Fonts に収録されているフリーフォント「小杉丸ゴシック」を利用することとします。

右上の [Download family] ボタンをクリックしてフォントをダウンロードし、ZIP ファイルを展開しておきます。

その他の TTF フォントを利用する場合、ファイルサイズや収録されている文字の種類によっては正しく読み込めなかったり、描画されない場合があります。

フォルダの作成とフォントファイルの配置

microSD カードに「 fonts 」と「 icons 」の 2 つのフォルダを作成し、fonts フォルダにダウンロードした TTF フォントファイルを配置します。icons フォルダの中身は空で問題ありません。

このフォルダ名は後述のスケッチ内の定義で変更できますが、このレシピでは以下のようなフォルダとファイルの配置を前提とします。

/
├── fonts
│   ├── KosugiMaru-Regular.ttf
└── icons

microSD カードを M5Stack に挿入する

microSD をコンピュータからアンマウントし、M5Stack 下部のスロットに挿入します。SIM カードと同じく「カチッ」と音がするまで奥まで差し込んでください。

以上でレシピを実装する準備が整いました。

セルラー通信のテスト

3G 拡張ボードを使って SORACOM Air によるセルラー通信を行い、3G 拡張ボードの動作確認を行います。ここでは、世界時計を API で提供している WorldTimeAPI から日時を取得して表示します。

ブラウザで試してみるには?
WorldTimeAPI を始めとした、いわゆる Web API はブラウザでも実行できることがあります。今回の WorldTimeAPI であればブラウザで http://worldtimeapi.org/api/timezone/Asia/Tokyo.txt を開いてみると、API で取得できる値をブラウザで表示できます。

WorldTimeAPI の利用上の注意点
このAPIは、IoTデバイスのメーカーやクリエイターが、簡単にパースできるデータ形式を使って、位置情報の初期時刻を取得するために設計されており(FAQより)、本レシピでは3G拡張ボードの動作確認として使用しました。他の目的利用においては、FAQ等を参照ください。

Arduino IDE を起動し[ファイル]>[新規ファイル]を開くと void setup() { から始まる「空のスケッチ」が表示されます。

一度スケッチの内容を削除してから、後述のスケッチで置き換えてください。

m5stack_3gextboard_worldclock.ino

/*
 * Copyright (c) 2019 Kohei "Max" MATSUSHITA
 * Released under the MIT license
 * https://opensource.org/licenses/mit-license.php
*/
#include <M5Stack.h>

#define TINY_GSM_MODEM_UBLOX
#include <TinyGsmClient.h>

TinyGsm modem(Serial2); /* 3G board modem */
TinyGsmClient ctx(modem);

void setup() {
  M5.begin();
  M5.Lcd.clear(BLACK);
  M5.Lcd.setTextColor(WHITE);
  M5.Lcd.println(F("M5Stack + 3G Module"));

  M5.Lcd.print(F("modem.restart()"));
  Serial2.begin(115200, SERIAL_8N1, 16, 17);
  modem.restart();
  M5.Lcd.println(F("done"));

  M5.Lcd.print(F("getModemInfo:"));
  String modemInfo = modem.getModemInfo();
  M5.Lcd.println(modemInfo);

  M5.Lcd.print(F("waitForNetwork()"));
  while (!modem.waitForNetwork()) M5.Lcd.print(".");
  M5.Lcd.println(F("Ok"));

  M5.Lcd.print(F("gprsConnect(soracom.io)"));
  modem.gprsConnect("soracom.io", "sora", "sora");
  M5.Lcd.println(F("done"));

  M5.Lcd.print(F("isNetworkConnected()"));
  while (!modem.isNetworkConnected()) M5.Lcd.print(".");
  M5.Lcd.println(F("Ok"));

  M5.Lcd.print(F("My IP addr: "));
  IPAddress ipaddr = modem.localIP();
  M5.Lcd.print(ipaddr);
  delay(2000);
}

void loop() {
  M5.update();

  M5.Lcd.clear(BLACK);
  M5.Lcd.setCursor(0, 0);
  M5.Lcd.println(F("World Clock from worldtimeapi.org"));

  /* HTTP GET example */
  if (!ctx.connect("worldtimeapi.org", 80)) {
    Serial.println(F("Connect failed."));
    return;
  }
  Serial.println(F("connected."));

  /* send request */
  ctx.println("GET /api/timezone/Asia/Tokyo.txt HTTP/1.0");
  ctx.println("Host: worldtimeapi.org");
  ctx.println();
  Serial.println("sent.");

  /* receive response */
  while (ctx.connected()) {
    String line = ctx.readStringUntil('\n');
    Serial.println(line);
    if (line == "\r") {
      Serial.println("headers received.");
      break;
    }
  }
  char buf[1 * 1024] = {0};
  ctx.readBytes(buf, sizeof(buf)); /* body */
  ctx.stop();
  M5.Lcd.println(buf);

  delay(1000 * 10);
}

マイコンボードに書き込む

Arduino IDE でボタンをクリックします。ボードへの書き込みが完了しました。と表示されたら正常終了です。

ファイルの保存ダイアログが表示されたら
ファイルを保存してください。ファイル名は任意ですが m5stack_3gextboard_worldclock というファイル名はどうでしょうか。

ファイルを保存しない (キャンセル) してもコンパイルとスケッチの転送はされます。

実行の様子

最初にモデムの型番や IP アドレスを表示した後に WorldTimeAPI から取得したデータを表示します。現在の日時などのブラウザで動作確認したときのような内容が表示されていれば成功です。

gprsConnect(soracom.io) から実際に IP アドレスが表示される(= 接続される)までに30~45秒程度掛かりますが、異常ではありません。

うまく動作しなかった場合

症状考えられる原因対策
getModemInfo() の結果が SARA-U201... と表示されない(空の場合など)3G 拡張ボードで内部エラーが発生している可能性があるカスタマーサポートへご連絡ください。
waitForNetwork() より先に進まないSIM が取り付けられていない。(もしくは SORACOM IoT SIM ではない)SORACOM 特定地域向け IoT SIM plan-D を取り付けてください。
電波が圏外もしくは微弱である可能性がある窓際等、通信条件が良い環境でお試しください。
SIM が SORACOM に登録されていない※ SORACOM ユーザーコンソールで確認できます ( “登録されてない” 事が確認できます)発注済みの SIM を登録する もしくは 通販サイトやイベント等で入手した SIM を登録する を行ってください。
SIM の「状態」が “準備完了” となっている( “使用中” でない)※ SORACOM ユーザーコンソールで確認できます当該 SIM のチェックボックスをチェックしてから[操作]>[使用開始]をクリックして “使用中” に変更してください。

天気データを表示する (直接アクセス版)

それでは本題の OWM API から取得した天気データを M5Stack で表示します。

Arduino IDE を起動し[ファイル]>[新規ファイル]を開き、以下のスケッチを貼り付けます。

M5Stack から OWM API のURL を直接アクセスするため、スケッチ内61行目のクエリパラメータに含まれている <YOUR_API_KEY> を API キー で置き換えるようにしてください。

スケッチの.inoファイルはこちら

/*
 * Copyright (c) 2021 Hisaya OKADA
 * Released under the MIT license
 * https://opensource.org/licenses/mit-license.php
*/
#define IMAGE_BUFFER_SIZE 10 * 1024
#define IMAGE_ICON_DIR "/icons"
#define FONT_FILE_PATH "/fonts/KosugiMaru-Regular.ttf"
#define TINY_GSM_MODEM_UBLOX
#define CONSOLE Serial

#include <M5Stack.h>
#include <HTTPClient.h>
#include <M5FontRender.h>

#include <TinyGsmClient.h>
#include <ArduinoHttpClient.h>
#include <ArduinoJson.h>

TinyGsm GsmModem(Serial2);
TinyGsmClient GsmClient(GsmModem);
M5FontRender render;

void initializeGsmModem() {
  // Begin GSM modem initialization process
  CONSOLE.println("Initializing GSM modem...");
  renderProcessingScreen("通信モジュールを初期化中…");

  // Open Serial2 as 3G Module
  Serial2.begin(115200, SERIAL_8N1, 16, 17);
  
  // Start GSM Modem
  GsmModem.restart();
  CONSOLE.println("--- Modem Info ---");
  String info = GsmModem.getModemInfo();
  CONSOLE.println(info);
  CONSOLE.println("---  End Info  ---");

  // Wait for catch cellular netwoek
  CONSOLE.println("Waiting cellular network...");
  renderProcessingScreen("セルラーネットワークに接続中…");
  while(!GsmModem.waitForNetwork()) CONSOLE.print(".");

  // Connect SORACOM network
  CONSOLE.println("Connecting SORACOM...");
  renderProcessingScreen("SORACOMに接続中…");
  GsmModem.gprsConnect("soracom.io", "sora", "sora");
  while(!GsmModem.isNetworkConnected()) CONSOLE.print(".");

  // Show device IP address
  CONSOLE.println("Device IP Address: ");
  IPAddress ipaddr = GsmModem.localIP();
  CONSOLE.println(ipaddr);
}

String getWeatherJSON() {
  // Create HTTP Client
  HttpClient httpClient = HttpClient(GsmClient, "api.openweathermap.org", 80);

  // Send request
  int err = httpClient.get("/data/2.5/weather?q=Yokohama&appid=<YOUR_API_KEY>&lang=ja&units=metric");
  if (err != 0) {
    CONSOLE.println("Failed to get weather: connection failed.");
    renderErrorScreen("OWMとの接続に失敗しました");
    return "";
  }
  renderProcessingScreen("OWMに接続しました");

  // Get header
  int statusCode = httpClient.responseStatusCode();
  CONSOLE.print("Status: HTTP "); CONSOLE.println(statusCode);
  if (statusCode >= 400) {
    CONSOLE.println("Failed to get weather: invalid status code.");
    renderErrorScreen("異常なステータスコードです");
    return "";
  }

  // Get response
  String response = httpClient.responseBody();
  CONSOLE.print("Response: "); CONSOLE.println(response);

  // Close connection
  httpClient.stop();

  renderProcessingScreen("天気データを取得しました");
  return response;
}

int getWeatherIconViaOWM(String iconCode, uint8_t *imageBuf) {
  // Create HTTP Client
  HttpClient httpClient = HttpClient(GsmClient, "openweathermap.org", 80);

  // Send request: @2x means large size icon
  int err = httpClient.get("/img/wn/" + iconCode + "@2x.png");
  if (err != 0) {
    CONSOLE.println("Failed to get weather icon: connection failed.");
    renderErrorScreen("OWMとの接続に失敗しました");
    return -1;
  }
  renderProcessingScreen("OWMに接続しました");

  // Get header
  int statusCode = httpClient.responseStatusCode();
  CONSOLE.print("Status: HTTP "); CONSOLE.println(statusCode);
  if (statusCode >= 400) {
    CONSOLE.println("Failed to get weather icon: invalid status code.");
    renderErrorScreen("異常なステータスコードです");
    return -1;
  }

  // Get response header
  while (httpClient.connected()) {
    String line = httpClient.readStringUntil('\n');
    if (line == "\r") {
      break;
    }
  }
  // Get response body
  int length = httpClient.read(imageBuf, IMAGE_BUFFER_SIZE);
  
  // Close connection
  httpClient.stop();

  renderProcessingScreen("天気アイコンを取得しました");
  return length;
}

String getWeatherIconFilePath(String iconCode) {
  char filename[50] = { 0 };
  sprintf(filename, "%s/%s.png", IMAGE_ICON_DIR, iconCode);

  // If weather icon already exists on SD card, return immediately its filename.
  if (SD.exists(filename)) {
    CONSOLE.print("Weather icon is exist on SD card: "); CONSOLE.println(iconCode);
    renderProcessingScreen("SDカードからアイコンを取得しました");
    return filename;
  }

  // Otherwise, try to download from OpenWeatherMap
  static uint8_t imageBuf[IMAGE_BUFFER_SIZE] = { 0 };
  int imageSize = getWeatherIconViaOWM(iconCode, imageBuf);
  CONSOLE.print(imageSize); CONSOLE.println(" bytes received");

  // If download is successful, save to SD card
  int writtenBytes = 0;
  if (imageSize > 0) {
    File file = SD.open(filename, FILE_WRITE);
    writtenBytes = file.write(imageBuf, imageSize);
    CONSOLE.print(writtenBytes); CONSOLE.println(" bytes wrote on SD.");
    file.close();
  }

  if (writtenBytes <= 0) {
    CONSOLE.println("Failed to write file on SD card, abort.");
    renderErrorScreen("天気アイコンの保存に失敗しました");
    return "";
  }

  renderProcessingScreen("天気アイコンを保存しました");
  return filename;
}

void setup() {
  M5.begin();
  
  // Setup font renderer
  if (!render.loadFont(FONT_FILE_PATH)) {
    CONSOLE.println("Failed to load font file.");

    // Display error message on LCD using default font
    M5.Lcd.setTextSize(2);
    M5.Lcd.setTextColor(TFT_RED);
    M5.Lcd.printf("Failed to load font file, abort.");
    while(1);
  }
  render.enableAutoNewline(true);
  
  // Initialize GSM modem
  initializeGsmModem();
}

void loop()
{
  M5.update();
  M5.Lcd.clear(TFT_BLACK);
  
  // Get weather data
  CONSOLE.println("Receiving weather data...");
  renderProcessingScreen("天気データを取得中…");
  String responseString = getWeatherJSON();
  
  // Parse received JSON data
  CONSOLE.println("Processing response JSON data...");
  renderProcessingScreen("JSONオブジェクトを処理中…");
  StaticJsonDocument<2048> J_ROOT;
  DeserializationError error = deserializeJson(J_ROOT, responseString);

  if (error) {
    CONSOLE.println("Failed to parse JSON!");
    CONSOLE.println(error.f_str());
    renderErrorScreen("JSONの処理に失敗しました");
    while(1);
  }

  // Extarct weather data from JSON Objects
  JsonObject J_WEATHER = J_ROOT["weather"][0];
  JsonObject J_MAIN = J_ROOT["main"];
  JsonObject J_WIND = J_ROOT["wind"];

  String city = J_ROOT["name"].as<String>();
  String description = J_WEATHER["description"].as<String>();
  String iconCode = J_WEATHER["icon"].as<String>();
  int temperature = (int) J_MAIN["temp"].as<float>();
  int humidity = (int) J_MAIN["humidity"].as<float>();
  int windSpeed = J_WIND["speed"].as<int>();
  
  // Get weather image icon from SD or OpenWeatherMap
  renderProcessingScreen("天気アイコンを取得中…");
  String imageFilePath = getWeatherIconFilePath(iconCode);

  // Render main weather screen
  renderMainScreen(city, imageFilePath, description, temperature, humidity, windSpeed);

  // wait for press center button (Btn B)
  do {
    M5.update();
  }
  while(M5.BtnB.isReleased());

}

/**
 * Draw center aligned text into boundary
 * This is ROUGH calcuration, so it can't centering if str is mixed multi-byte characters.
 */
void drawCenterAlignedText(const char *str, int bx, int by, int bw, int bh, int fontSize, bool useMultibyte) {
  int tw = (float)fontSize * (float)((float)strlen(str) / (float)(useMultibyte ? 3 : 2));
  int th = (float)(fontSize * 1.1);

  int marginX = (bw - tw > 0) ? ((float)(bw - tw) / 2) : 0;
  int marginY = (bh - th > 0) ? ((float)(bh - th) / 2) : 0;

  render.setTextSize(fontSize);
  render.drawString(str, bx + marginX, by + marginY);
}

/**
 * Render main weather screen.
 */
void renderMainScreen(String city, String weatherIconPath, String description, int temperature, int humidity, int windSpeed) {
  M5.Lcd.clear(BLACK);
  render.setTextColor(TFT_WHITE);
  
  // Render city name
  M5.Lcd.drawRect(0, 0, 320, 40, TFT_ORANGE);
  drawCenterAlignedText(city.c_str(), 0, 0, 320, 40, 30, true);

  // Render weather icon (if exist)
  if (weatherIconPath != "") {
    M5.Lcd.drawPngFile(SD, weatherIconPath.c_str(), 0, 40, 120, 120, 0, 0, 1.2, 127);
  }

  // Render weather description
  render.setTextColor(TFT_WHITE);
  drawCenterAlignedText(description.c_str(), 120, 40, 180, 120, 36, true);

  // Temperature
  M5.Lcd.drawRect(0, 160, 100, 75, TFT_WHITE);
  drawCenterAlignedText("気 温", 0, 160, 100, 20, 20, true);
  
  render.setTextSize(32);
  render.setCursor(10, 190);
  render.printf("%-3d℃", temperature);

  // Humidity
  M5.Lcd.drawRect(110, 160, 100, 75, TFT_WHITE);
  drawCenterAlignedText("湿 度", 110, 160, 100, 20, 20, true);
  
  render.setTextSize(32);
  render.setCursor(125, 190);
  render.printf("%-3d%%", humidity);

  // Wind speed
  M5.Lcd.drawRect(220, 160, 100, 75, TFT_WHITE);
  drawCenterAlignedText("風 速", 220, 160, 100, 20, 20, true);
  
  render.setTextSize(32);
  render.setCursor(230, 190);
  render.printf("%-2dm/s", windSpeed);
}

/**
 * Render processing screen
 */
static int progressState = 0;
const static char* progressBar[6] = {"○●●●●", "●○●●●", "●●○●●", "●●●○●", "●●●●○", "●●●●●"};
void renderProcessingScreen(String message) {
  
  drawCenterAlignedText("★処理中です★", 0, 0, 320, 25, 25, true);
  
  M5.Lcd.fillRect(0, 100, 320, 140, TFT_BLACK);
  drawCenterAlignedText(message.c_str(), 0, 100, 320, 40, 20, true);
  drawCenterAlignedText(progressBar[progressState % 6], 0, 200, 320, 20, 20, true);
  
  progressState++;
}

/**
 * Render error screen
 */
void renderErrorScreen(String message) {
  M5.Lcd.clear(TFT_BLACK);
  render.setTextColor(TFT_RED);
  drawCenterAlignedText("!! ERROR !!", 0, 0, 320, 25, 25, false);
  drawCenterAlignedText(message.c_str(), 0, 100, 320, 40, 20, true);
}

実際の動作

このスケッチは以下のような内容になっております。

  • 3G モジュールを初期化し、SORACOM ネットワークに接続します( initializeGsmModem )
  • OWM API を利用し天気データを取得します( getWeatherJSON )
  • 取得した天気データをもとに画面を描画します( renderMainScreen )。
  • microSD カード内にすでに保存した画像がある場合は、その画像を利用します( getWeatherIconFilePath )。
    保存されていない場合は OWM が提供しているアイコン画像を取得し、microSDカードの /icons フォルダ配下に画像を保存します( getWeatherIconViaOWM )。
  • M5Stack の真ん中のボタンを押すと再度天気データを取得します。

M5Stack は背面に磁石がついているので、下図のように玄関ドアや冷蔵庫のドアに貼り付けておき、お出かけ前に現在の天気を確認する…といった使い方もできるかもしれません。

天気データを表示する (SORACOM Beam 経由版)

ここまでで M5Stack と Web サービスをセルラー通信で連携できるようになりました。直接アクセスする場合は API キー をプログラムのURLに埋め込むことを行いました。

しかし、 API キー を再発行したり、URL に基づいたパラメータの変更を行うためには毎度プログラムを書き換える必要があり、書き換え環境が無ければ何もできなくなってしまいます。もし API キーが漏洩して無効化する必要があったときは有効な API キーを再発行してスケッチを書き換える必要がありますし、さきほどのスケッチでは横浜市の天気を表示していましたが、他の地域の天気が見たい時にいちいち開発環境を立ち上げて書き換えるのは面倒です。

SORACOM Beamを使う理由

この手間を軽減するのが SORACOM Beam / SORACOM Funnel / SORACOM Funk といった SORACOM のサービスです。API キー やアクセス先の URL をプログラムに埋め込むのではなく、「設定情報」としてSORACOM に保管します。そして SIM が持つ固有の情報で設定情報を引き出し、その情報に基づいて URLやサービスへのアクセスを中継する仕組みです。

ここからは直接アクセスする方法から SORACOM Beam を中継する方法へ切り替えていきます。

SORACOM Beam の設定

このレシピの SORACOM Beam では http://beam.soracom.io:8888/weather へのアクセスを OWM へのアクセスとして中継する設定とします。

beam.soracom.io へのアクセスは http で大丈夫?
M5Stack から beam.soracom.io (以下Beam) へのアクセスは “http://” と暗号化されていませんが、安全なのでしょうか?
結果から言えば、安全であると言えます。これは M5Stack から Beam までで使われている 3G や LTE と呼ばれる「セルラー通信」は、その通信自体が暗号化されているためです。
一方、スマートフォンのアプリケーションは暗号化通信を行っていますが、これはセルラー通信のその先にあるインターネット上のサービスと通信するため、本来不要であるはずのセルラー通信の区間も暗号化しています。

SORACOM Beam 等のサービスはアクセス先の中継だけでなく、SORACOM とインターネット上のサービスとの通信区間の暗号化も肩代わります。
このため、M5Stack のようなデバイスで暗号化処理が不要になり、プログラムの負担や暗号化に伴うオーバーヘッド通信も削減しつつも、安全にインターネット上のサービスと通信する事ができます。

SIM グループの作成と所属

まずはグループの作成と、作成したグループへ SIM を所属させる事から始めます。

グループとは?
SORACOM サービスのほとんどがグループという単位に対して設定するようになっています。SORACOM Harvest Data 等、SORACOM サービスのほとんどが SIM に直接設定をするのではなくグループに設定をします。そして、SIM をグループに所属させることで SORACOM サービスが利用できるという間接的な仕組みです。
グループを作成してから SIM を所属させる方法の他、グループの作成と SIM の所属を同時に行う方法もあります。本レシピでは後者の「同時に行う」手順で進めていきます。

まず、SORACOM ユーザーコンソールにログインした後[Menu]>[SIM 管理]とクリックして SIM 管理画面を開きます。

次に、M5Stack に取り付けた SIM にチェックを付け、[操作]>[所属グループ変更]とクリックします。

SORACOM の便利な使い方: 複数の SIM を同時に扱う
チェックをつける対象を複数にすれば、一度の複数の SIM を対象に操作が可能です。

「新しい所属グループ」のプルダウンボックスをクリックした後、[新しいグループを作成…]をクリックします。

「グループ作成」のグループ名を入力して[グループ作成]をクリックします。ここでは「owm-for-m5stack」という名前のグループを作成しています。

項目備考
グループ名owm-for-m5stack自由に入力可能です。日本語も設定可能です。

新しい所属グループが先ほど作成したグループになっていることを確認したら、[グループ変更]をクリックします。

すると、自動的に SIM 管理画面に戻ります。SIM の「グループ」に先ほど作ったグループが設定されていることを確認してください。

以上で、グループの作成と所属の作業は完了です。

SORACOM Beam エンドポイントの設定

続いて Beam の設定に移ります。SIM 管理画面から、先ほど割り当てたグループ名をクリックします。

[SORACOM Beam 設定]をクリックして、設定ができるように開きます。

SORACOM Beam 設定のをクリックし、表示されたメニューの中から HTTP エントリポイントをクリックします。

「SORACOM Beam – HTTP 設定」では以下のように入力します。

エントリポイントパス/weather
転送先プロトコルHTTP
ホスト名api.openweathermap.org
ポート番号(なにも入力しません)
パス/data/2.5/weather?q=Yokohama&appid=<YOUR_API_KEY>&lang=ja&units=metric
※ <YOUR_API_KEY> を API キーに置き換えてください
ヘッダ操作(変更する場所はありません)

入力したら[保存]をクリックします。SORACOM Beam の設定に、今設定した内容が表示されていることを確認します。

スケッチの変更

OWM から取得した天気データを SORACOM Beam を経由して取得し、 M5Stack で表示します。Arduino IDE を起動し[ファイル]>[新規ファイル]を開き、以下のスケッチを貼り付けます。

貼り付ける際、スケッチの編集は不要です。

スケッチのURLはこちら

/*
 * Copyright (c) 2021 Hisaya OKADA
 * Released under the MIT license
 * https://opensource.org/licenses/mit-license.php
*/
#define IMAGE_BUFFER_SIZE 10 * 1024
#define IMAGE_ICON_DIR "/icons"
#define FONT_FILE_PATH "/fonts/KosugiMaru-Regular.ttf"
#define TINY_GSM_MODEM_UBLOX
#define CONSOLE Serial

#include <M5Stack.h>
#include <HTTPClient.h>
#include <M5FontRender.h>

#include <TinyGsmClient.h>
#include <ArduinoHttpClient.h>
#include <ArduinoJson.h>

TinyGsm GsmModem(Serial2);
TinyGsmClient GsmClient(GsmModem);
M5FontRender render;

void initializeGsmModem() {
  // Begin GSM modem initialization process
  CONSOLE.println("Initializing GSM modem...");
  renderProcessingScreen("通信モジュールを初期化中…");

  // Open Serial2 as 3G Module
  Serial2.begin(115200, SERIAL_8N1, 16, 17);
  
  // Start GSM Modem
  GsmModem.restart();
  CONSOLE.println("--- Modem Info ---");
  String info = GsmModem.getModemInfo();
  CONSOLE.println(info);
  CONSOLE.println("---  End Info  ---");

  // Wait for catch cellular netwoek
  CONSOLE.println("Waiting cellular network...");
  renderProcessingScreen("セルラーネットワークに接続中…");
  while(!GsmModem.waitForNetwork()) CONSOLE.print(".");

  // Connect SORACOM network
  CONSOLE.println("Connecting SORACOM...");
  renderProcessingScreen("SORACOMに接続中…");
  GsmModem.gprsConnect("soracom.io", "sora", "sora");
  while(!GsmModem.isNetworkConnected()) CONSOLE.print(".");

  // Show device IP address
  CONSOLE.println("Device IP Address: ");
  IPAddress ipaddr = GsmModem.localIP();
  CONSOLE.println(ipaddr);
}

String getWeatherJSON() {
  // Create HTTP Client
  HttpClient httpClient = HttpClient(GsmClient, "beam.soracom.io", 8888);

  // Send request
  int err = httpClient.get("/weather");
  if (err != 0) {
    CONSOLE.println("Failed to get weather: connection failed.");
    renderErrorScreen("Beamとの接続に失敗しました");
    return "";
  }
  renderProcessingScreen("Beamに接続しました");

  // Get header
  int statusCode = httpClient.responseStatusCode();
  CONSOLE.print("Status: HTTP "); CONSOLE.println(statusCode);
  if (statusCode >= 400) {
    CONSOLE.println("Failed to get weather: invalid status code.");
    renderErrorScreen("異常なステータスコードです");
    return "";
  }

  // Get response
  String response = httpClient.responseBody();
  CONSOLE.print("Response: "); CONSOLE.println(response);

  // Close connection
  httpClient.stop();

  renderProcessingScreen("天気データを取得しました");
  return response;
}

int getWeatherIconViaOWM(String iconCode, uint8_t *imageBuf) {
  // Create HTTP Client
  HttpClient httpClient = HttpClient(GsmClient, "openweathermap.org", 80);

  // Send request: @2x means large size icon
  int err = httpClient.get("/img/wn/" + iconCode + "@2x.png");
  if (err != 0) {
    CONSOLE.println("Failed to get weather icon: connection failed.");
    renderErrorScreen("OWMとの接続に失敗しました");
    return -1;
  }
  renderProcessingScreen("OWMに接続しました");

  // Get header
  int statusCode = httpClient.responseStatusCode();
  CONSOLE.print("Status: HTTP "); CONSOLE.println(statusCode);
  if (statusCode >= 400) {
    CONSOLE.println("Failed to get weather icon: invalid status code.");
    renderErrorScreen("異常なステータスコードです");
    return -1;
  }

  // Get response header
  while (httpClient.connected()) {
    String line = httpClient.readStringUntil('\n');
    if (line == "\r") {
      break;
    }
  }
  // Get response body
  int length = httpClient.read(imageBuf, IMAGE_BUFFER_SIZE);
  
  // Close connection
  httpClient.stop();

  renderProcessingScreen("天気アイコンを取得しました");
  return length;
}

String getWeatherIconFilePath(String iconCode) {
  char filename[50] = { 0 };
  sprintf(filename, "%s/%s.png", IMAGE_ICON_DIR, iconCode);

  // If weather icon already exists on SD card, return immediately its filename.
  if (SD.exists(filename)) {
    CONSOLE.print("Weather icon is exist on SD card: "); CONSOLE.println(iconCode);
    renderProcessingScreen("SDカードからアイコンを取得しました");
    return filename;
  }

  // Otherwise, try to download from OpenWeatherMap
  static uint8_t imageBuf[IMAGE_BUFFER_SIZE] = { 0 };
  int imageSize = getWeatherIconViaOWM(iconCode, imageBuf);
  CONSOLE.print(imageSize); CONSOLE.println(" bytes received");

  // If download is successful, save to SD card
  int writtenBytes = 0;
  if (imageSize > 0) {
    File file = SD.open(filename, FILE_WRITE);
    writtenBytes = file.write(imageBuf, imageSize);
    CONSOLE.print(writtenBytes); CONSOLE.println(" bytes wrote on SD.");
    file.close();
  }

  if (writtenBytes <= 0) {
    CONSOLE.println("Failed to write file on SD card, abort.");
    renderErrorScreen("天気アイコンの保存に失敗しました");
    return "";
  }

  renderProcessingScreen("天気アイコンを保存しました");
  return filename;
}

void setup() {
  M5.begin();
  
  // Setup font renderer
  if (!render.loadFont(FONT_FILE_PATH)) {
    CONSOLE.println("Failed to load font file.");

    // Display error message on LCD using default font
    M5.Lcd.setTextSize(2);
    M5.Lcd.setTextColor(TFT_RED);
    M5.Lcd.printf("Failed to load font file, abort.");
    while(1);
  }
  render.enableAutoNewline(true);
  
  // Initialize GSM modem
  initializeGsmModem();
}

void loop()
{
  M5.update();
  M5.Lcd.clear(TFT_BLACK);
  
  // Get weather data
  CONSOLE.println("Receiving weather data...");
  renderProcessingScreen("天気データを取得中…");
  String responseString = getWeatherJSON();
  
  // Parse received JSON data
  CONSOLE.println("Processing response JSON data...");
  renderProcessingScreen("JSONオブジェクトを処理中…");
  StaticJsonDocument<2048> J_ROOT;
  DeserializationError error = deserializeJson(J_ROOT, responseString);

  if (error) {
    CONSOLE.println("Failed to parse JSON!");
    CONSOLE.println(error.f_str());
    renderErrorScreen("JSONの処理に失敗しました");
    while(1);
  }

  // Extarct weather data from JSON Objects
  JsonObject J_WEATHER = J_ROOT["weather"][0];
  JsonObject J_MAIN = J_ROOT["main"];
  JsonObject J_WIND = J_ROOT["wind"];

  String city = J_ROOT["name"].as<String>();
  String description = J_WEATHER["description"].as<String>();
  String iconCode = J_WEATHER["icon"].as<String>();
  int temperature = (int) J_MAIN["temp"].as<float>();
  int humidity = (int) J_MAIN["humidity"].as<float>();
  int windSpeed = J_WIND["speed"].as<int>();
  
  // Get weather image icon from SD or OpenWeatherMap
  renderProcessingScreen("天気アイコンを取得中…");
  String imageFilePath = getWeatherIconFilePath(iconCode);

  // Render main weather screen
  renderMainScreen(city, imageFilePath, description, temperature, humidity, windSpeed);

  // wait for press center button (Btn B)
  do {
    M5.update();
  }
  while(M5.BtnB.isReleased());

}

/**
 * Draw center aligned text into boundary
 * This is ROUGH calcuration, so it can't centering if str is mixed multi-byte characters.
 */
void drawCenterAlignedText(const char *str, int bx, int by, int bw, int bh, int fontSize, bool useMultibyte) {
  int tw = (float)fontSize * (float)((float)strlen(str) / (float)(useMultibyte ? 3 : 2));
  int th = (float)(fontSize * 1.1);

  int marginX = (bw - tw > 0) ? ((float)(bw - tw) / 2) : 0;
  int marginY = (bh - th > 0) ? ((float)(bh - th) / 2) : 0;

  render.setTextSize(fontSize);
  render.drawString(str, bx + marginX, by + marginY);
}

/**
 * Render main weather screen.
 */
void renderMainScreen(String city, String weatherIconPath, String description, int temperature, int humidity, int windSpeed) {
  M5.Lcd.clear(BLACK);
  render.setTextColor(TFT_WHITE);
  
  // Render city name
  M5.Lcd.drawRect(0, 0, 320, 40, TFT_ORANGE);
  drawCenterAlignedText(city.c_str(), 0, 0, 320, 40, 30, true);

  // Render weather icon (if exist)
  if (weatherIconPath != "") {
    M5.Lcd.drawPngFile(SD, weatherIconPath.c_str(), 0, 40, 120, 120, 0, 0, 1.2, 127);
  }

  // Render weather description
  render.setTextColor(TFT_WHITE);
  drawCenterAlignedText(description.c_str(), 120, 40, 180, 120, 36, true);

  // Temperature
  M5.Lcd.drawRect(0, 160, 100, 75, TFT_WHITE);
  drawCenterAlignedText("気 温", 0, 160, 100, 20, 20, true);
  
  render.setTextSize(32);
  render.setCursor(10, 190);
  render.printf("%-3d℃", temperature);

  // Humidity
  M5.Lcd.drawRect(110, 160, 100, 75, TFT_WHITE);
  drawCenterAlignedText("湿 度", 110, 160, 100, 20, 20, true);
  
  render.setTextSize(32);
  render.setCursor(125, 190);
  render.printf("%-3d%%", humidity);

  // Wind speed
  M5.Lcd.drawRect(220, 160, 100, 75, TFT_WHITE);
  drawCenterAlignedText("風 速", 220, 160, 100, 20, 20, true);
  
  render.setTextSize(32);
  render.setCursor(230, 190);
  render.printf("%-2dm/s", windSpeed);
}

/**
 * Render processing screen
 */
static int progressState = 0;
const static char* progressBar[6] = {"○●●●●", "●○●●●", "●●○●●", "●●●○●", "●●●●○", "●●●●●"};
void renderProcessingScreen(String message) {
  
  drawCenterAlignedText("★処理中です★", 0, 0, 320, 25, 25, true);
  
  M5.Lcd.fillRect(0, 100, 320, 140, TFT_BLACK);
  drawCenterAlignedText(message.c_str(), 0, 100, 320, 40, 20, true);
  drawCenterAlignedText(progressBar[progressState % 6], 0, 200, 320, 20, 20, true);
  
  progressState++;
}

/**
 * Render error screen
 */
void renderErrorScreen(String message) {
  M5.Lcd.clear(TFT_BLACK);
  render.setTextColor(TFT_RED);
  drawCenterAlignedText("!! ERROR !!", 0, 0, 320, 25, 25, false);
  drawCenterAlignedText(message.c_str(), 0, 100, 320, 40, 20, true);
}

実際の動作

動作自体は直接アクセス版と同じとなります。

直接アクセス版との差分

  • 接続先ホストが api.openweathermap.org から beam.soracom.io へ変更となった (61 行目)
  • 接続先パスが /data/2.5/weather?q=..... から /weather へ変更となった

URL を変更してみる

アクセス先 URL は SORACOM Beam に設定という形で保管されるようになったため、URLの変更を行う場合でもM5Stackのスケッチを変更する必要が無くなりました。試しにSORACOM Beam上の q=Yokohama となっていた部分を、例えばロシア/ハバロフスク q=Khabarovsk と変更し、M5Stack のボタンを押して天気データの再取得を行ってみましょう。表示が変化したのではないでしょうか。

あとかたづけと注意事項

本レシピでは費用がかかるサービスを利用しています。
本項をよく読み、必要な操作や解除作業を行うようにして、想定外の費用が掛からないようにしてください。

費用について

ここで記載している金額は全て税込み、送料別となります。

SORACOM プラットフォームの利用料金

サービス/機能料金
SORACOM Air (plan-D)基本料: 11円/日
通信料: 0.22円~/MB(今回の利用であれば 1MB 以内で収まる範囲)
SORACOM Beam0.00198円/リクエスト
(SORACOM への IN で 0.00099円/リクエスト、SORACOM からの OUT で 0.00099円/リクエストであるが、今回の利用方法だとIN/OUTが1:1であるため1リクエスト当たり0.00198円となる)

※ 費用詳細はリンク先をご確認ください。

無料利用枠について
SORACOM サービスでは一部サービスにおいて無料枠が設定されています。たとえば SORACOM Air for セルラーであればアカウント毎で30円/月の通信分や、SORACOM Beam であれば100,000リクエスト分などです。料金詳細に「無料利用枠」として掲載されていますので、ご確認ください。

グループ解除

SORACOM Beam はリクエスト発生時毎の従量課金となっているため、作成したグループ内の設定が SORACOM Beam のみであれば、存在し続けることによる費用の発生はありませんが、後日利用の目途がない場合は早々にグループからの解除をお勧めいたします。

グループ解除の方法はグループからの解除 (JP)をご覧ください。

次のステップ

本レシピでは、M5Stack と 3G 拡張ボードを組み合わせた「情報表示端末」を作りました。M5Stack は文字だけでなく画像表示も可能であるため、表現力は非常に高いデバイスと言えます。

元々 M5Stack が採用している ESP32 というマイコンは Wi-Fi 通信機能が搭載されていますが、3G 拡張ボードを用いることで SSID といった設定が無くともインターネット上のサービスと接続できるといった利点、そして SORACOM サービスを組み合わせることによる「変化・変更に強いデバイス」を学んでいただけたら幸いです。

商品ページへ戻る

SORACOM IoT DIY レシピ »

レシピの達成、おめでとうございます!

達成したことをTwitterで呟く

普段の生活やビジネスに役立つ #IoTレシピ 「IoTで外部データを表示する情報端末」 を達成しました!
@SORACOM_PR https://soracom.jp/recipes_index/2960


ご質問などはこちらよりお問い合わせください。