ぶらり組込みRustライブラリ探索の旅 atat編 -ATコマンドクライアント-

ファームウェアエンジニアの中林 (id:tomo-wait-for-it-yuki) です。大好評の組込みRustで使えるライブラリをゆるく紹介していく「ぶらり組込みRustライブラリ探索の旅」シリーズ、第2弾はATコマンドクライアントライブラリのatatです。

私ごとですが、拙著「基礎から学ぶ組込みRust」ではネットワーク接続して遊ぶ、という内容が書けておらず、ずっとリベンジの機会を伺っています。atatは組込みRustでネットワーク接続して遊ぶ上で有力な選択肢になりそうなcrateです。

atat

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

github.com

https://docs.rs/atat/0.16.1/atat/index.html

特徴

atatno_std環境で使用できるATコマンドクライアントライブラリです。embedded-halのtraitを使って、ATコマンドベースのシリアルモジュール制御ドライバを書くことができます。

しかもatatを使うと動的なメモリ確保なしに、可変長のATコマンドを送受信できます。またATコマンドには、URC (Unsolicited Response Code) と呼ばれる非同期イベントがありますが、こちらもハンドリングが可能です。

メモリの動的確保なしに可変長のデータを送受信するために、heaplessと前回紹介したbbqueueをうまく活用しています。この活用が見事で組込みRustでメモリ確保どうすれば良いねん?に対する1つの解答だと感じたので、そのあたりも紹介していきます。

ATコマンド

ATコマンドはHayes社が自社のモデムを制御するために開発したコマンド体系です。コマンドの多くがATで始まることから、ATコマンドと呼ばれています。

en.wikipedia.org

例えば、ATDダイアルするコマンド、ATEはクライアントが発行したATコマンドをエコーするか制御するコマンド、といった具合です。

2022年現在でも、セルラーモジュールの制御や、WiFi通信モジュール*2の制御に使われています。多くの場合UARTを始めとするシリアル通信でコマンドを送受信します。ATコマンド自体についての説明は他リソースを参照ください*3

atatのソフトウェア構成とおおよその使い方

主要なモジュール

主要なモジュールは以下の3つです。

モジュール 概要
client ATコマンドクライアント。ATコマンドをシリアライズして送信したり、レスポンスやURCをデシリアライズする。
ingress manager レスポンスやURCの受信を担当する。レスポンスやURCを受信したらbbqueueを通してclientに送る。
digester ingress managerの一部。受信データをレスポンスやURC単位のバイト列に分割する(受信データの詳細は見ない)。

これらのモジュールは、次のシーケンスに沿って使用します。

  1. clientはATコマンドをシリアライズして送信する
  2. 受信スレッド (もしくは受信割り込み) で、ingress managerに受信データを書いていき、digesterがレスポンス / URC単位のバイト列に分割する
  3. ingress managerdigesterが分割したレスポンス / URCclientに送る
  4. clientは受信したレスポンス / URCをデシリアライズする

使い方

おおよそのデータの流れは次の通りです。clientingress manager, digesterの詳細は追って説明していきますので、ここではデータの流れをイメージできればOKです。

図中のAT command moduleが制御対象のモジュールであり、シリアル通信でATコマンドとレスポンス / URCをやり取りします。

main threadclientはRustの構造体として表現されたATコマンドをバイト列にシリアライズしてモジュールに送信します。また、ingress managerがqueueに詰めたレスポンス / URCを受け取り、そのバイト列をRustの構造体にデシリアライズします。

rx threadはモジュールからデータを受信して、レスポンス / URCの単位でバイト列を分割し、spsc (single producer single consumer) のqueueに詰めていきます*4

これをコードレベルに落とすと次のようになります (このコードはコンパイルできません) 。これが理解できると、あとは応用が色々あるだけです。

// レスポンスをデシリアライズした結果を格納する構造体 (今回の場合、空)
#[derive(AtatResp)]
pub struct NoResponse;

// ATコマンドにシリアライズされる構造体
#[derive(AtatCmd)]
#[at_cmd("", NoResponse)]
pub struct AT;

