M5PaperでRemo/Remo Eのセンサデータを表示する その2 (JSONパーサー編)

この記事は Nature Remo Advent Calendar 2022/ モダン言語による組み込み開発 Advent Calendar 2022の13日目その2です。今日は17日な気がしますが、13日その2です。 例のごとく2つのアドカレに投げてますあが、2個目なので許してください。

その1はこちら

adventar.org

qiita.com

その1の方はM5Stackアドベントカレンダーにも投げてますので、こちらもよろしければどうぞ。

qiita.com

ファームウェアエンジニアの井田です。 今年8月半ばからNatureでファームウェアエンジニアをやっています。(2回目)

前回はM5PaperでRemo/Remo Eのセンサデータを表示するプログラムの概要を説明しました。 今回はその中で出てきた、Nature Remo Cloud API (以下Cloud API) から取得したJSONデータを解析する部分の実装についての話です。

Cloud APIのデータ (再掲)

まずおさらいとして、前回掲載した Cloud APIJSONデータ例を再度掲載しておきます。

  • https://api.nature.global/1/devicesJSONデータの例
[
    {
        "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/appliancesJSONデータの例
[
    {
        "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"
                }
            ]
        }
    }
]

JSONパーサーの設計方針

JSONパーサーの設計方針として、ESP32上で問題なく動かすために、なるべく省メモリかつ実行時のメモリ確保を行わないようにします。

まず、JSONの解析に必要なバッファの長さを考えます。結論から言うと、対象のJSONデータ中に含まれるスカラー値 (文字列や数値) を十分格納できるだけのバッファが必要です。 文字列の場合は、ダブルクォートで囲むので、スカラー値の長さ + 2の長さが必要です。

実行時のメモリ確保を行わない方針ですので、コンパイル時にあらかじめ処理できるスカラー値の長さの上限から、バッファ長を決めておきます。

また、パーサーの状態管理のために、 {}[] のネストの深さ分のステートのスタックが必要となります。こちらも解析対象のJSONデータに基づいて最大値を決めておきます。

Cloud APIJSONの場合、 デバイス名やユーザー名、アプライアンス名のフィールドはユーザーにより設定可能な文字列ですので、それなりに長くなる可能性があります。 スタック深さは各APIJSONデータを見ると、devicesは4、appliancesは6の深さが必要なことがわかります。 パーサーの初期ステートもスタックに積む構成にするため、実際にはデータの深さ + 1のスタックが必要です。以下にAPIとバッファ長、スタック深さの表を示します。

対象 バッファ長 スタック深さ
/1/devices 48 5
/1/appliances 64 7

JSONパーサーの大枠の処理

JSONパーサーの大枠の処理は単純です。

  1. 入力ストリームからバッファの空きに読めるだけ読みこむ
  2. バッファの先頭から続いている 空白文字 (スペースやタブ、改行文字) を読み飛ばす
  3. バッファの文字列を現在のステートにしたがって解析する
  4. 解析結果にしたがってステートを更新する

1, 2の処理により、現在のところ入力ストリームからバッファへ読みこめるだけのデータが読みこまれており、空白を読み飛ばした位置以降には全く何もないか、空白文字以外の何かしらの文字列がある状態になっています。

この状態で、空白を読み飛ばした位置以降の文字列が、現在のパーサーの状態で期待する文字列であるかどうかを解析します。

例えば、解析処理の初期状態の場合、JSONの規則としては次の4つの可能性があります。

  1. JSONデータの末尾
  2. オブジェクトの開始 ({)
  3. 配列の開始 ([)
  4. スカラー

1は空白を除いて空のJSONデータだった場合に該当します。空なので解析結果も空です。

2はオブジェクトの開始記号が来たので、これ以降は対応する } が現れるまではキーと値のペアの列が来るはずです。

3は配列の開始記号なので、これ以降は対応する ] が現れるまで、値の列が来るはずです。

4はスカラー値を見つけたので、スカラー値を出力してここで終わりです。

