M5PaperでRemo/Remo Eのセンサデータを表示する

この記事は Nature Remo Advent Calendar 2022/ M5Stack Advent Calendar 2022の13日目その1です。2個くらいに分けるから2つに属してても許してください。

adventar.org

qiita.com

というわけで、改めまして、ファームウェアエンジニアの井田です。 今年8月半ばからNatureでファームウェアエンジニアをやっています。

さて、今回はタイトルのとおり、 M5PaperRemo/Remo E のセンサデータを表示するネタです。

M5Paperとは

まず初めに M5Paperの説明から。

M5PaperはM5Stack社が製造・販売している、電子ペーパーを搭載した組込み向けのユニットです。 制御用のMCUとして、みなさんおなじみの ESP32 を搭載しており、無線LANBluetoothによる無線通信を行いつつ、電子ペーパーディスプレイにデータを表示するガジェットを簡単に作ることができます。

m5-docs

スイッチサイエンスでの執筆時点での価格は、14000円弱です。 (1年前は1万円切ってたのですが、円安とかでかなり高くなってしまいました…)

M5Paper V1.1www.switch-science.com

言葉で説明してもわかりづらいと思いますので、今回作ったアプリケーションを動かした状態の写真を載せておきます。

M5Paper

電子ペーパーの部品としては960x540@4.7", 16階調のディスプレイを搭載しており、高コントラストで高精細な白黒画像を表示できます。

Nature Remo Cloud API

前述のとおり、M5Paperは無線通信機能を持っているため、WiFiアクセスポイントに接続してインターネット経由で通信できます。

一方、Natureでは、Nature Remo Cloud API (以下Cloud APIと呼ぶ)を用意しています。この Cloud API経由で、Remoのユーザーが用意したプログラム等からHTTPS経由でRemo自体やRemoが制御対象としているアプライアンスの状態を取得・設定できます。

一般的にはPC等から curl コマンドやスクリプト等を用いてCloud APIにアクセスしますが、今回はM5PaperにRemoのセンサデータを表示したいので、M5Paperに搭載されている ESP32 からCloud APIにアクセスします。

M5Paper (ESP32) の開発環境

というわけで、M5Paper上で動作する、Cloud APIからデータを取得して電子ペーパーに情報を出力するプログラムを作成します。

一般的には M5Paper を含む ESP32マイコンを搭載したユニット向けのソフトウェア開発環境としては、

  1. Arduino を用いてC/C++ (のサブセットのArduino言語) で開発する
  2. MicroPythonといった組込み向けスクリプト環境で開発する

といった方法があります。これ以外にも、ESP32の開発元である Espressif が提供している ESP-IDF を直接用いることも可能です。 (Remoのファームウェア開発にはESP-IDFを使っています)

とはいえ、お気づきの通り、本記事は モダン言語によるベアメタル組込み開発 アドベントカレンダーにも登録していますので、もちろん今回は Rust でソフトウェアを開発しています。

ESP32のRust環境

Rustで組込み開発なんてできるのか?と疑問をお持ちの方は、組込みRust本 あたりを見ていただければよいかと思います。 できます。 最近はLinuxカーネルモジュールも書けるようになってきてたりします。

さて、ご存知の通り、ESP32ではよく使われてるArm Cortex-Mとは異なる、XtensaというCPUが使われています。 一般的に、Rustであるプラットフォーム向けのソフトウェア開発をおこなうためには、プラットフォームのCPUのアーキテクチャごとに対応したRustのコンパイラが必要となります。 このため、よく使われるx86_64やArm Cortexといったアーキテクチャ向けのRustコンパイラについてはrustupによって公式のバイナリを取得・インストールできます。

一方、ESP32で使われているXtensa向けのRustコンパイラは公式では提供されていません。このため1~2年前までは自分でRustコンパイラソースコードをダウンロードしてビルドして環境を構築する必要がありました。 ところが現在では、ESP32の開発元のEspressifがESP32向けのRustツールチェインやライブラリをGitHub上で公開しており、用意された手順に従って数行のコマンドを入力するだけで、ESP32向けのRust開発環境を構築できます。非常に便利になりました。