fn main() {
    // client と ingress manager を初期化する
    // 中略...

    // Launch reading thread
    std::thread::Builder::new()
        .spawn(move || loop {
            let mut buffer = [0; 32];
            // モジュールからシリアル通信でデータを受信する
            match serial_rx.read(&mut buffer[..]) {
                Ok(bytes_read) => {
                    // ingress manager に受信データを書き込む
                    ingress.write(&buffer[0..bytes_read]);
                    // 受信データをレスポンスやURCの単位に分割して、clientに送る
                    ingress.digest()
                }
                Err(e) => {
                    // error handling
                },
            }
        })
        .unwrap();

    // ATコマンド (`AT`) をシリアライズ & 送信して、受信 & デシリアライズしたレスポンス (`NoResponse`) を得る
    let res: NoResponse = client.send(&AT{})?;
}

コマンド例: AT

先程のサンプルコードではATというコマンドを例にしました。このコマンドはクライアントが対向モジュールと通信できているかどうか検査するためのテストコマンドとして利用できます。ATに対してはレスポンスはなく、OKというレスポンスコードだけが返ってきます。

コマンド レスポンス / URC 説明
AT テストコマンドをクライアントからモジュールに送信
OK レスポンスコード

先程のコードでこのことが表現されています。構造体AT"AT"という文字列にシリアライズされて、そのレスポンスはNoRenponseになります。このコードでは、#[derive(AtatCmd)]によってシリアライズ / デシリアライズの処理が自動で実装されています。

#[derive(AtatResp)]
pub struct NoResponse;

#[derive(AtatCmd)]
#[at_cmd("", NoResponse)]
pub struct AT;

コマンド例: AT+USORD

もう1つ具体的なATコマンドの例を用いて、それぞれの役割をもう少し説明します。ubloxのATコマンドでsocketからデータを読み込むAT+USORDコマンドの実行を考えます。AT+USORDコマンドを使うシーケンスは次の通りです。

コマンド レスポンス / URC 説明
AT+USORD=0,4 socket 0から4バイト読み出すATコマンドを送信
+USORD: 0,4,"90030002" 4バイトのデータを読み出す
OK レスポンスコード

このコマンドはatatで次のように実装できます。"AT"は常にデフォルトのプレフィックスとして追加される*5ので、"+USORD"という文字列を追加しています。

/// 25.12 Read Socket Data +USORD
#[derive(Clone, AtatResp)]
pub struct SocketData {
    pub socket: usize,
    pub length: usize,
    pub data: Option<String<SIZE>>,
}

#[derive(Clone, AtatCmd)]
#[at_cmd("+USORD", SocketData)]
pub struct ReadSocketData {
    pub socket: usize,
    pub length: usize,
}

ATコマンドを送信して、レスポンスを受け取るコードは次のようになります。

    let cmd = ReadSocketData { socket: 0, length: 4 };
    let res: SocketData = client.send(&cmd)?;

ここでReadSocketDataのコマンドを送信してから、ScoketDataの値を得るまでの工程をもう少し詳細に見てみましょう。

  1. モジュールからAT+USORDコマンドのエコー、レスポンス、レスポンスコードを受信します*6
  2. ingress managerの受信バッファに受信データを保持します
  3. digesterに受信バッファの内容を渡して、レスポンスを作ります
  4. diesterはエコーを削除し、レスポンスコードOKまでに受信した内容をレスポンスとみなして、レスポンス+USORD: 0,4,"90030002"を返します
  5. ingress managerdigesterから返ってきたレスポンスをresponse queueに書き込みます*7
  6. main thread側のclientではresponse queueに書かれたレスポンスを読み込みます*8
  7. レスポンスはこのままでは文字列 (バイト列) なので、デシリアライズしてRustの構造体 (SocketData) として表現された値にします

少し細かい話ですが、client.send()ジェネリック関数になっており、各ATコマンドに対して、異なる型のレスポンスを受け取ることができます。また、非同期イベントのURCについても、基本的な動作はレスポンス受信時と同じです。

atatの使い方を知るのに最も参考になるのは、atatの開発者が開発しているublox cellular module向けのクライアント実装です。より詳細に実践的な使い方を知りたい場合は、ublox-cellular-rsを読んでみてください。

github.com

dive into atat

では、atatの実装を深堀りしていきます。#[derive(AtatCmd)]の裏で何が起こっているのか、を把握しましょう。

