ぶらり組込みRustライブラリ探索の旅 BBQueue編 -スレッドセーフなSingle Producer Single Consumer Queue-

ファームウェアエンジニアの中林 (id:tomo-wait-for-it-yuki) です。あけましておめでとうございます。 今年の目標はNature Remoのファームウェア開発にRustを導入すること、です。そこで引くに引けない状況を作り出すためにRustに関するブログエントリを書き、既成事実を積み上げていきます。

ぶらり組込みRustライブラリ探索の旅、と題して組込みRustで使えるライブラリをゆるく紹介していくシリーズをやりたいと思います。第一弾はBBQueueです。

BBQueue

本エントリ内で紹介する使い方や内部実装は、v0.5.1をもとにしています。

github.com

https://docs.rs/bbqueue/0.5.1/bbqueue/

特徴

BBQueueはno_stdで使えるだけでなく、スレッドセーフで、排他制御なしに使える Single Producer / Single Consumer なリングバッファです。BBQueueを使うと動的なメモリ確保なしに、スレッド間で可変長フレームを送受信できます。さらに、フレームは常に連続したメモリブロックに配置されます。「動的なメモリ確保なしに可変長フレームを送受信できる」というところがポイントで、もし固定長 (特定の型) のデータに対する Single Producer / Single Consumer な Queue で良ければ、heapless crate の spsc を使うことができます。

スレッド間で可変長フレームを送受信する一般的な方法としては、Vec (C では malloc) を使う方法があります。しかし、組込みシステムでは、実行時のメモリ使用量を測定可能にしたい、という要求がしばしばあります。BBQueueを使うと固定長のバッファを静的 (もしくはスタック) に確保して、うまく使い回すことができます。

ドキュメントではBBQueueは組込みシステムのDMAで使うことを第一に考えたリングバッファという説明がなされています。通常のリングバッファではheadとtailがラップアラウンドするとメモリブロックが非連続になりますが、BBQueueでは常に連続したメモリブロックを使うため、DMAから直接データを書き込むことができます。その仕組みはバッファの末尾までで十分なメモリブロックを確保出来ない場合に、バッファの先頭からメモリブロックを確保する、というシンプルなものです。

プロセッサのアトミック命令を使っているので一部のターゲットではコンパイルエラーになります。A拡張のないRISC-Vなど。ただし、特別にCortex-M0つまりthumbv6をサポートしており、同様の対応を行えばアトミック命令を実装していないターゲットでもコンパイルできそうです。

もう少し詳しいことは、Ferrous Systemsのブログポストでわかりやすい図付きで説明されています。

ferrous-systems.com

使い方

BBQueueには2つの使い方があります。

  1. データをQueueに read / write する
  2. フレーム化したデータをQueueに read / write する

1 の場合、Queueへの読み書きは次のようになり、複数回に分けて書き込んだ場合、読み出し時にその境界はわかりません。

  • Queueに5バイト書き込む
  • Queueに3バイト書き込む
  • Queueを読み出すと8バイトのデータが読み出せる

2 の場合は、フレームごとに読み書きが発生します。

  • Queueに5バイトのフレームを書き込む
  • Queueに3バイトのフレームを書き込む
  • Queueから5バイトのフレームを読み出す
  • Queueから3バイトのフレームを読み出す

このことを念頭において、bbqueueを使うコードを書いてみます。

Cargo.tomlには、bbqueueへの依存を追加するだけです。後々のテストで使うので、rand crate も追加しています。

Cargo.toml
[dependencies]
bbqueue = "0.5"
# 後々のテストで使う
rand = "0.6"

1回書いて、1回読む

書き込み / 読み出しは次の手順で行います。

  1. grantを得る
  2. 書き込む / 読み出す
  3. commit / release で書き込みの確定 / 読み出しデータの解放をする

コードにすると次の通りです。

    use bbqueue::{BBBuffer, Error};

    #[test]
    fn one_byte_read_write() -> Result<(), Error> {
        let queue: BBBuffer<1024> = BBBuffer::new(); // ★1
        // p: Producer, c: Consumer
        let (mut p, mut c) = queue.try_split()?; // ★2

        // Producer
        let mut wg = p.grant_exact(1)?; // ★3
        assert_eq!(wg.len(), 1);
        wg[0] = 123; // ★4
        wg.commit(1); // ★5

        // Consumer
        let rg = c.read()?; // ★6
        assert_eq!(rg.len(), 1);
        assert_eq!(rg[0], 123); // ★7
        rg.release(1); // ★8

        Ok(())
    }

★1: BBBuffer::new()で固定長のバッファを内部に持つインスタンスを生成します。BBBuffer::new()const fnになっており、staticスコープのインスタンスを生成する場合にも使えます。
★2: try_split()で Producer / Consumer に分割します。すでに分割済みの場合はエラーになります。

Producer 側
★3: grant_exact(1) でQueueに1バイト書くgrantを取得します
★4: Queueにデータ 123 を書き込みます
★5: Queueへのデータ書き込みを確定します

Consumer 側
★6: Queueからデータを読み出すgrantを取得します
★7: 読み出したデータは1バイトで、123 です
★8: 読み出したデータを解放します

grantをcommit / releaseしないと新しいgrantは得られません。

    #[test]
    fn not_granted() -> Result<(), Error> {
        let queue: BBBuffer<1024> = BBBuffer::new();
        let (mut p, mut c) = queue.try_split()?;

        let mut wg = p.grant_exact(1)?;
        assert_eq!(wg.len(), 1);
        wg[0] = 123;

        // write grant not released yet
        let another_wg = p.grant_exact(1);
        assert_eq!(another_wg, Err(Error::GrantInProgress));

        // release write grant
        wg.commit(1);

        let rg = c.read()?;
        assert_eq!(rg[0], 123);

        // read grant not released yet
        let another_rg = c.read();
        assert_eq!(another_rg, Err(Error::GrantInProgress));

        Ok(())
    }

複数バイト読み書きする場合は、次のような感じです。

        let mut wg = p.grant_exact(3)?;
        assert_eq!(wg.len(), 3);
        wg[0..].copy_from_slice(&[1, 2, 3]);
        wg.commit(3);

        let rg = c.read()?;
        assert_eq!(&*rg, &[1, 2, 3]);
        rg.release(3);

複数回書いて、1回で読む

try_split()で分割した Producer / Consumer では、複数回にわけて書き込みをしても、読み込むときは一括で読み出せます。データを分解して処理したい場合は、データをパースして使った分だけrelease()します。

    #[test]
    fn write_twice_read_once() -> Result<(), Error> {
        let queue: BBBuffer<1024> = BBBuffer::new();
        let (mut p, mut c) = queue.try_split()?;

        let mut wg = p.grant_exact(1)?;
        wg[0] = 1;
        wg.commit(1);

        let mut wg = p.grant_exact(1)?;
        wg[0] = 2;
        wg.commit(1);

        let rg = c.read()?;
        assert_eq!(&*rg, &[1, 2]);
        rg.release(2);

        Ok(())
    }

commit / release を Drop 時に自動的にやる

自分でお試しコードを書いていて何度かcommit / release を忘れることがありました。これを防ぐために、commit / release を Drop 時に自動的に行うメソッドが用意されています。書き込みはto_commit() / 読み出しはto_release()を使います。to_release()を使う場合、読み出しのgrantはmutableにします。

        {
            // スコープを抜けると自動的にcommitされる
            let mut wg = p.grant_exact(1)?;
            wg.to_commit(1);
            wg[0] = 1;
        }

        {
            let mut wg = p.grant_exact(1)?;
            wg.to_commit(1);
            wg[0] = 2;
        }

        {
            // grantをmutableに
            let mut rg = c.read()?;
            rg.to_release(2);
            assert_eq!(&*rg, &[1, 2]);
        }

連続したメモリブロックがない