この時、 {[ が来たかどうあを判定するのは簡単ですが、JSONスカラー値かどうかを解析するのは結構面倒です。 こういった解析処理を簡単に書くためのライブラリとして、 Rustには nom crateがあります。

nomによるJSONスカラー値の解析

nomはいわゆる パーサー・コンビネータ と呼ばれる機能を提供するcrateです。指定した一文字の入力を受理する関数を返す関数や空白文字を受理する関数、複数の関数を受け取ってそのうち入力を受理するものの結果を返す関数を構築する関数、と言ったパーサーを構築するための関数を組み合わせて、受理する入力の集合 (言語) を定義します。

例えば、以下のコードは char パーサーを使って指定した文字 'a' を1文字入力して返す関数を構築する例です。

use nom::character::streaming::{char};
use nom::IResult;

fn main() {
    let r: IResult<&str, char, ()> = char('a')("abc");
    println!("{:?}", r);
   // Ok(("bc", 'a'))
    let r: IResult<&str, char, ()> = char('a')("bac");
    println!("{:?}", r);
   // Err(Error(()))
}

実行例

char('a')&str を受け取って IResult<&str, char, ()> を返す関数を返します。入力 "abc" を与えると、先頭の a を入力として受理し、残りの文字列 "bc" と受理した文字列 'a' を返します。一方、入力 "bac" を与えるとエラーを返します。

nomに定義されているパーサーの詳細はnomのドキュメントに記載されています。

github.com

以下の json_scalar_value 関数はJSONスカラー値を解析して返すパーサーを構築します。

#[derive(Clone, Copy, Debug, PartialEq)]
pub enum JsonScalarValue<'a> {
    Null,
    Boolean(bool),
    String(&'a str),
    Number(JsonNumber),
}

fn json_string<'a, E: ParseError<&'a str> + ContextError<&'a str>>(
    i: &'a str,
) -> IResult<&'a str, &'a str, E> {
    context(
        "string",
        preceded(char('\"'), cut(terminated(escaped_str, char('\"')))),
    )(i)
}
fn json_null<'a, E: ParseError<&'a str>>(input: &'a str) -> IResult<&'a str, (), E> {
    value((), tag("null"))(input)
}
fn json_boolean<'a, E: ParseError<&'a str>>(input: &'a str) -> IResult<&'a str, bool, E> {
    alt((value(true, tag("true")), value(false, tag("false"))))(input)
}

fn json_scalar_value<'a, E: ParseError<&'a str> + ContextError<&'a str>>(
    _is_eof: bool,
) -> impl FnMut(&'a str) -> IResult<&'a str, JsonScalarValue, E> {
    alt((
        map(json_string, |s| JsonScalarValue::String(s)),
        map(json_boolean, |b| JsonScalarValue::Boolean(b)),
        map(json_null, |_| JsonScalarValue::Null),
        terminated(
            |i| {
                recognize_float_or_exceptions(i).and_then(|p| {
                    JsonNumber::try_parse(p.1)
                        .map(|n| (p.0, JsonScalarValue::Number(n)))
                        .or(Err(nom::Err::Failure(E::from_error_kind(
                            i,
                            ErrorKind::Float,
                        ))))
                })
            },
            peek(one_of(",]} \t\n\0")),
        ),
    ))
}

json_scalar_value 関数が返す関数は、入力したJSONスカラー値の型に応じて JsonScalarValue 列挙型の値を返します。

入力したJSONスカラー値の型 解析結果
文字列 JsonScalarValue::String(s)
数値 JsonScalarValue::Number(n)
ブール値 (true/false) JsonScalarValue::Boolean(b)
null JsonScalarValue::Null

数値型の解析は複雑ですが、幸いなことに、nomには recognize_float_or_exceptions 関数が定義されており、符号の有無や指数表記 (+1.0e-3 など) を含めた数値を解析できます。 json_scalar_value では recognize_float_or_exception を用いて数値型の解析を行っています。

作成したJSONパーサーを使ったJSON解析処理

作成したJSONパーサーは以下のAPIを持ちます。 JSONパーサーを表す Parser 型は、前述の解析用バッファ buffer フィールド、ステートのスタック state_stack などのフィールドを持ちます。 また、実際に解析を実行する parse 関数を実装します。

use heapless::Vec;
use embedded_io::{blocking::Read, Io};
#[derive(Clone, Copy, Debug)]
enum ParserState {
    Start,
    End,
    MapStart,
    MapKey,
    KeyDelimiter,
    MapValue,
    MapPairDelimiter,
    ArrayStart,
    ArrayValue,
    ArrayValueDelimiter,
    Pop,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum JsonNode<'a> {
    StartMap,
    EndMap,
    StartArray,
    EndArray,
    Key(JsonScalarValue<'a>),
    Value(JsonScalarValue<'a>),
}
pub enum ParserCallbackAction {
    Nothing,
    End,
}
pub struct Parser<const BUFFER_SIZE: usize, const MAX_DEPTH: usize> {
    state: ParserState,
    buffer: Vec<u8, BUFFER_SIZE>,
    state_stack: Vec<ParserState, MAX_DEPTH>,
    bytes_remaining: Option<usize>,
}

impl<const BUFFER_SIZE: usize, const MAX_DEPTH: usize> Parser<BUFFER_SIZE, MAX_DEPTH> {
    pub const fn new() -> Self {
        Self {
            state: ParserState::Start,
            buffer: Vec::new(),
            state_stack: Vec::new(),
            bytes_remaining: None,
        }
    }
    pub fn parse<I: Read, F, CallbackError>(
        &mut self,
        reader: &mut I,
        mut callback: F,
    ) -> Result<bool, ParserError<I::Error, CallbackError>>
    where
        F: for<'node> FnMut(JsonNode<'node>) -> Result<ParserCallbackAction, CallbackError> {
            //...
    }
}

parse 関数は文字を入力元となる embedded_io::Read を実装した型の参照と、JSONの解析内容を通知・処理するクロージャ callback を受け取ります。

クロージャJSONの各種要素を見つけるたびに、見つけたJSONの要素を表す JsonNode 列挙型の参照を引数として呼び出され、処理を継続するか否かを表す ParserCallbackAction を返します。

JsonNode 列挙型は以下の値を持ちます。

値名 内容
StartMap オブジェクト(マップ)の開始
EndMap オブジェクト(マップ)の終了
StartArray 配列の開始
EndArray 配列の終了
Key(JsonScalarValue<'a>) オブジェクト(マップ)のキー
Value(JsonScalarValue<'a>)

例として、以下のJSONデータを解析した場合のクロージャに渡される JsonNode 値を順に示します。

[
    {
        "name": "Remo",
        "users": [
            {
                "id": "50937884-2550-46b1-9b0a-503410c06f6d",
            }
        ],
        "newest_events": {
            "hu": {
                "val": 66,
                "created_at": "2022-10-07T05:58:00Z"
            },
            "il": {
                "val": 27,
                "created_at": "2022-10-07T06:05:47Z"
            },
        }
    },
]
StartArray
StartMap
Key(JsonScalarValue::String("name"))
Value(JsonScalarValue::String("Remo"))
Key(JsonScalarValue::String("users"))
StartArray
StartMap
Key(JsonScalarValue::String("id"))
Value(JsonScalarValue::String("50937884-2550-46b1-9b0a-503410c06f6d"))
EndMap
EndArray
Key(JsonScalarValue::String("newest_events"))
StartMap
Key(JsonScalarValue::String("hu"))
StartMap
Key(JsonScalarValue::String("val"))
Value(JsonScalarValue::Number(66))
Key(JsonScalarValue::String("created_at"))
Value(JsonScalarValue::String("2022-10-07T05:58:00Z"))
EndMap
Key(JsonScalarValue::String("il"))
StartMap
Key(JsonScalarValue::String("val"))
Value(JsonScalarValue::Number(27))
Key(JsonScalarValue::String("created_at"))
Value(JsonScalarValue::String("2022-10-07T06:05:47Z"))
EndMap
EndMap
EndMap
EndArray

例えば users.id50937884-2550-46b1-9b0a-503410c06f6d のデバイスhu.val を取得したい場合は、

Key(JsonScalarValue::String("users"))
StartArray
StartMap
Key(JsonScalarValue::String("id"))
Value(JsonScalarValue::String("50937884-2550-46b1-9b0a-503410c06f6d"))

の順で呼び出されたことを検出し、その後、

Key(JsonScalarValue::String("hu"))
StartMap
Key(JsonScalarValue::String("val"))
Value(JsonScalarValue::Number(66))

の順で呼び出されたときの Value の内容を保存するようなクロージャを渡します。

実際にはいちいちアプリケーションの実装側でこれらの処理を実装するのは手間がかかるので、 remo-api crateには、JSONパーサーを用いて devicesappliances を解析し、デバイスアプライアンスの内容の構造体を構築する read_devices read_appliances 関数を実装しています。

github.com

まとめ

serde で実装したらメモリ不足でpanicしたときはどうしようかと思いましたが、nom が優秀なおかげで、割と簡単に省メモリなJSONパーサーと、それを使ったCloud APIの返すJSONデータのパーサーを実装できました。

このあたりはcargoという優秀なパッケージ・マネージャ、Rustのパターンマッチなどの現代的な言語機能のおかげと思います。Rustはいいぞ!

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

herp.careers

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

herp.careers

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

speakerdeck.com