crates

atatは次の3つのcrateから構成されています。

crate 説明
atat 中心となるcrateでコアとなるtraitや、ingress manager, digester, clientのデフォルト実装があります
serde_at atatのserde実装で、Rustの構造体⇔ATコマンド文字列のシリアライズ、デシリアライズを実装します
atat_derive atatのtraitを自動導出するderive macroが定義されています

リストの上から順番に、ボトムアップで説明します。

atat & traits

atatで定義されている主なtraitの一覧です。

trait 概要
AtatCmd 各ATコマンド型が実装しなければならないトレイトです。#[derive(AtatCmd)]で実装することもできます。
AtatResp レスポンス型が実装しなければならないマーカートレイトです。
AtatUrc URC型が実装しなければならないトレイトです。
AtatLen #[derive(AtatCmd)]を使う時に、シリアライズ後のATコマンドの長さを、コンパイル時に計算するためのトレイトです。

上記の内、AtatCmdAtatLenについてさらに説明します。

AtatCmd

AtatCmdatat/src/traits.rsに以下の通り定義されています (説明を簡略化するため抜粋しています) 。

pub trait AtatCmd<const LEN: usize> {
    /// The type of the response. Must implement the `AtatResp` trait.
    type Response: AtatResp;

    // 中略

    /// Return the command as a heapless `Vec` of bytes.
    fn as_bytes(&self) -> Vec<u8, LEN>;

    /// Parse the response into a `Self::Response` or `Error` instance.
    fn parse(&self, resp: Result<&[u8], InternalError>) -> Result<Self::Response, Error>;
}

as_bytes()はこの構造体をATコマンドのバイト列にシリアライズするメソッドです。heapless:Vecはスタック上に最大容量LENの可変長配列を作る構造体で、これがas_bytes()の戻り値型になっています。つまり、AtatCmdを実装する構造体をシリアライズすると、最大LENバイトのバイト列になる、ということです。シリアライズ処理は、手動でゴリゴリ書くこともできますし、serde_atを使ってserdeシリアライズに乗っかることもできます。

ジェネリックパラメータ<const LEN: usize>は、シリアライズ後のバイト列が最大で何バイトになるか、を示すパラメータになっています。これは手動で計算するか、#[derive(AtatCmd)]コンパイル時に計算させることができます。

parse()はレスポンスのバイト列を構造体にデシリアライズするメソッドです。デシリアライズ処理もシリアライズ処理と同様に、手動で書くことも、serde_atを使うこともできます。

例として、手動でATコマンドにAtatCmdを実装すると次のようになります。

use heapless::Vec;

pub struct NoResponse;
impl AtatResp for NoResponse{};

pub struct At{};
// 改行コード含めて`"AT\r\n"`の4バイトになるので、`LEN`は`4`です
impl AtatCmd<4> for At {
    type Response = NoResponse;

    fn as_bytes(&self) -> Vec<u8, 4> {
        // 手動でシリアライズ処理を書いています
        Vec::from_slice(b"AT\r\n").unwrap()
    }

    fn parse(&self, resp: Result<&[u8], InternalError>) -> Result<Self::Response, Error> {
        let _ = resp?; // レスポンスはないので無視する
        Ok(NoResponse)
    }
}

ATコマンドの場合はレスポンスがないため、parse()がほとんど空です。もしレスポンスが返ってくるコマンドのデシリアライズ処理を書くのであれば、次のように書くこともできます。

    fn parse(&self, resp: Result<&[u8], InternalError>) -> Result<Self::Response, Error> {
        let bytes = resp?;
        if !bytes.starts_with(b"+USORD:") {
            return Error::Mismatched;
        }
        let body = &bytes[b"+recv:".len()..];

        // バイト列をパースする (詳細は省略)
        
        Ok(SocketData{ /* ... */ })
    }

AtatLen

AtatCmdにはシリアライズ後のバイト数をパラメータLENとして与える必要があります。ATコマンド程度であれば簡単に計算できますが、ATコマンドのパラメータが多かったり、構造体を持つ構造体にAtatCmdトレイトを実装したい、となるとLENを計算するのが大変です。