BBQueueの特徴として、連続したメモリブロックにデータを配置する、という点を挙げました。その動作を試すコードを書いてみましょう。

    #[test]
    fn continuous_memory_block_not_available() -> Result<(), Error> {
        // 8バイトのバッファを確保
        let queue: BBBuffer<8> = BBBuffer::new();
        let (mut p, mut c) = queue.try_split()?;

        // 先頭の3バイト書き込み
        // read   write
        // ┌─────┬───────────┐
        // │  3  │           │
        // └─────┴───────────┘
        let mut wg = p.grant_exact(3)?;
        wg[0..].copy_from_slice(&[1; 3]);
        wg.commit(3);
        // 先頭の3バイト読み出し
        //       read
        //       write
        // ┌─────┬───────────┐
        // │     │           │
        // └─────┴───────────┘
        let rg = c.read()?;
        rg.release(3);

        // 次の4バイト書き込み
        //       read      write
        // ┌─────┬────────┬──┐
        // │     │   4    │  │
        // └─────┴────────┴──┘
        let mut wg = p.grant_exact(4)?;
        wg[0..].copy_from_slice(&[1; 4]);
        wg.commit(4);

        // 連続した4バイトを確保できないのでgrant取得に失敗する
        let wg = p.grant_exact(4);
        assert_eq!(wg, Err(Error::InsufficientSize));

ここまでの時点で、8バイトのバッファのうち4バイトを使用しています。残りはバッファの先頭の3バイトとバッファ末尾の1バイトとの合計4バイトです。この状態で4バイトの書き込みgrantを得ようとすると連続した4バイトのメモリブロックを取得できないため、上記コードの通り、Error::InsufficientSize が返ってきます。

ここで問題です。4バイトの書き込みgrantを得るためには、Queueからデータを読み出して解放する必要があります。さて何バイトのデータを解放すればよいでしょうか?

        let rg = c.read()?;
        rg.release(/* 何バイト? */);

正解は、2バイトです。1バイトと思ったあなた!惜しい!私と同じトラップにかかりましたね!

1バイト解放すれば、バッファの先頭から4バイトのデータが未使用になるので、4バイトの書き込みgrantが得られそうですが、なぜでしょうか?真実は実装を読むと判明します。grant_exact()の実装を見てみましょう。書き込みgrantを得ると、readとwriteのラップアラウンドが発生する時だけ特殊な条件が発生します。

bbqueue-0.5.1/src/bbbuffer.rs
    pub fn grant_exact(&mut self, sz: usize) -> Result<GrantW<'a, N>> {
         // 中略
        let already_inverted = write < read;

        let start = if already_inverted {
            if (write + sz) < read {
                // Inverted, room is still available
                write
            } else {
                // Inverted, no room is available
                inner.write_in_progress.store(false, Release);
                return Err(Error::InsufficientSize);
            }
        } else {
            if write + sz <= max {
                // Non inverted condition
                write
            } else {
                // Not inverted, but need to go inverted

                // ★★★ これ! ★★★
                // NOTE: We check sz < read, NOT <=, because
                // write must never == read in an inverted condition, since
                // we will then not be able to tell if we are inverted or not
                if sz < read {
                    // Invertible situation
                    0
                } else {
                    // Not invertible, no space
                    inner.write_in_progress.store(false, Release);
                    return Err(Error::InsufficientSize);
                }
            }
        };
        // 以下略

どういうことかと言うと、ラップアラウンドが発生するときにぴったりバッファの容量を使い切ってしまうと、read / write が指す位置が同じになってしまい、バッファが全て空いている状態なのか、バッファが全て使われている状態なのか判断することができなくなります。そのため、書き込みgrantを得ることで、ラップアラウンドが発生してしまう場合に限って、+1バイト空きが必要となります。

ということで、先程のコードの続きは、次のように書くと、4バイトの書き込みgrantを得られます。

        // 2バイト読み出す
        //           read write
        // ┌──────────┬───┬──┐
        // │          │ 2 │  │
        // └──────────┴───┴──┘
        let rg = c.read()?;
        rg.release(2);

        // バッファの先頭から連続した4バイトを確保できる
        //       write read
        // ┌──────┬───┬───┬──┐
        // │  4   │   │ 2 │  │
        // └──────┴───┴───┴──┘
        let mut wg = p.grant_exact(4)?;
        wg[0..].copy_from_slice(&[1; 4]);
        wg.commit(4);

        Ok(())
    }

フレームごとに読み書きする

可変長のフレームを読み書きします。複数のフレームを書いた場合、読み出し側はフレーム単位でgrantを得ます。

    #[test]
    fn framed_read_write() -> Result<(), Error> {
        let bb: BBBuffer<1024> = BBBuffer::new();
        // p: FrameProducer, c: FrameConsumer
        let (mut p, mut c) = bb.try_split_framed()?; // ★1

        // 10バイトのフレームを書き込む
        let mut wg = p.grant(10)?;
        assert_eq!(wg.len(), 10);
        wg[0..].copy_from_slice(&[1; 10]);
        wg.commit(10);

        // 20バイトのフレームを書き込む
        let mut wg = p.grant(20)?;
        assert_eq!(wg.len(), 20);
        wg[0..].copy_from_slice(&[2; 20]);
        wg.commit(20);

        // 1回目に書き込んだ10バイトのフレームを読み込む
        let frame = c.read().unwrap();
        assert_eq!(&*frame, &[1; 10]);  // ★2
        frame.release();

        // 2回目に書き込んだ10バイトのフレームを読み込む
        let frame = c.read().unwrap();
        assert_eq!(&*frame, &[2; 20]);
        frame.release();

        Ok(())
    }

★1: try_split_framed()でFrameProducer / FrameConsumerに分割するとフレームごとにデータを読み書きします
★2: Queue内には30バイト分のデータがありますが、1フレーム分だけ読み出せています

注意事項として、フレームで読み書きする場合、バッファ格納時にフレームヘッダが追加されます。bbqueueの利用者はフレームヘッダの構成を意識せずに使うことができますが、bbqueueのsrc/vusize.rsを見ると現時点での実装がわかります。フレームの長さが127バイトまでなら1バイトのヘッダが、16,383バイトまでなら2バイトのヘッダが…、という具合です。

//! | Prefix     | Precision | Total Bytes |
//! |------------|-----------|-------------|
//! | `xxxxxxx1` | 7 bits    | 1 byte      |
//! | `xxxxxx10` | 14 bits   | 2 bytes     |
//! | `xxxxx100` | 21 bits   | 3 bytes     |
//! | `xxxx1000` | 28 bits   | 4 bytes     |
//! | `xxx10000` | 35 bits   | 5 bytes     |
//! | `xx100000` | 42 bits   | 6 bytes     |
//! | `x1000000` | 49 bits   | 7 bytes     |
//! | `10000000` | 56 bits   | 8 bytes     |
//! | `00000000` | 64 bits   | 9 bytes     |

commit / release を Drop 時に自動的にやる

FrameConsumerから得たgrantにはto_commit() / auto_release()メソッドが実装されており、このメソッドを呼び出しておくと、Dropのタイミングで自動的にgrantをリリースしてくれます。ライブラリ側でフレームサイズがわかっているため、commit / releaseするサイズを指定する必要はありません。

    #[test]
    fn framed_auto_commit_release() -> Result<(), Error> {
        let bb: BBBuffer<1024> = BBBuffer::new();
        let (mut p, mut c) = bb.try_split_framed()?;

        {
            let mut wg = p.grant(10)?;
            wg.to_commit(10);
            wg[0..].copy_from_slice(&[1; 10]);
        }

        {
            let mut wg = p.grant(20)?;
            wg.to_commit(20);
            wg[0..].copy_from_slice(&[2; 20]);
        }

        {
            let mut frame = c.read().unwrap();
            frame.auto_release(true);
            assert_eq!(&*frame, &[1; 10]);
        }

        {
            let mut frame = c.read().unwrap();
            frame.auto_release(true);
            assert_eq!(&*frame, &[2; 20]);
        }

        Ok(())
    }

マルチスレッドで読み書きする

最後にもう少し実践的な例として、マルチスレッドでフレームを読み書きするコードを書いてみます。テストデータはランダムなバイト列を用意して、ランダムな長さのフレームに分割しています。そのテストデータを、送信スレッドから書き込み、受信スレッドで読み出します。受信スレッドでは期待通りのデータが読み出せているかどうかテストしています。

    #[test]
    fn multi_thread() -> Result<(), Error> {
        use rand::prelude::*;
        use std::thread::spawn;

        // テストデータを作成
        const DATA_SIZE: usize = 1_000_000;
        let mut data = Vec::with_capacity(DATA_SIZE);
        (0..DATA_SIZE).for_each(|_| data.push(rand::random::<u8>()));

        let mut trng = thread_rng();
        let mut chunks_tx = vec![];
        while !data.is_empty() {
            let chunk_sz = trng.gen_range(1, (1024 - 1) / 2);
            if chunk_sz > data.len() {
                continue;
            }
            chunks_tx.push(data.split_off(data.len() - chunk_sz));
        }
        let chunks_rx = chunks_tx.clone();    

        // FrameProducer / FrameConsumer を作成
        static BB: BBBuffer<1024> = BBBuffer::new();
        let (mut tx, mut rx) = BB.try_split_framed().unwrap();

        // 送信スレッドから chunk を 1 frame ごとに queue に書き込む
        let tx_thr = spawn(move || {
            for chunk in chunks_tx.iter() {
                loop {
                    if let Ok(mut wg) = tx.grant(chunk.len()) {
                        wg.copy_from_slice(chunk);
                        wg.commit(chunk.len());
                        break;
                    }
                }
            }
        });

        // 受信スレッドで chunk を 1 frame ごとに読み込み、そのデータ内容をテスト
        let rx_thr = spawn(move || {
            for chunk in chunks_rx.iter() {
                loop {
                    if let Some(frame) = rx.read() {
                        assert_eq!(&*frame, chunk);
                        frame.release();
                        break;
                    }
                }
            }
        });

        tx_thr.join().unwrap();
        rx_thr.join().unwrap();

        Ok(())
    }

おわりに

ぶらり組込みRustライブラリ探索の旅、第一弾はSingle Producer / Single Consumer なリングバッファBBQueueを紹介しました。第二弾があるかどうかはわかりませんが、ご期待ください。


NatureではIoTと電気を組み合わせた新しい電力サービスを作りたい思っており、エンジニアを積極採用中です。

herp.careers

カジュアル面談も実施していますので、興味がある方はお気軽にご応募ください!

herp.careers

strptime関数のフォーマット指定子%Zはglibc拡張なのでnewlibにはない

ファームウェアエンジニアの中林 (id:tomo-wait-for-it-yuki) です。 既存実装やコメントを見ると、「お?これ、なんでだろう?」と違和感に覚えることがありますよね。システムは動作しているので、そのままにしておいても特に問題は発生しません。ですが、あえてこういうところを調査してみると、新たな発見があったり、コードをより改善できたり、ブログのネタにできたりします。