github.com

手順に沿って環境を構築すると、Rustの開発環境が簡単に手に入ります。

Cloud APIへのアクセスとセンサデータの取得

はい、というわけでまずはCloud APIへのアクセスをしてみます。

Cloud APIへのアクセス方法については、 Nature Developer Pageに記載されています。

developer.nature.global

手順としては、

  1. https://home.nature.global/home でアクセス・トークンを生成しておきます。一度生成したアクセス・トークンは生成時以外は2度と確認できないので、安全な場所に保管します。
  2. Cloud API仕様に記載されている通りのエンドポイントに、 Authorization: Bearer {アクセス・トークン} の形式でHTTPヘッダを付加してリクエストを送信します。

例えば、手元でcurlコマンドを使って、 ユーザーが所属するホームの全デバイス情報を取得するには、

 curl -H "Authorization: Bearer {アクセス・トークン}"  https://api.nature.global/1/devices

とします。出力結果を読みやすいようにエディタのフォーマット機能などを用いると、以下のような内容のJSONが得られます。

[
    {
        "name": "Remo",
        "id": "12948215-568a-49ca-be45-c556e8140c56",
        "created_at": "2022-10-07T05:57:52Z",
        "updated_at": "2022-10-07T05:57:52Z",
        "mac_address": "24:6f:28:00:11:22",
        "bt_mac_address": "24:6f:28:22:33:44",
        "serial_number": "1W012345678901",
        "firmware_version": "Remo/1.10.0",
        "temperature_offset": 1,
        "humidity_offset": 0,
        "users": [
            {
                "id": "50937884-2550-46b1-9b0a-503410c06f6d",
                "nickname": "Hoge Fuga",
                "superuser": true
            }
        ],
        "newest_events": {
            "hu": {
                "val": 66,
                "created_at": "2022-10-07T05:58:00Z"
            },
            "il": {
                "val": 27,
                "created_at": "2022-10-07T06:05:47Z"
            },
            "mo": {
                "val": 1,
                "created_at": "2022-10-07T06:05:57Z"
            },
            "te": {
                "val": 26.6,
                "created_at": "2022-10-07T06:06:01Z"
            }
        }
    },
]

このデータは https://api.nature.global/1/devices にアクセスして得られるJSONの例であり、値は実際のものではありませんが、 newest_events 以下に各種センサデータが含まれているのが分かります。このデータは Remo3 のデータの例ですので、以下のセンサデータが含まれています。

  • hu - 湿度計のデータ
  • il - 照度計のデータ
  • mo - モーションセンサ
  • te - 温度計のデータ

同様に、 https://api.nature.global/1/appliances にアクセスすると以下のデータが得られます。

[
    {
        "id": "081c5163-ee9e-486e-ba4d-e86a16ea4c9b",
        "device": {
            "name": "Remo E lite",
            "id": "159c34f6-d99a-46ca-a50a-3440ba7f8c8e",
            "created_at": "2022-10-08T07:49:56Z",
            "updated_at": "2022-10-08T07:52:43Z",
            "mac_address": "34:ab:95:00:11:22",
            "bt_mac_address": "34:ab:95:33:44:55",
            "serial_number": "4W012345678901",
            "firmware_version": "Remo-E-lite/1.7.2",
            "temperature_offset": 0,
            "humidity_offset": 0
        },
        "model": {
            "id": "1eb17958-9a47-4000-8b9d-b3dffaf9616c",
            "manufacturer": "",
            "name": "Smart Meter",
            "image": "ico_smartmeter"
        },
        "type": "EL_SMART_METER",
        "nickname": "スマートメーター",
        "image": "ico_smartmeter",
        "settings": null,
        "aircon": null,
        "signals": [],
        "smart_meter": {
            "echonetlite_properties": [
                {
                    "name": "cumulative_electric_energy_effective_digits",
                    "epc": 215,
                    "val": "7",
                    "updated_at": "2022-10-22T11:38:14Z"
                },
                {
                    "name": "normal_direction_cumulative_electric_energy",
                    "epc": 224,
                    "val": "1097158",
                    "updated_at": "2022-10-22T11:38:14Z"
                },
                {
                    "name": "cumulative_electric_energy_unit",
                    "epc": 225,
                    "val": "2",
                    "updated_at": "2022-10-22T11:38:14Z"
                },
                {
                    "name": "measured_instantaneous",
                    "epc": 231,
                    "val": "397",
                    "updated_at": "2022-10-22T11:38:14Z"
                }
            ]
        }
    }
]