そこでAtatLenトレイトの登場です。AtatLenはその型をATコマンドにシリアライズした時に、何バイトのバイト列になるか、を示します。AtatLenatat/src/derive.rsに定義されており、その定義は非常に単純です。

pub trait AtatLen {
    const LEN: usize;
}

Rustのプリミティブ型やheapless::String<L>heapless::Vec<T, L>などatatを使う上で頻出の型に対して、AtatLenが実装されています。

macro_rules! impl_length {
    ($type:ty, $len:expr) => {
        #[allow(clippy::use_self)]
        impl AtatLen for $type {
            const LEN: usize = $len;
        }
    };
}

impl_length!(char, 1);
impl_length!(bool, 5);
// 中略
impl_length!(f32, 42);
impl_length!(f64, 312);

impl<const T: usize> AtatLen for String<T> {
    const LEN: usize = T;
}

impl<T: AtatLen> AtatLen for Option<T> {
    const LEN: usize = T::LEN;
}

impl<T: AtatLen> AtatLen for &T {
    const LEN: usize = T::LEN;
}

impl<T, const L: usize> AtatLen for Vec<T, L>
where
    T: AtatLen,
{
    const LEN: usize = L * <T as AtatLen>::LEN;
}

AtatLenがあることで、構造体をシリアライズした結果が何バイトになるか、簡単に計算できます。

const ATAT_READSOCKETDATA_LEN = usize::LEN + usize::LEN;
pub struct ReadSocketData {
    pub socket: usize,
    pub length: usize,
}

ATAT_READSOCKETDATA_LEN"AT+USORD" + "\r\n"の長さを加えれば計算完了です!heapless::Vecを使う上で、事前に最大長を指定しなければならない、という面倒臭さがこれできれいに解消されています。

serde_at

AtatCmdトレイトを実装する際、as_bytes()で構造体をバイト列にシリアライズしていました。またparse()でバイト列を構造体にデシリアライズしていました。このシリアライズ / デシリアライズ処理を簡単化するためにserde実装が提供されています。それがserde_atです。

構造体にserde::Serializeトレイトが実装されていれば、serde_at::to_vec()で構造体をバイト列にシリアライズすることができます。同様に、構造体にserde::Deserializeトレイトが実装されていれば、serde_at::from_slice()でバイト列を構造体にデシリアライズできます。先程のAT+USORDを例にすると、次のように書けます。

// レスポンスは`serde::Deserialize`トレイトを実装
#[derive(serde::Deserialize)]
pub struct SocketData {
    pub socket: usize,
    pub length: usize,
    pub data: Option<String<SIZE>>,
}

const ATAT_READSOCKETDATA_LEN = usize::LEN + usize::LEN;
// ATコマンドは`serde::Serialize`トレイトを実装
#[derive(serde::Serialize)]
pub struct ReadSocketData {
    pub socket: usize,
    pub length: usize,
}

// シリアライズ後のバイト数 = 構造体をシリアライズしたバイト数 + 固定文字列のバイト数
impl AtatCmd<{ ATAT_READSOCKETDATA_LEN + b"AT+USORD\r\n".len() }> for ReadSocketData {
    type Response = SocketData;

    fn as_bytes(&self) -> Vec<u8, { ATAT_READSOCKETDATA_LEN + b"AT+USORD\r\n".len() }> {
        // `serde_at::to_vec()`でATコマンドのバイト列にシリアライズ
        match atat::serde_at::to_vec(self, "+USORD", atat::serde_at::SerializeOptions::default() {
            Ok(s) => s,
            Err(_) => panic!("Failed to serialize command")
        }
    }

    fn parse(&self, res: Result<&[u8], atat::InternalError>) -> Result<Self::Response, atat::Error> {
        match res {
            Ok(resp) => atat::serde_at::from_slice::<Self::Response>(resp).map_err(|e| {
                atat::Error::Parse
            }),
            Err(e) => Err(e.into())
        }
    }
}

パターンが見えてきましたか?