本エントリでは、ファームウェア実装の中で見つけた、次のstrptime関数の使い方について、タイトルの結論に至った経緯を共有します。

    // NTP パケットをドロップしていると使えないので
    // `Mon, 27 Jul 2020 06:53:34 GMT`
    // のフォーマットをパースして、システムの時刻とする
    struct tm t;
    char *pos = strptime(buf, "%a, %d %b %Y %H:%M:%S %Z", &t);
    // GMT 無視されるが正しく取得できている

    if (!(strlen(pos) == 3 && (0 == strncmp(pos, "GMT", 3)))) {
        // エラー処理
    }

背景

まず、上記コードがどのような処理で使われているか説明します。 Nature Remo Eではスマートメーターから取得した瞬時電力量 (まさにそのとき家庭で使われている電力量) をサーバーにアップロードしています。瞬時電力量をいつ取得したかはファームウェア側でタイムスタンプを付与しています (ネットワーク断絶時のリトライ処理があるのでサーバー側でタイムスタンプ付与ができない事情があります)。正しいタイムスタンプを付与するために時刻同期が必要で、NTPを使う方法が一般的です。

Remo EでもNTPによる時刻同期を行っています。しかしながら、これだけでは不十分であることがわかっています。過去に、NTPパケットがルーターのフィルタでドロップされる環境でRemo Eのデータが集計されない、という問題が発生していました。この問題を解決するために、NTPに依存しない、Remoとサーバーとだけで完結する時刻同期方法が必要になりました。

そこで、サーバーから返ってくるHTTPレスポンスのDateヘッダーを使って、時刻同期する方法が検討されました。Remoでは1日1回Websocketの再接続を行っており、この時のHTTP Upgradeリクエストに対するレスポンスを使って時刻同期します。NTPによる定期的な時刻同期とDateヘッダーによる時刻同期を併用することで、ほとんどの問題は解決しました (実はまだ闇があるのですが、今回は省略します) 。

このときに実装されたのが、さきほどのコードです。HTTPレスポンスのDateヘッダーからMon, 27 Jul 2020 06:53:34 GMTのような時刻データを取得して、strptime関数でパースして、Remoのシステム時刻を更新します。

最近、このあたりのコードを見直す機会があり、コメントに対して「お?これ、なんでだろう?」と思うに至りました。

    // `Mon, 27 Jul 2020 06:53:34 GMT`
    char *pos = strptime(buf, "%a, %d %b %Y %H:%M:%S %Z", &t);
    // GMT 無視されるが正しく取得できている

どうも%Zのフォーマット指定子が無視されてしまうようです。

strptime

strptimeは時刻を表す文字列をパースして、時刻構造体struct tmに変換するlibc (POSIX) に含まれる関数です。Mon, 27 Jul 2020 06:53:34 GMTのような文字列をprintfで使うようなフォーマット指定子 (例: %a, %d %b %Y %H:%M:%S %Z) を使ってパースします。

man strptime

libcの関数でわからないことがあれば、とりあえずmanやろ、ということでmanします。対象が組込みシステムなので、Linux上のmanで全ての情報を得られるわけではありませんが、ターミナル上3秒でありつける情報はとりあえずで読んでおいても損はしません (日本語訳:1) 。%Zについて書かれているところを探します。

       %Z     The timezone name.

%Zタイムゾーン名、とあります。manで付近を読んでみます。欲しい情報が見つかりました。

   Glibc notes
       For reasons of symmetry, glibc tries to support for strptime() the same
       format characters as for strftime(3).  (In most cases, the  correspond‐
       ing fields are parsed, but no field in tm is changed.)  This leads to

いくつかフォーマット指定子の説明

       %Z     The timezone name.

つまり、%Zglibc (GNU C library) の拡張サポートである、ということです。Nature Remoではglibcではなく組込み用途のC libraryであるnewlibを使っています。そこでnewlibの実装で%Zがサポートされているか確認します。と言っても、SDKではlibcはバイナリ配布されているため、GitHubでミラーされている実装を覗いてみます。

     case 'Z' :
        /* Unsupported. Just ignore.  */
        break;

github.com

あ、ダメそう…。

解決策

SDK内で同じようなことをしているコードがあるはず、ということでSDK内をgrepします (ripgrepはいいぞぉ!) 。

  char *r = strptime(s.c_str(), "%a, %d %b %Y %H:%M:%S GMT", &tm);

なるほど、GMTをハードコーディングしてしまえば良い、ということのようです。こういうとき私は、ライブラリの挙動を確かめるために、ユニットテストを書きます。ターゲットハード上でcatchを使ったユニットテスト環境を構築してあるので、次のテストを追加します。これまでの理解が正しければ次のテストがpassするはずです。

#include <catch.hpp>
#include <ctime>
#include <cstring>

TEST_CASE("strptime", "[libc]")
{
    const char *date = "Mon, 27 Jul 2020 06:53:34 GMT";
    struct tm t;

    SECTION("use %Z")
    {
        // `GMT`が無視されるので3文字残っている
        char *remaining = strptime(date, "%a, %d %b %Y %H:%M:%S %Z", &t);
        REQUIRE(strlen(remaining) == 3);
    }
    SECTION("use hard coded GMT")
    {
        // GMTもパースされるので文字は残らない
        char *remaining = strptime(date, "%a, %d %b %Y %H:%M:%S GMT", &t);
        REQUIRE(strlen(remaining) == 0);
    }
}

テストコードを実際にターゲット上で実行すると無事passしました。これで今回の調査は完了です。

本番コードもすっきり、原因もわかって気持ちもすっきりしましたね!

-     char *pos = strptime(buf, "%a, %d %b %Y %H:%M:%S %Z", &t);
-      // GMT 無視されるが正しく取得できている
- 
-     if (!(strlen(pos) == 3 && (0 == strncmp(pos, "GMT", 3)))) {
-         // エラー処理
-     }
+    char *pos = strptime(buf, "%a, %d %b %Y %H:%M:%S GMT", &t);
+    if (pos == NULL) {
+        // エラー処理
+    }

おわりに

今回は放っておいても問題にならない違和感でしたが、こういう違和感の中に潜在バグがあったりもするので、違和感を覚えたらチャンスだと思って調査するのが重要だと考えています。特にlibcの実装依存は組込みではハマるポイントなので、きちんと理解を深めていきたいところです。なんと言ってもブログのネタになるのが良いです。


Natureではちょっとした違和感でも深堀りできるエンジニアを募集しています。スマートリモコンNature Remo、スマートエナジーハブNature Remo Eを一緒に開発しましょう。

herp.careers

カジュアル面談も実施していますので、興味がある方はお気軽にご応募ください!

herp.careers

Nature Remo - Design Renewal and Dark Mode implementation

* English follows Japanese: Click here to read the article in English

v7.15.5

Nature Remo - デザインリニューアルとダークモード実装のお知らせ

                                                               

f:id:ArnaudDerosin:20211215162341p:plain
ダークモード

はじめに

iOS 13.0(Apple)、Android 10(APIレベル29)以降、システム全体を暗くする「ダークモード(iOS)」「ダークテーマ(Android)」を選択することができるようになりました。ダークモード/テーマでは、システムはすべての画面、ビュー、メニュー、およびコントロールに暗いカラーパレットを使用します。暗い背景の中で前面のコンテンツを目立たせるために、より鮮やかな色彩を使用します。
ライトモード/ダークモードを使用することの利点や省電力は議論されていますが、私たちは、ユーザーの好みや使用シーンを重視することにしました。


この記事では読みやすさのため、今後はテーマとモードを「モード」という言葉に統一しています。

プロジェクトの始まり

iOS / Androidにダークモードが導入され、その人気から、Nature Remo アプリのダークバージョンを望むユーザーから要望が届いています。特に暗い環境でアプリケーションを使用する際に、目の疲れや不快感を感じるというフィードバックが繰り返し届いています。
この機能はユーザーにとって重要なものでしたので、アプリケーションのUIを更新すると同時にダークモードを実装することを決定しました。
私たちの目標は、常にUXを向上させることであり、UIの更新はすべてのフィードバックを元に慎重に行われています。アプリをより楽しんでいただくために、ライト/ダークモード機能はUIの更新と並行して開発しました。

アプリの現状

Nature Remoアプリはすでに3年以上React NativeとTypescriptで開発しています。React Nativeのバージョンアップとと当時からのアプリの設計により、実装中にいくつかの課題に直面しました。その1つは、アプリケーションがクラスコンポーネントと関数コンポーネントを使用しているということです。(クラスコンポーネントは開発中に関数型コンポーネントに書き換えられていますが、アプリケーションの複雑さにより、ダークモードの実装前に残りのクラスコンポーネントをすべて関数型に書き換えることはできませんでした)。
そこで今回は、Nature Remoアプリのモード選択機能について、私たちがどのように考えどのように設計したかを紹介したいと思います。

eh-career.com

実装

ロジック

Reactコンポーネントのツリーに対して「グローバル」とも言えるデータを共有するために設計されたContextからモード選択を管理することにしたのですが、モード管理にはぴったりのユースケースでした。

export type CurrentTheme = 'light' | 'dark';
export type SelectedTheme = CurrentTheme | 'device_settings'

export interface ThemeContextInterface {
  current: CurrentTheme;
  selected: SelectedTheme;
}