Remo E/E liteは家庭のスマートメーターからECHONET Liteというプロトコルにしたがって、家の消費電力データを取得しています。 https://api.nature.global/1/appliances エンドポイントで得られるデータには、ECHONET Lite規格で規定されたプロパティがほぼそのまま格納されています。よってこれらのデータから瞬時電力や積算電力量を得られます。

例えば、瞬時電力を得るのであれば、 namemeasured_instantaneous、もしくは epc231 (0xE7) のマップの val の値を取得します。 このときの val の値が現在の瞬時電力を [W] 単位であらわした値になります。

Rust on ESP32での無線通信

さて、前節ではPC上でcurlを使ってCloud APIにアクセスしていましたが、元の目的を達成するには、ESP32上からCloud APIにアクセスする必要があります。

ESP32向けのRust環境には、ESP-IDFが提供する各種サービスをRust側から使用するためのcrateとして、 esp-idf-svc crateがあります。このcrateに含まれる EspHttpClient により、簡単にHTTPSでの通信を行えます。例えば、以下のコードで response 変数は https://api.nature.global/1/appliances からのレスポンスを表す EspHttpResponse<'a> 型の構造体となります。あとは response の内容に対してJSONの解析処理を行うだけです。

use embedded_svc::http::{client::*};
use esp_idf_svc::http::client::*;

let mut client = EspHttpClient::new(&EspHttpClientConfiguration {
    crt_bundle_attach: Some(esp_idf_sys::esp_crt_bundle_attach),
    ..Default::default()
})?;
let mut request = client.get("https://api.nature.global/1/appliances")?;
request.set_header("Authorization", "Bearer {アクセス・トークン}");
let response = request.submit()?;

組込みRustでのJSON処理

RustでJSONを解析する場合によく使われるのは serde_json crateです。ESP32向けのRust環境でも使用可能ですが、今回は serde_json を用いずに自力でJSONの解析を行っています。

理由としては、解析対象のJSONデータがESP32上で扱うには比較的大きく、serde_jsonを用いるとメモリ不足となるためです。 一般のRemoユーザーの場合は問題ない範囲の可能性がありますが、Natureのスタッフのアカウントはは業務のために大量のRemoが登録されたホームに所属しており、それに応じてappliancesやdevicesのレスポンスのサイズが数十[kiB]程度となっています。

これらのレスポンスを試しにESP32上で serde_json を使って解析しようとしたところ、タスクのスタックサイズを調整するなどしてもメモリ不足で頻繁にクラッシュする問題が発生しました。serde_jsonでは解析対象のJSONデータがメモリ上にすべて読み出せている必要があるため、数十[kiB]の連続したメモリ領域を確保する必要がありますが、このメモリ確保に失敗する場合があるためです。 (HTTPS通信をしている時点で結構メモリを使うため、空きメモリは厳しい…)

対策として、通信対象のJSONデータを一度にメモリ上に読みこむのではなく、先頭から随時解析していく、所謂SAX styleのJSONパーサーを実装しています。 また、このJSONパーサーを使って devicesとappliancesのレスポンスを解析する remo-api crateを実装しました。 (リポジトリに記載の通り、Unofficial実装です)