  • レスポンスはserde::Deserializeを実装する
  • ATコマンドはserde::Serializeを実装する
  • シリアライズ後のバイト数は、AtatLenと固定文字列のバイト数とを使ってコンパイル時に計算する
  • as_bytes()ではserde_at::to_vec()シリアライズする
  • parse()ではserde_at::from_slice()でデシリアライズする

atat_derive

ここまでで、構造体をシリアライズした後のバイト数の計算、およびserdeを使ったシリアライズ / デシリアライズ処理が、完全にパターン化されました。そこで、atat_deriveでこれらのパターンを自動的に導出できるようにします。

#[derive(AtatCmd)]は、構造体にserde::Serializeトレイト、AtatCmdトレイト、AtatLenトレイトを実装します。AtatCmdトレイトの実装にあたっては、これまで説明したAtatLenによるシリアライズ後のバイト数の計算、および、serde_atによるシリアライズ / デシリアライズを行います。

#[derive(AtatResp)]は、構造体にserde::DeserializeトレイトとAtatRespトレイトを実装します。レスポンスの構造体にserde::Deserializeが実装されているため、AtatCmdparse()serde_atによるデシリアライズが可能となります。

Appendix

digester

Appendixその1は、digesterについてです。モジュールから受信したバイト列を、レスポンス / URC単位に分割する、という役割を果たしています。その内部ではパーサーコンビネータnomを使用しています。

さらにデフォルトのdigester実装では対応できないパターンも処理できるように、カスタマイズしたパーサーをdigesterに登録できます。例えば、デフォルトのdigester実装では"ERROR: <error message>"のようなエラーレスポンスは処理できません。そこでカスタムパーサーを用意し、digester初期化時に与えます。

use nom::{
    combinator::recognize,
    bytes::streaming::{tag, take_until},
    character::complete::line_ending,
};

/// 正規表現 "\r\nERROR:(.*)\r\n" にマッチする
/// "\r\nERROR"と"\r\n"を除いたエラーメッセージを含むバイト列を返す 
pub fn custom_error_parser(buf: &[u8]) -> Result<(&[u8], usize), atat::digest::ParseError> {
    let (i, tag) = recognize(tag("\r\nERROR:"))(buf)?;
    let (i, error_msg) = recognize(take_until("\r\n"))(i)?;
    let (_, end_line) = line_ending(i)?;
    Ok((error_msg, tag.len() + error_msg.len() + end_line.len()))
}

    // digesterの初期化
    let digester = atat::AtDigester::new().with_custom_error(custom_error_parser);
    let (client, mut ingress) =
        atat::ClientBuilder::new(serial_tx, timer, digester, config).build(queues);

custom-error-messages featureを有効にするとatat::Error::CustomMessage(Vec<u8, 64>)として、カスタムパーサーで返したバイト列を受け取ることができます。

logging

ログ周りも整備されており、Rustのlogを使っています。フォーマット文字列を使うところは組込み向けの軽量フォーマット文字列crateであるdefmtを選択的に使うことができます。下のようにfeatureで切り替えできるようになっています。

                #[cfg(any(feature = "defmt", feature = "log"))]
                match &resp {
                    Ok(r) => {
                        if r.is_empty() {
                            debug!("Received OK")
                        } else {
                            debug!("Received response: \"{:?}\"", LossyStr(r.as_ref()));
                        }
                    }
                    Err(e) => {
                        error!("Received error response {:?}", e);
                    }
                };

おわりに

atatno_stdで使えるATコマンドクライアントライブラリです。本エントリではatatのおおよその構成や使い方、主要な実装を紹介しました。

ESP32シリーズのようなWi-Fiモジュールのドライバをatatで書けば、組込みRustでネットワーク接続して存分に遊ぶことができそうです。atatでドライバを書いたあとは、embedded-nalを抽象レイヤーとして繋ぎ込むと良いでしょう。

そして組込みRustでネットワークプログラミングライフを満喫しましょう!


Natureでは組込みRustでネットワークプログラミングするのが面白そう、と思えるエンジニアを募集しています。

herp.careers

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

herp.careers

*1:v0.15とはdigesterの実装が大きく変更されています

*2:ESP32 ATコマンドファームウェアなど

*3:SORACOM で学ぶ AT コマンド入門 など

*4:可変長データを1単位として送受信するためにbbqueueを使っています

*5:atatでは、このプレフィックスもカスタマイズ可能です

*6:エコーはない場合もあります

*7:bbqueueは任意長のデータを1かたまりとして書き込めます

*8:一定時間内にレスポンスが来なければタイムアウトにすることもできます