export const ThemeContext = React.createContext<ThemeContextInterface>({
  current: 'light',
  selected: 'light',
});

Context Provider は、React コンポーネントで、Context の変更を受信するためのコンポーネントを提供します。
値として currentTheme (Light | Dark) と selectedTheme (Light | Dark | Device Setting) を渡しています。

import { CurrentTheme, SelectedTheme, ThemeContext } from './lib/Theme';

interface State {
  currentTheme: CurrentTheme;
  selectedTheme: SelectedTheme;
}
export default class App extends React.Component<{}, State> {
  render() {
    return (
      <ThemeContext.Provider value={{current: this.state.currentTheme, selected: this.state.selectedTheme}}>
        // Any component can read it, no matter how deep it is.
      </ThemeContext.Provider>
    );
  }
}

この2つの値を渡す理由は簡単で、アプリの実際のモードと、ユーザーがどれを選択したかを管理したいからです。
この段階で、Context の値のみで更新するのは最適な実装ではないことがわかりました。
当然ですがモードの設定はどこかに保存する必要があり、現在はreduxの永続層に保存されています。
そうなるとモードの設定がContextとreduxの二箇所で管理されることになってしまいます。
reduxのみで管理するようにしたかったのですが、既存のアプリの設計による制約もあり残念ながらこのタイミングできませんでした。そのためreduxの永続層でモードを管理し、一部のクラスコンポーネントのためにreduxからコンテキストを更新するという決定を下しました。
ユーザーが Light、Dark、Device Settings のいずれかのモードを選択すると、reduxを更新するactionがdispatchされます。Device Settings に対応する正しいモードを取得するために、アプリケーションの Appearance.getColorScheme()をチェックして正しい値を返す関数を作成しました。

export const getCurrentTheme = (selectedTheme: SelectedTheme) : CurrentTheme => {
  if (selectedTheme === 'device_settings') {
    const colorScheme = Appearance.getColorScheme();
    if (colorScheme === 'light' || colorScheme === 'dark') {
      return colorScheme;
    }
    else {
      return 'light';
    }
  }
  return selectedTheme;
}

各ビューは関数コンポーネントで作らられておりRedux StoreからcurrentThemeとselectedThemeを取得します

また、端末自身のモード変更にとアプリのモード変更を監視するために、ColorSchemeChangedとThemeChangedという2種類のコンポーネントを作成しました。

ThemeChangedコンポーネントはアプリの自身のモード(currentThemeとselectedTheme)を監視し、必要に応じてOSの外観設定(キーボード、アクションシートの色、アラート)/ステータスバーに関するユーザーインターフェイスを更新する役割を担っています。
ColorSchemeChangedコンポーネントは、端末自身のモード変更を監視します。
このコンポーネントは、useEffect フックを使用しており、端末自身のモードがデバイス上で変化し、実際のものと異なる場合に onChange コールバックをトリガーします。
外観が変更された場合、Redux Store で現在のモードを更新し、すべてのビューを「一度に」再レンダリングします。結果ThemeChangedコンポーネントの監視が発火します。

スタイル

色の管理及びメンテナンスを容易にし、再利用可能なコンポーネントを効率的に作成するために、モードごとに異なる色を定義する型を作成しました。
以下は、そのコードの一部です。

export type ColorInterface = {
  surface: {
    card: {
      default: string, 
      pressed: string,
    },
    background: string,
  }
}

以下の例の通りLightColorsとDarkColorsは同じインタフェースを持ち独自の色を定義できる。

export const LightColors: ColorInterface = {
  surface: {
    card: {
      default: "#FFFFFF",
      pressed: "#F2F2F2",
    },
    background: "#F6F6F7", 
  }
}

export const DarkColors: ColorInterface = {
  surface: {
    card: {
      default: "#2C2C2E",
      pressed: "#373738",
    },
    background: "#1C1C1E", 
  }
}

Navbar / TabBar / StatusBar

ナビゲーションバーとタブバーの色の変更に対応するため、現在のモードをチェックして正しいスタイルをレンダリングする画面で、定義したスタイルを使用しています。

export const getThemedNavigation = (current: CurrentTheme) => {
  const themeColors = current === 'light' ? LightColors : DarkColors

  return {
    headerStyle: {
      backgroundColor: themeColors.surface.card.default
      borderBottomWidth: 0,
      shadowOpacity: 0,
      elevation: 0,
    },
    headerTitleStyle: current === 'light' ? LightTitleStyle : DarkTitleStyle,
    headerTintColor: themeColors.elements.primary.highEmphasis,
    cardStyle: { backgroundColor: themeColors.surface.background }
  }
}

使用例:

class AutomationsScreen extends React.Component<Props, State> {
  static navigationOptions: NavigationOptions<NavigationStackScreenProps, NavigationStackOptions> = ({ navigation, theme }) => {
    return {
      ...getThemedNavigation(theme),
      title: 'Automations'
    }
  };
}

StatusBarについては、App componentdidMount()のライフサイクルで、Redux Storeから取得したcurrentThemeからステータスバーの色を更新しています。

export default class App extends React.Component<{}, State> {
  state: State = {
    currentTheme: store.getState().account.currentTheme,
    selectedTheme: store.getState().account.selectedTheme,
  }

  componentDidMount() {
    updateThemedStatusBar(this.state.currentTheme);
  }
}

ユーザーが選択したモードを変更する場合、そのモードが明るいか暗いか、またはデバイスの設定かを確認し、必要な変更を加えて、アプリモードに合わせたステータスバーに更新しています。

デザイナーとの連携

2021年の3Qにダークモードの機能をゼロから実装することにしました。そのためデザイナーと効率的に作業する方法を模索する必要がありました。

ダークモードをデザインする前に、まず私達はライトモードを作りました。以前のライトモードは背景色が白で各UIコンポーネントも白が使われていました。背景色と各UIコンポーネント間のコントラストを改善するために、全ページの背景色をライトグレーに更新しました。驚くことにこの変更はほとんどのユーザ気づかれず自然に行われました。これによりダークモードを作る準備ができました。

ライトとダークの実装のために、アプリのUIリニューアルと同時にUIコンポーネントの一貫性を保ち最適化するため必要がありました。デザイナーとエンジニアの間で毎日行われるミーティングレビューにより、非常に良い実装フローを保つことができました。しかしながらアプリには古いコンポーネントと新しいコンポーネントが混在したため困難を極めました。

背景を白にしたデザインリニューアルの初期バージョン

この間、エンジニアはページのリニューアルやライトモード(背景がグレー)の実装を進めていました。デザイナーは常に一歩先を行き、アプリのメインページであるコントロール、エネルギー(RemoEを利用するユーザー向け)及びオートメーションをデザインし、独自の機能を持つこれらのページでダークモードがどう見えるかのおおよその見当をつけることが出来ました。

エンジニアの重要な役割は、再利用不可能なコンポーネントをすべて特定し、デザイナーがこれらのページにリソースを集中させることでした。このように、デザイナーが開発よりも先行してリズムを刻むことで、日々新しいページを実装し、どのページにデザイナーがもっと注意を払う必要があるのかをピンポイントで把握することができるようになりました。

We are hiring

Nature では Nature Remo, Nature Remo E, Nature スマート電気を組み合わせ新しい体験を提供していきたいと思っています。 カジュアル面談も常に募集していますので興味がある方は是非話してみませんか。
herp.careers


NATURE REMO DESIGN RENEWAL AND DARK MODE IMPLEMENTATION

                                                               

f:id:ArnaudDerosin:20211215162341p:plain
ダークモード

Introduction

Since iOS 13.0 for Apple users and Android 10 (API level 29) for Android users, people can choose to adopt a dark system-wide appearance called Dark Mode (iOS) / Dark Theme (Android). In Dark Mode / Theme, the system uses a darker color palette for all screens, views, menus, and controls. It uses more vibrancy to make foreground content stand out against the darker backgrounds.
Aside from the advantages and performances of using light / dark mode that are debated, we decided to focus on the preferences of our users and what they are requesting us.

* For the sake of readability, theme and mode will be replaced by the term “mode” in this article.

Origin of the project

Since the native Dark Mode introduction for iOS / Android and its rise in popularity, we gradually have received feedback about users that would love to have a dark version of the Nature Remo app. Some recurrent feedback are mentioning eye strain and discomfort when using the application in dark environments.
This feature was important for our users, so we decided to implement the dark mode at the same time we were implementing the new design of the application.
Our goal is to always improve the user experience, the interface renewal is a very important step that we are implementing carefully after reading all the feedback. To allow our users to enjoy the app even more, the light / dark mode feature was a crucial implementation that we accomplished in parallel of the design renewal.

Actual app state

The Nature Remo app was developed with React Native in Typescript for the past 3 years, the evolution of React and the app structure confronted us with certain challenges during implementation, one of them is that the application is using class and functional components. (class components are being rewritten to functional components during our developments but the complexity of the application doesn’t allowed us to rewrite all the remaining class components to functional before the implementation of dark mode).
So today we want to share with you the way we thought and designed the appearance selection feature in the Nature Remo app.

eh-career.com

Implementation

Logic

We decided to manage the mode selection from the Context which is designed to share data that can be considered “global” for a tree of React components, the mode management was a great use case.

export type CurrentTheme = 'light' | 'dark';
export type SelectedTheme = CurrentTheme | 'device_settings'