github.com

以下の例では、data/devices.json ファイルを開いて順次読み出しながら、デバイスの情報を表示します。

use embedded_io::adapters;
use nature_api::read_devices;
use std::{fs::File, io::Read};

fn main() {
    let mut file = File::open("data/devices.json").unwrap();
    let file_length = file.metadata().unwrap().len();
    let mut reader = embedded_io::adapters::FromStd::new(&mut file);
    let mut num_devices = 0;
    read_devices(
        &mut reader, // 入力のストリーム (embedded_io::Read traitを実装していればいい)
        Some(file_length as usize), // 入力ストリームの長さ
        |device, sub_node| { // deviceのマップを解析するたびに第3引数のクロージャが呼ばれる
            if sub_node.is_none() { 
                num_devices += 1;
            }
            println!("{:?} {:?}", device, sub_node);
        },
    )
    .unwrap();
    println!("num_devices: {}", num_devices);
}

これらをESP32のHTTPSアクセス処理と組み合わせて、目的のRemoのセンサデータを抜き出す処理は以下のように書けます。

// Cloud APIにアクセス
use embedded_svc::http::{client::*};
use esp_idf_svc::http::client::*;

let mut client = EspHttpClient::new(&EspHttpClientConfiguration {
    crt_bundle_attach: Some(esp_idf_sys::esp_crt_bundle_attach),
    ..Default::default()
})?;
let mut request = client.get("https://api.nature.global/1/appliances")?;
request.set_header("Authorization", "Bearer {アクセス・トークン}");
let response = request.submit()?;
// レスポンスを解析
let content_length = response.content_len();
let mut target_device: Option<Device> = None;
let mut target_device_newest_events: Option<NewestEvents> = None;  // 目的のデバイスのセンサデータを含む newest_event
read_devices(&mut &mut response, content_length, |device, sub_node| {
    if device.id == config::SENSOR_REMO_DEVICE_ID { // config::SENSOR_REMO_DEVICE_IDにはセンサデータのもととなるRemoのデバイスIDが入っている
        target_device = Some(device.clone());
        if let Some(DeviceSubNode::NewestEvents(newest_events)) = sub_node { // デバイスのサブノードがNewestEventsなので内容を保存しておく
            target_device_newest_events = Some(newest_events.clone());
        }
    }

})

この解析処理時に必要なバッファは、JSONの逐次解析に必要なバッファとしてキーと値1つ分を保持する領域の100~200[Bytes]、および現在解析対象のDeviceやDevice以下のノードの情報を保持する100~200[Bytes] 程度になります。

今回実装したJSONパーサーの中身については次回あたりに書こうと思います。

M5Paperの画面の制御

M5Paperに搭載されている電子ペーパーディスプレイなどを制御するためには、当然対応するハードウェアの仕様に基づいた制御用のプログラムが必要となります。

組込みRust環境でこれらのディスプレイデバイスを扱う方法としては、 embedded-graphics crateと embedded-graphics crateに対応した各デバイス制御用のcrateを組み合わせることが一般的です。 ただし、現時点では十分なディスプレイ描画速度が出るドライバは限られており、Arduino向けの各種ドライバと比較してパフォーマンス面や機能面で劣る状況です。

また、M5Stackのデバイスにて描画処理を行うのによく用いられる高パフォーマンスなディスプレイ・ドライバ実装として LovyanGFX があります。M5Paperの電子ペーパーディスプレイにも当然対応しており、複雑な図形描画やフォントの描画を高速に行えます。

そこで、今回はLovyanGFXをRustから呼び出すためのラッパーを作成しディスプレイの制御に用います。

github.com

このRustバインディングを用いた描画処理は以下のようになります。LovyanGFXをArduinoで使用している方にはおなじみの、gfx 構造体に対して、各種描画メソッドを呼び出す形式となります。