export interface ThemeContextInterface {
  current: CurrentTheme;
  selected: SelectedTheme;
}

export const ThemeContext = React.createContext<ThemeContextInterface>({
  current: 'light',
  selected: 'light',
});

We wrapped up the application component tree with the Context Provider, which is a React component that permit consuming components to subscribe to Context changes.
We are passing as value the currentTheme (Light | Dark) and the selectedTheme that can be Light | Dark | Device Setting.

import { CurrentTheme, SelectedTheme, ThemeContext } from './lib/Theme';

interface State {
  currentTheme: CurrentTheme;
  selectedTheme: SelectedTheme;
}
export default class App extends React.Component<{}, State> {
  render() {
    return (
      <ThemeContext.Provider value={{current: this.state.currentTheme, selected: this.state.selectedTheme}}>
        // Any component can read it, no matter how deep it is.
      </ThemeContext.Provider>
    );
  }
}

The reason for passing these two values is simple, we want to keep tracking of the actual mode of the app and also the user selection.
At this stage, we realized that working only with the Context values was not the best implementation. Of course, the selected mode needs to be stored somewhere, and currently it is stored in the Redux persistence layer.
This means that the mode settings are managed in two places, Context and Redux.
We would have liked to manage it only in Redux, but unfortunately we couldn't do it at this time due to the limitations of the existing application design. So we made the decision to manage the mode in the Redux persistence layer and update the Context from Redux for some class components.
When the user selects one of the Light, Dark, or Device Settings modes, an action is dispatched to update redux. In order to get the correct mode corresponding to Device Settings, we created a function that checks Appearance.getColorScheme() in the application and returns the correct value.

export const getCurrentTheme = (selectedTheme: SelectedTheme) : CurrentTheme => {
  if (selectedTheme === 'device_settings') {
    const colorScheme = Appearance.getColorScheme();
    if (colorScheme === 'light' || colorScheme === 'dark') {
      return colorScheme;
    }
    else {
      return 'light';
    }
  }
  return selectedTheme;
}

Each view is using a custom function that is returning the currentTheme and selectedTheme from the Redux Store.

To manage the application appearance update on native mode change, we have created two different functions, one named ColorSchemeChanged and another one named ThemeChanged.

ThemeChanged is in charge of updating the local state of the app (current and selected) and also if necessary update the user interface related to OS appearance preferences (keyboard, action sheet colors, alert) / status bar.
The ColorSchemeChanged function is responsible for updating the current mode of the application. The function is using an useEffect hook that triggers an onChange callback if the native mode appearance changes on the device and differs from the actual one. If the appearance changed, we update the current mode on the Redux Store that re-render all our views at "once".

Style

To be able to manage, maintain the colors easily and make efficient reusable components we have created a type defining the colors that differs on each mode.
Here is a part of the code that we are using:

export type ColorInterface = {
  surface: {
    card: {
      default: string, 
      pressed: string,
    },
    background: string,
  }
}

Following this example, light colors and dark colors are typed annotated and define their own colors from this type.

export const LightColors: ColorInterface = {
  surface: {
    card: {
      default: "#FFFFFF",
      pressed: "#F2F2F2",
    },
    background: "#F6F6F7", 
  }
}

export const DarkColors: ColorInterface = {
  surface: {
    card: {
      default: "#2C2C2E",
      pressed: "#373738",
    },
    background: "#1C1C1E", 
  }
}

Navbar / TabBar / StatusBar

To handle the change of colors of the Navigation Bar / Tab Bar, we have imported our defined styles in the screens where we need to check the current mode and render the right style.

export const getThemedNavigation = (current: CurrentTheme) => {
  const themeColors = current === 'light' ? LightColors : DarkColors

  return {
    headerStyle: {
      backgroundColor: themeColors.surface.card.default
      borderBottomWidth: 0,
      shadowOpacity: 0,
      elevation: 0,
    },
    headerTitleStyle: current === 'light' ? LightTitleStyle : DarkTitleStyle,
    headerTintColor: themeColors.elements.primary.highEmphasis,
    cardStyle: { backgroundColor: themeColors.surface.background }
  }
}

Example of usage:

class AutomationsScreen extends React.Component<Props, State> {
  static navigationOptions: NavigationOptions<NavigationStackScreenProps, NavigationStackOptions> = ({ navigation, theme }) => {
    return {
      ...getThemedNavigation(theme),
      title: 'Automations'
    }
  };
}

For the StatusBar, on the App componentdidMount() lifecycle we are updating the status bar color from the currentTheme that we are getting from the Redux Store.

export default class App extends React.Component<{}, State> {
  state: State = {
    currentTheme: store.getState().account.currentTheme,
    selectedTheme: store.getState().account.selectedTheme,
  }

  componentDidMount() {
    updateThemedStatusBar(this.state.currentTheme);
  }
}

If the user is making a change in the selected mode, we are checking if the mode is light, dark or device settings and then making the necessary changes to update the status bar related to app mode.

Workflow with the designer

We’ve decided to implement the dark mode functionality from scratch during the Q3 of 2021, to respect that deadline we had to find a way to work efficiently with the designer to optimize our time and progress.
The implementation of two different appearances (light and dark) had to be done during the design renewal of the application to make sense and optimize the consistency of elements in the app. A daily meeting review between designers and engineers allows us to keep a very good implementation flow.

Before designing the Dark Mode, the first step was to create and implement a Light Mode for the application and finally add contrast, we originally thought all the new design with the grey background to improve the contrast between elements but because we didn’t want to impose to our users some difference of color between renewed pages and old ones, we first implemented the renewal of main pages with a white background (which involve to change other elements). During that implementation, we designed components like buttons and cells to be reusable and easy to update at once when we switched all the pages background to a light grey color.


Initial version of the design renewal with white background to keep consistency in the app


During the time engineers were implementing renewal of pages and light mode (grey background). The designer always worked one step further and designed the main pages of the Nature Remo app like Control, Energy (for users who use the RemoE) and Automation to have a rough idea of how the dark mode will look on these pages that feature more unique functionalities.

An important role of the engineers was to identify all the components that were not reusable to focus the designer resources on these pages. By keeping this rhythm where the designer was ahead of the development, it allows us to implement new pages on a daily basis and pinpoint which page might need more attention from the designer.

Recruitment

Nature is looking to expand the team from 30 to 100 people! If you are interested by the company vision and like challenges, why don't you talk with us?!
Please see the recruitment information below for recruitment positions!
herp.careers


Written by: Arnaud Derosin
Reviewed and translated by: Kyosuke Kameda

Go で祝日判定をする github.com/soh335/shukujitsu を書きました

こんにちは北原です。

Natureのバックエンドはおおよそ Go で書かれています。3月にリリースしたNatureスマート電気も同じく Go で実装されています。

energy.nature.global

Natureスマート電気を作るにあたって、いわゆる営業日の計算をする必要があり*1、当時小さく、メンテナンスを自分でしていける Go のライブラリがなかったので github.com/soh335/shukujitsu というものを作りました。

github.com

インターフェイスとしては与えられた日付が祝日かどうかを判定するもののみを提供しており非常にシンプルな作りになっています。

if shukujitsu.IsShukujitsu(time.Now()) {
    fmt.Println("shukujitsu!")
}

こちらは内閣府から提供されている csv データをもとに作成されています。 csvのファイル名が syukujitsu.csv や、過去様々な変遷がありましたが、現在は安定して提供されています。

github.com/soh335/shukujitsu では GitHub Actions を利用し毎週こちらをチェックし*2差分があった場合は自動で pull request が作られるようになっていて、それをマージしリリースすることで新しいデータを参照できるようになります。こちらは東京オリンピックの都合で海の日、山の日、スポーツの日が移動した際の pull request になります。

github.com

また、cli も提供されており

$ shukujitsu || somecmd

のようにすると祝日の場合は実行しないということも可能です。こちらは fujiwara さんに追加していただきました。


Nature では Nature Remo, Nature Remo E, Nature スマート電気を組み合わせ新しい体験を提供していきたいと思っています。 カジュアル面談も常に募集していますので興味がある方は是非話してみませんか。

herp.careers

*1:小売電気事業者を切り替える際は標準的には切り替え元、切り替え先事業者が切り替えをることを承諾した日に加え1営業日、2暦日をあける必要があります

*2:リポジトリに60日アクティビティがないと止まってしまうという制限があります。 ワークフローの無効化と有効化 - GitHub Docs

Remo ユーザの家電操作を可視化してみた

機械学習エンジニアの原 @toohsk です。

ユーザの皆さんに使っていただくなかでNature Remo は4周年を迎えることができました、ありがとうございます。
今回は、Nature Remo シリーズが日常でどのような時間帯にどのような家電を操作しているのか?を可視化したので、紹介していきたいと思います。

何をしたの??

今回は2021年9月にNature Remo から家電にコマンドを送信した曜日 × 時間帯をヒートマップとして可視化しました。 また、Nature Remo 単位で利用した曜日 × 時間帯をヒートマップとして可視化しました。

何でこの分析をしたの??

一般的にサービスを開発していく中で、このような使われ方をされるのではないか?しているのではないか?などの想像が働くことがあります。このような想像をふくらませるときに想像者の原体験に左右されてしまい、マイノリティなケースに引っ張られすぎてしまうということがあります。

実際のNature Remo の操作ログから、どのように使われているのか?どの時間に操作されているのか?を具体的に知ることは、上記のことを防ぐだけでなく、サービス開発で広範なことに役立ちます。
例えば、

  • 新機能を開発する上で現製品がどのように使われているのかを把握する
  • どのような利用シーン(時間帯や曜日、ユーザのデモグラ)が多いのか
  • どの時間にメンテナンスをすればユーザへの影響が少なくなるか
  • 障害時にどの程度のユーザに影響が出てしまうのか

などがあります。

今回は、今後さらに使い勝手の良いサービス開発のために、Nature Remo の操作ログを可視化していきました。

ヒートマップ

私たちは、 自然との共生をテクノロジーでドライブする というミッションを掲げています。
なので、今回のヒートマップはSeaborn のocean_r カラーマップで表現してみました。 少し見にくいところがあるかもしれませんが、ご容赦ください。

ヒートマップの意味合いとしては、その表の単位で集計したときに相対的に高い、相対的に低い位置を色味で表現し、視覚的に把握できる意味合いがあります。 今回採用したカラーマップにおいて、各色が表現する意味合いは

白 < 薄い青 < 濃い青 < 緑

という並びで相対的な関係を表現しています。

f:id:toohsk:20211026113946p:plain
カラーパレットの頻度表現

また、実際の頻度の情報はあえて隠して表示してあります。

では、実際に可視化の結果を見ていきましょう。

コマンド送信数と特徴

f:id:toohsk:20211026113041p:plain
コマンド送信数のヒートマップ

コマンド送信数の特徴として以下が挙げられると思います。

  • 夜の時間帯では、どの曜日でも多く活用していただけており、メインの時間帯である
  • 一方、日中の時間では、土日は平日よりも多く活用していただけている
  • また、平日の朝の時間帯も活用していただけており、外出前の少ない時間を有効活用するために使っていただけている

コマンド送信したNature Remo 数と特徴

f:id:toohsk:20211026114110p:plain
コマンドを送信したNature Remo のヒートマップ

コマンド送信したNature Remo数の特徴として以下が挙げられると思います。

  • 活用されているNature Remo 単位で見ると、よりどの時間帯で使われているかがわかり、コマンド送信数の特徴が色濃く表現されている
  • 1つのNature Remo に対して、複数回の操作が行われている

どこから操作しているの??

Nature Remo を操作する方法はいくつかありますが、よく使われている方法としては、

の3つがあり、これらの3つについてどの時間帯で使われているのか?を可視化していきます。 それぞれコマンド送信数と操作されたNature Remo 数の2つの軸でヒートマップを6枚作ってみました。

ヒートマップ

コマンド送信数

アプリ

f:id:toohsk:20211027095738p:plain
アプリからNature Remo へのコマンド送信数

アプリからNature Remo へのコマンド送信数の特徴として以下が挙げられると思います。

  • 18時 ~ 0時の時間帯でよく使われている
  • 日中の時間帯では、平日に比べ土日は多く使われている
  • 就寝の時間はほぼ使われていない

Amazon Alexa

f:id:toohsk:20211027095857p:plain
Amazon Alexa からNature Remo へのコマンド送信数

Amazon Alexa からNature Remo へのコマンド送信数の特徴として以下が挙げられると思います。

  • 17時 ~ 0時の時間帯でよく使われている
  • 平日の朝に多く活用されており、少ない時間を効率よく使えるようにするため、スマートスピーカからシーン機能が使われている
  • 就寝の時間はほぼ使われていない

Google Assistant

f:id:toohsk:20211027095806p:plain
GoogleアシスタントからNature Remoへのコマンド送信数

Google Assistant からNature Remo へのコマンド送信数の特徴として以下が挙げられると思います。

  • 多少の違いはあれど、Amazon Alexa とほぼ同じ配色パターンが示されているので、Amazon Alexa ユーザと同じ活用のされ方をしている

操作されたNature Remo 数

アプリ

f:id:toohsk:20211027101814p:plain
アプリから操作されたNature Remo 数

アプリから操作されたNature Remo 数の特徴として以下が挙げられると思います。

  • 18時 ~ 0時の時間帯でよく使われている
    • 特に土日の0時 ~ 1時にコマンド送信されたNature Remo 数が多く、生活の時間帯が平日と比較すると遅くずれていることがわかります
  • 平日の日中において、コマンド送信数は多くなかったものの、操作されたNature Remo 数は多い傾向にある
    • 外出時でもアプリから家電が操作されていると考えられます

Amazon Alexa

f:id:toohsk:20211027101855p:plain
Amazon Alexa からコマンド送信されたNature Remo 数

Amazon Alexaから操作されたNature Remo 数の特徴として以下が挙げられると思います。

  • 17時 ~ 0時の時間帯でよく使われている
    • アプリと異なり土日の0時に操作された台数は平日と大きな差はみられない
  • 土日の日中においても、操作されたNature Remo 数は多く、活動しやすい時間帯では定常的に使っていただけている

Google Assistant

f:id:toohsk:20211027101938p:plain
Google アシスタントからコマンド送信されたNature Remo 数

Google Assistantから操作されたNature Remo 数の特徴として以下が挙げられると思います。

  • 操作数と同様に多少の違いはあれど、Amazon Alexa ユーザと同じ活用のされ方をしている

どのような家電が動いているの??

続いて、Nature Remo から操作した家電を曜日 × 時間帯で見ていこうと思います。 Nature Remo に登録されている代表的な家電は

  • テレビ
  • 照明器具
  • エアコン

の3つがあり、この3つの家電に対して、どの時間帯で使われているのか?を可視化していきます。それぞれコマンド送信数とコマンド送信したNature Remo 数の2つの軸でヒートマップを6枚作ってみました。

ヒートマップ

コマンド送信数

テレビ

f:id:toohsk:20211026134057p:plain
テレビへのコマンド送信数

テレビ操作の特徴として以下が挙げられると思います。

  • 夜の時間帯では、どの曜日も活用していただけている
  • 朝の時間帯では、平日は6時 ~ 8時の時間で活用していただけている
  • 平日の日中と比較すると、土日の日中のほうが活用していただけている

照明器具

f:id:toohsk:20211026134720p:plain
照明器具へのコマンド送信数

照明器具操作の特徴として以下が挙げられると思います。

  • 夜の時間帯では、どの曜日も活用していただけている
    • テレビよりも遅い時間で多くコマンドが送信されている
  • 朝の時間帯では、平日は6時 ~ 7時の時間で活用していただけている
  • 平日の日中と比較すると、土日の日中のほうがわずかに多く活用していただけている

エアコン

f:id:toohsk:20211026134907p:plain
エアコンへのコマンド送信数

エアコン操作の特徴として以下が挙げられると思います。

  • 夜の時間帯では、どの曜日も活用していただけている
    • テレビと照明器具よりも広い時間帯で多く活用していただけている
  • 朝の時間帯では、平日は6時 ~ 8時の時間で活用していただけている
  • 他の家電と比較すると
    • 平日の日中であっても活用していただけている
    • 土日の日中も多く活用していただけている

コマンド送信したNature Remo 数

テレビ

f:id:toohsk:20211026140243p:plain
テレビへコマンド送信したNature Remo数

テレビ操作を行っているNature Remo数の特徴として以下が挙げられると思います。

  • 夜の時間帯では、どの曜日も多くのNature Remo が活用していただけている
  • 朝の時間帯では、平日は6時 ~ 8時の時間で多くのNature Remo が活用していただけている
  • 平日の日中と比較すると、土日の日中のほうが多くのNature Remo を活用していただけている
  • 0時 ~ 1時の時間帯において、平日と土日を比較すると、土日のほうがNature Remo を活用していただけており、土日のほうが多少夜ふかし気味であることがわかります

照明器具

f:id:toohsk:20211026140828p:plain
照明器具へコマンド送信したNature Remo 数

照明器具操作の特徴として以下が挙げられると思います。

  • 23時 ~ 0時では、どの曜日も活用していただけている
    • Nature Remo 単位で見ると、より就寝時間に活用されている
  • 朝の時間帯では、平日は6時 ~ 7時の時間で活用していただけている
  • 平日の日中と比較すると、土日の日中のほうがわずかに多く活用していただけている

エアコン

f:id:toohsk:20211026141452p:plain
エアコンへコマンド送信したNature Remo数
エアコン操作の特徴として以下が挙げられると思います。

  • コマンド送信と同様に、夜の時間帯では、どの曜日も活用していただけている
    • テレビと照明器具よりも広い時間帯で多くのNature Remo が活用していただけている
  • 他の家電と比較すると就寝時間にNature Remo を活用していただけている
  • 室温などをトリガーにした方法でNature Remo を活用して、エアコンを操作できている
  • 土日では、12時以降に活用していただけるNature Remo 数が徐々に増えている

最後に

2021年9月の一月分だけでしたが、Nature Remo の活用状況を可視化してみました。 日常生活に深く紐付いてNature Remo が活用されていることが確認できたと思います。

Nature ではNature Remo とNature スマート電気を組み合わせて、生活の心地よさを向上しながら自然への負荷を低減するサービスを開発していくエンジニアを募集しています。
カジュアル面談から雑多に話すこともできますので、ご気軽に応募してください!

nature.global

特に、生活の心地よさの向上と自然への負荷低減を両立は、データの活用と価値をユーザに還元することが重要だと思います。
そのようなデータの活用をしていくエンジニアも募集しています!

herp.careers