let gfx = {
    *GFX.lock().unwrap() = Some(Gfx::setup().unwrap()); // ディスプレイの初期化
    let guard = GFX.lock().unwrap();
    let gfx_shared = guard.as_ref().unwrap().as_shared();
    let mut gfx = gfx_shared.lock();
    gfx.set_epd_mode(EpdMode::Quality); // 電子ペーパーデバイスの更新方式初期化:画質重視
    gfx.set_rotation(1);                // 画面を横向きに設定
    gfx_shared
};
// ...
{
    let mut guard = gfx.lock_without_auto_update(); // ディスプレイをロック、スコープを抜けたら一括更新
    let foreground = ColorRgb332::new(0xff);
    let background = ColorRgb332::new(0x00);
    guard.set_font(lgfx::fonts::FreeMono24pt7b).ok();   // フォントを設定
    let font_height = guard.font_height();
    let line_height = font_height * 9 / 8;
    guard.clear(lgfx::ColorRgb332::new(0xff));  // クリア
    let screen_width = 960;
    let screen_height = 540;
    let chart_left = 300;
    let chart_width = screen_width - chart_left;
    // Draw TOP BAR
    {
        guard.fill_rect(0, 0, screen_width, font_height, background);
        guard.draw_string(&rate_limit_str, 0, 0, background, foreground, 0.75, 0.75, textdatum_top_left);
        guard.draw_string(&rate_limit_str, 0, 0, background, foreground, 0.75, 0.75, textdatum_top_left);
        guard.draw_string(&wifi_connection_str, 300, 0, background, foreground, 0.75, 0.75, textdatum_top_left);
    }
    let chart_height = (540 - line_height) / 3;
    let value_margin_left = 20;
    let value_width = 200;
    {
        let mut y_offset = font_height;
        let mut record_iter = sensor_records.records.iter();
        Chart::new(chart_width, chart_height, background, foreground)
            .draw(&mut guard, chart_left, y_offset, sensor_records.records.len(), min.ambient_temperature, max.ambient_temperature, move |_| record_iter.next().map(|item| item.ambient_temperature) )
            .ok();
        // 前景色foreground, 背景色background, サイズ比率75%で左上座標を起点に "Temperature:" を(0, y_offset)に描画
        guard.draw_string("Temperature:", 0, y_offset, foreground, background, 0.75, 0.75, textdatum_top_left);
        y_offset += line_height;
        guard.draw_string(&cur_temperature_str, value_margin_left, y_offset, foreground, background, 1.0, 1.0, textdatum_top_left);
        y_offset += line_height;
        guard.draw_string(&min_temperature_str, value_margin_left, y_offset, foreground, background, 0.75, 0.75, textdatum_top_left);
        y_offset += line_height * 3 / 4;
        guard.draw_string(&max_temperature_str, value_margin_left, y_offset, foreground, background, 0.75, 0.75, textdatum_top_left);

    }
}

組み合わせてアプリケーション作成

ここまでの内容を組み合わせて、筆者の家にあるRemo 3とRemo E liteのデータを表示するダッシュボードのアプリケーションを作成しています。コードはGitHubで公開していますが、ビルド手順などが未整備なので、後程まとめようと思います。

github.com

youtu.be

まとめ

Espressifによる環境整備により、M5Paperといった組込み環境上で無線通信によるインターネット接続を行うアプリケーションを比較的簡単に作れるようになってきたことがお分かりいただけたかと思います。EspressifはRustの環境整備にかなり力をいれており、今後もさらに手軽に開発できるようになることが期待できますので、ぜひ触ってみていただければと思います。

JSONパーサーとLovyanGFX呼び出し部分の詳細につきましては、後日解説しようと思います。

なお、現在エンジニア積極採用中ですので、組込みRustを含む組込み開発環境の改善に興味がある方をお待ちしております。

herp.careers

カジュアル面談もやってますので、ぜひ。

herp.careers

Natureのミッション、サービス、組織や文化、福利厚生についてご興味のある方は、ぜひCulture Deckをご覧ください。

speakerdeck.com