React Native Matsuri 2021 で発表を行いました

北原です。10月2日に React Native Matsuri 2021 にて React Native in Nature というタイトルで発表を行いました。

reactnative-matsuri.com

Nature では2018年当時に iOS, Android それぞれ開発を行なっていましたが React Native を用いた開発に移行しました。 当時の話や、最近行った hermes への移行の結果などについても話しました。

speakerdeck.com

こちらは hermes 移行時の内容です。Android 64bit 対応を行った後から Android JSC の中で起動時にランダムにクラッシュしてしまうという問題に悩まされていました。 移行後はこの問題が解決されクラッシュ数が激減しました。他の登壇者の方も hermes への移行による起動速度の改善などにも言及されており印象的でした。

f:id:soh335:20211007125448p:plain

個人的にはオンラインで Keynote を用いて発表する際に全画面で行うと、発表サイトなどでの見てもらっている方からのコメントの反応が掴めない等に困っていました。リハーサルの際にウィンドウで再生することができることを教えてもらい、非常に助かりました。 準備期間、当日含めて運営チームの方々には色々サポートしていただきスムーズに行えました。改めてありがとうございました。

最後に、Nature ではエンジニアの採用を行なっています。 会社のミッションとして、"自然との共生をテクノロジーでドライブする"を掲げて Nature Remo, Nature Remo E や Nature スマート電気の開発をしています。まずはカジュアルに Nature の事を知ってみたい、という方はぜひこちらからご応募ください。

herp.careers

スマートホームの新標準「Matter」のサンプルを動かしてみる

Nature株式会社ファームウェアエンジニアの中林 (id:tomo-wait-for-it-yuki) です。スマートリモコンNature Remoシリーズを手がけるNatureでは、スマートホーム新標準のMatter1に注目しています。

GitHubでMatterのプロトコルスタックやいくつかのサンプルが公開されて2います。今回は比較的簡単に動かせるM5Stack3のBLE/無線LANを使ったサンプルを試しながらMatterについて学んでみたいと思います。

Matter

MatterはCSA (Connectivity Standards Alliance) が策定している新しいスマートホームの標準規格です。これまでのスマートホームバイスは各デバイスメーカーがそれぞれの規格に沿って開発していましたが、各社のデバイスが共通して使う規格を決めることで、デバイス間の相互接続性を高めよう、という狙いです。Google, Apple, Amazonなどが参画しており、今後スマートホームバイスの標準になっていくことが期待できます。

無線LAN, BLE, Threadといった通信規格の上位にIPv6をベースとする共通プロトコルを定義しています。このことにより、下位の通信規格に依存せず、相互接続を可能とします。元々CHIP (Connected Home over IP) という名称で、まだところどころにその名残が残っています。技術者視点だとConnected Home over IPはずばり何したいのかそのまんまの名称だったわけですね。

サンプル動かしてみる

注意!

  • 2021/09/29時点の手順です
  • 著者の環境はUbuntu20.04です

用意するものはこれだけです。

  • ホストPC
  • M5Stack BASICを1つ

M5Stack Core2はディスプレイ非対応とのことなので、動かせるものを全て動かしたい場合、M5Stack BASICが安定択です。

M5Stackでall-clusters-appサンプルを動かし、ホストPCのデバイス制御用CLIツールからコミッショニングおよび制御を行います。

all-clusters-app

これまでいくつかサンプル動かしてみましたが、お手軽に雰囲気掴みたい場合、examples/all-clusters-app/esp324を使うのがおすすめです。all-clusters-appではデバイスのコミッショニング (機器の初期設定) とデバイスの制御を試すことが可能です。

今回はM5Stackを使用しますが、DevKitCなどもっと安価なデバイスでも動きます。RISC-Vコアが搭載されたESP32C3-DevKitMで動かすこともできます。デバイスを用意したら、all-clusters-appのREADME.md5に記載の手順通り進めていきます。

all-clusters-appでは、次のようなデバイスがあるかのようにM5Stackが振る舞います。

  • 時計 (バッテリ、心拍計歩数計)
  • ライト2つ
  • 温度計
  • ドアロック
  • ガレージ2つ (ドア)

時計のバッテリ残量やドアロックの状態などは、M5Stackの画面とボタンから変更することができます。ライトのようなコントローラから制御するデバイスは制御に合わせて画面の表示内容が変わります。

サンプルのビルド

次の手順でサンプルをビルドします。

  1. ESP-IDF v4.3のセットアップ
  2. Matter開発環境のセットアップ
  3. サンプルアプリケーションのビルド

ESP-IDF v4.3のセットアップ

ESP-IDF (Espressif ESP32 IoT Development Framework) とxtensa用のビルドツールチェインをセットアップします。セットアップ手順は下記を参照ください。

https://docs.espressif.com/projects/esp-idf/en/v4.3/esp32/get-started/index.html

環境変数IDF_PATHが設定されていることを確認した上で、ESP-IDFの環境をセットアップし、Matter開発環境をセットアップします。

source $IDF_PATH/export.sh
# このコンソールで次の手順へ

Matter開発環境のセットアップ

submoduleのcloneなどあるので、サンプルをビルドする前に実行します。

git clone https://github.com/project-chip/connectedhomeip.git
cd connectedhomeip
source ./scripts/bootstrap.sh
source ./scripts/activate.sh

2回目以降はsource ./scripts/activate.shだけでOKです。

サンプルアプリケーションのビルド

# connectedhomeipのルートディレクトリから
cd examples/all-clusters-app/esp32

デフォルトではESP32-DevKitCがターゲットになっているので、M5Stackに切り替えます。

idf.py menuconfig

Demo -> Device TypeからM5Stackを選択して、保存します。

サンブルアプリケーションをビルドします。

idf.py build

サンプルの実行

M5StackをホストPCにUSBケーブルで接続して、ファームウェアを書き込みます。

idf.py flash monitor

次のような画面が表示されればOKです。CHIPと表示されているのはご愛嬌ですね。

f:id:tomo-wait-for-it-yuki:20210927164500p:plain
all-clusters-app起動画面

コミッショニングと制御

ホストPCからM5Stackのコミッショニング (初期設定) を実施して、Matterデバイスとして制御します。具体的にはBLE経由でコミッショニングして、M5StackをホストPCと同じ無線LANアクセスポイントに接続し、無線LAN経由で制御します。

CLIツールのセットアップ

ホストPCからM5Stackのコミッショニングと制御を行うためのCLIツールをセットアップします。

# connecteddhomeipのルートディレクトリ
./scripts/build_python.sh -m platform
source ./out/python_env/bin/activate

python contollerを起動します。

$ chip-device-ctrl
[1632729563.198847][112356:112356] CHIP:DL: Avahi client registered
[1632729563.199274][112356:112356] CHIP:ZCL: Using ZAP configuration...
# ...
[1632729563.200628][112356:112356] CHIP:CTL: Loaded credentials successfully
[1632729563.203879][112356:112364] CHIP:DL: Platform main loop started.
Chip Device Controller Shell

chip-device-ctrl >

BLE経由のコミッショニング

まずBLEをスキャンします。無事見つかりました。

chip-device-ctrl > ble-scan

2021-09-29 09:52:43,642 ChipBLEMgr   INFO     scanning started
2021-09-29 09:52:45,344 ChipBLEMgr   INFO     Name            = None
2021-09-29 09:52:45,344 ChipBLEMgr   INFO     ID              = 5f294ca3-b123-465a-aa2f-e48633e07dbe
2021-09-29 09:52:45,345 ChipBLEMgr   INFO     RSSI            = -56
2021-09-29 09:52:45,345 ChipBLEMgr   INFO     Address         = 08:3A:F2:68:37:1E
2021-09-29 09:52:45,347 ChipBLEMgr   INFO     Pairing State   = 0
2021-09-29 09:52:45,347 ChipBLEMgr   INFO     Discriminator   = 3840
2021-09-29 09:52:45,347 ChipBLEMgr   INFO     Vendor Id       = 9050
2021-09-29 09:52:45,347 ChipBLEMgr   INFO     Product Id      = 17729
2021-09-29 09:52:45,348 ChipBLEMgr   INFO     Adv UUID        = 0000fff6-0000-1000-8000-00805f9b34fb
2021-09-29 09:52:45,348 ChipBLEMgr   INFO     Adv Data        = 00000f5a234145
2021-09-29 09:52:45,348 ChipBLEMgr   INFO

M5StackにBLEで接続します。

chip-device-ctrl > connect -ble 3840 20202021 135246

Device is assigned with nodeid = 135246
[1632877083.478955][19975:19979] CHIP:BLE: BLE removing known devices.
[1632877083.487762][19975:19979] CHIP:BLE: BLE initiating scan.
[1632877084.025243][19975:19979] CHIP:BLE: New device scanned: 08:3A:F2:68:37:1E
[1632877084.025283][19975:19979] CHIP:BLE: Device discriminator match. Attempting to connect.

# たくさんログが出る

[1632877096.354977][19975:19983] CHIP:ZCL: NOCResponse:
[1632877096.354993][19975:19983] CHIP:ZCL:   StatusCode: 0
[1632877096.355000][19975:19983] CHIP:ZCL:   FabricIndex: 0
[1632877096.355007][19975:19983] CHIP:ZCL:   DebugText: 0
[1632877096.355018][19975:19983] CHIP:CTL: Device returned status 0 on receiving the NOC
[1632877096.355028][19975:19983] CHIP:CTL: Operational credentials provisioned on device 0x23baba8
Secure Session to Device Established
Device temporary node id (**this does not match spec**): 135246

これでデバイスとのセキュアセッションが確立できたようです。おおよそ10秒くらいでしょうか?ちょっとわかりにくいですが、BLE接続ができるとM5Stack画面の左側にある青色の四角が明るくなります。

f:id:tomo-wait-for-it-yuki:20210929133413p:plain
BLE接続後

少しコマンド connect -ble 3840 20202021 135246 のパラメータを説明しておきます。384020202021は、それぞれ識別用のIDとPINコードで、ビルド時のパラメータとして設定可能です。135246はノードIDでこの後のコマンドでは一貫してこのIDを使用しなければなりません。接続時の最後のログにDevice temporary node id (**this does not match spec**): 135246と出ていて、一時的なデバイスノードIDであろうことがわかります。ちなみにconnect-ble実行時にこのパラメータを指定しなければ、chip-device-ctrlがIDを生成して表示してくれます。

無線LANアクセスポイントに接続するために、SSID/passwordを設定し、無線LANネットワークを有効化します。

chip-device-ctrl > zcl NetworkCommissioning AddWiFiNetwork 135246 0 0 ssid=str:TESTSSID credentials=str:TESTPASSWD breadcrumb=0 timeoutMs=1000
chip-device-ctrl > zcl NetworkCommissioning EnableNetwork 135246 0 0 networkID=str:TESTSSID breadcrumb=0 timeoutMs=1000

1つ目のコマンドでSSID/passwordをM5Stackに送信します。TESTSSIDTESTPASSWDの部分は使用するアクセスポイントのものに置き換えて実行します。2つ目のコマンドでnetworkIDで指定したアクセスポイントに接続します。

M5Stack側のログでもアクセスポイントに接続したことがわかります。

I (390625) wifi:new:<3,1>, old:<1,1>, ap:<255,255>, sta:<3,1>, prof:1
I (391125) wifi:state: init -> auth (b0)
I (391135) wifi:state: auth -> assoc (0)
I (391155) wifi:state: assoc -> run (10)
I (391165) wifi:connected with TESTSSID, aid = 3, channel 3, 40U, bssid = xx:xx:xx:xx:xx:xx
I (391175) wifi:security: WPA2-PSK, phy: bgn, rssi: -50
I (391185) wifi:pm start, type: 1

I (391185) wifi:AP's beacon interval = 102400 us, DTIM period = 3

アクセスポイントに接続すると黄色い四角が明るくなります。

f:id:tomo-wait-for-it-yuki:20210929133526p:plain
アクセスポイント接続後

もうBLEは使わないのでBLEを切断します。これ以降はBLEのadvertiseをしなくなるようで、scanに引っかからなくなります。BLE切断しても青色の四角は明るいままのようです。

chip-device-ctrl > close-ble

M5StackのIPアドレスを取得します。M5Stack上でmDNSが動いており、これで通信するIPアドレスが取得できます。

chip-device-ctrl > resolve 0 135246

[1632878616.341733][38115:38123] CHIP:DL: Avahi resolve found
[1632878616.341806][38115:38123] CHIP:DIS: Node ID resolved for 0x000000000002104E to [192.168.1.16]:5540
Node address has been updated
[1632878616.342207][38115:38123] CHIP:CTL: OperationalDiscoveryComplete for device ID 135246
[1632878618.350885][38115:38123] CHIP:EM: Received message of type 0x31 with vendorId 0x0000 and protocolId 0x0000 on exchange 39023
Commissioning complete
Current address: 192.168.1.16:5540

ログを見ると0x000000000002104E (135246)[192.168.1.16]:5540に解決された、とあります。これでコミッショニングは完了です。次はデバイスの制御を試します。

バイスの制御

まずはライトのOn/Offを制御してみます。上から1番目の緑の四角が明るくなります。

chip-device-ctrl > zcl OnOff On 135246 1 1

f:id:tomo-wait-for-it-yuki:20210929133625p:plain
ライトON

ライトは2つあるので、もう1つの方を制御してみます。上から3番目の水色の四角が明るくなります。

chip-device-ctrl > zcl OnOff On 135246 2 1

f:id:tomo-wait-for-it-yuki:20210929133647p:plain
別のライトON

コマンドのフォーマットはこのような感じです。先程はendpointを変えることで、異なるライトを制御しました。

zcl <cluster> <command> <nodeid> <endpoint> <groupid> [key=value]

次は温度計から温度を読み取ってみます。

chip-device-ctrl > zclread TemperatureMeasurement MeasuredValue 135246 1 0

#...

[1632883644.088914][38115:38123] CHIP:ZCL: ReadAttributesResponse:
[1632883644.088919][38115:38123] CHIP:ZCL:   ClusterId: 0x0000_0402
[1632883644.088922][38115:38123] CHIP:ZCL:   attributeId: 0x0000_0000
[1632883644.088924][38115:38123] CHIP:ZCL:   status: Success                (0x0000)
[1632883644.088925][38115:38123] CHIP:ZCL:   attribute TLV Type: 0x00
[1632883644.088930][38115:38123] CHIP:ZCL:   attributeValue: 2100

#...

2100が返ってきています。これはZigBee Cluster Library Specificationによると21.0℃を表しています。

MeasuredValue = 100 x temperature in degrees Celsius.

値はプログラムの初期化時に設定されたものがそのまま返ってきています。M5Stackの画面とボタンから温度の値を変更すると、取得できる値も変わります。

大体雰囲気はつかめてきましたね。

サンプルを動かしてわかったこと

けっこうZigBee

Matterを策定しているCSA (Connectivity Standard Alliance)6は元々ZigBee Allianceだったこともあり、随所にZigBeeの資産を活用している様子が伺えます。プロトコルスタックの少し下の方をみると、ZigBee Cluster Library (zcl) を利用しているのも見て取れます。clusterの仕様などはZigBee Cluster Library Specification7を見ると読み解くことができます。

バイス初期設定も標準化

バイスの初期設定 (コミッショニング) が標準の中に含まれていて、その実装も与えられているのは嬉しいですね。今は各デバイスで独自の初期設定を持っていると思いますが (Nature Remoも)、これも統一化されることでユーザーが戸惑うことも少なくなるのではないか、と期待できます。BLE/IP/QRコードあたりが初期設定で使えるようになりそうです。

C++実装

ぱっと見、スタックもAPIC++です。スマートホーム用の標準でその実装なので当然マイコンをターゲットとしていますし、サンプルも複数のマイコン上で動くものが提供されています。こういうところもCではなくC++で作っていく感じなのでしょうね。Matterに関しては少なくとも暗号化処理やIPスタックが必要になってくるため、それなりの性能があるマイコン使うのが前提というのも言語選定の根底にありそうです。

今後C言語のbinding作られるのかどうかは少し注目です。

他のサンプル

今回はall-clusters-appサンプルをM5Stackで動かしましたが、複数のサンプルがいくつかのターゲットデバイスに対して提供されています。

nRF52840を使ったサンプルでは、モバイルアプリも含めてサンプルが提供されているので、けっこうそれっぽい雰囲気を味わうことができます。が、nRF52840を2台用意して、1台をThread Border Routerにして…、みたいな感じで動かすのが大変です。興味がある場合は、lock-app8を見てください。

Matterでこまったーこと

CLIツールを起動するとBluetoothペアリングが解除される

ホストPCからBLE接続するときにCLIツールを起動するとBluetoothのペアリングが解除されてしまいます。初回Bluetoothイヤホンで音楽聞きながら作業していたわけですが、急に音が止まりました。BLE使うししゃーないか、と思って改めてMatterのサンプル動かし終わった後にWeb会議のためBluetoothイヤホン接続しようとしたら、接続できなくて焦りました。

あとからペアリング自体が解除されていることに気づいてことなきを得ました。

2回目以降のコミッショニング方法が謎

M5Stackにファームウェアを書き込んで、CLIツールでBLE接続するところからコミッショニングすれば制御できるのですが、M5Stackを再起動したあとにコミッショニングする方法がわからず、事故ったら最初からやり直していて不便です!助けてください!

ツールをしらばく起動しているとCPU使用率が100%になる

Matterのサンプルで遊んでいると、特にPCに負荷かけていないはずなのに急にファンが唸りだしました。なんだろう?と思ってシステム負荷確認してみると…

f:id:tomo-wait-for-it-yuki:20210929135244p:plain
chip-device-ctrlがCPUを1個持っていく…

うーん、なんかイベントループの実装ミスってたりするのでしょうか…。

最後に

簡単にではありますが、Matterのサンプルを動かしてみました。ホストPCとM5Stack間のやり取りしかしていないのであまりスマートホーム感は出ませんでしたが。…スマートホームのデモとしてはやはりスマートスピーカーから制御できるようなものがあると見栄えが良いですね。

ソースコードもそれなりに読んでいるので、別の機会で紹介していければ良いな、と考えています。今後、Matterがもっと盛り上がるとおもしろいな、と思っているのでぜひ皆様もお試しください。

エンジニア積極採用中です

We are hiringです。ファームウェアのポジションもオープンしていますので、一緒にMatterでこまったー、となってくれる方からのご応募をお待ちしております。カジュアル面談等も歓迎なので、その場合はTwitterなどでお声がけくださると嬉しいです。

nature.global