Rust で gpsd から位置情報を取得する

Rust の入門書である the book を読み終えたので、 gpsd から TCP/IP 通信で位置情報を読み取りファイル保存するコード実装にトライする

今回使用したソースコードを GitHub で公開しています[[リンク]](https://github.com/ktr03rtk/gps-logger/tree/v0.1.0)

# 動作環境

- HW: Raspberry Pi 4B
- OS: Raspberry Pi OS
- その他: gpsd サービスが有効であること (GPS センサを使用して有効にする方法は[こちら](https://qiita.com/ktr03/items/ee9e9b1f194c2398479c))

# 実装する機能

- gpsd サーバに TCP/IP(ポート 2947)でリクエストする
- gpsd サーバからのレスポンス( gpsd_json 形式)をデシリアライズしフィルタする
- 結果をファイルに保存する

# 実行結果

`/var/log/gps-logger/data/gps.log`に gpad_json レスポンス内の TPV クラスの結果を書き出す

```:/var/log/gps-logger/gps.log
{"class":"TPV","device":"/dev/ttyUSB0","status":2,"mode":3,"timestamp":"2021-02-14T14:57:20+09:00","lat":34.XXXXXX,"lon":135.XXXXXX,"alt":39.3,"climb":0.0,"epc":7.93,"eps":3.5,"ept":0.005,"epx":1.628,"epy":1.749,"epv":3.967,"track":229.45,"speed":0.108}
{"class":"TPV","device":"/dev/ttyUSB0","status":2,"mode":3,"timestamp":"2021-02-14T14:57:21+09:00","lat":34.XXXXXX,"lon":135.XXXXXX,"alt":39.3,"climb":0.0,"epc":7.99,"eps":3.58,"ept":0.005,"epx":1.666,"epy":1.828,"epv":4.025,"track":229.45,"speed":0.123}
...
```

# ソースコード

```toml:Cargo.toml
[dependencies]
chrono = { version = "0.4.19", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
```

```Rust:main.rs
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
use std::{
    error,
    fs::{self, OpenOptions},
    io::{self, prelude::*},
    net, str,
};

#[derive(Serialize, Debug)]
struct Query {
    class: String,
    enable: bool,
    json: bool,
}

#[derive(Serialize, Default, Deserialize, Debug)]
#[serde(default)]
struct TPV {
    class: Option<String>,
    device: Option<String>,
    status: Option<u8>,
    mode: Option<u8>,
    #[serde(alias = "time")]
    timestamp: Option<DateTime<Local>>,
    lat: Option<f64>,
    lon: Option<f64>,
    alt: Option<f64>,
    climb: Option<f64>,
    epc: Option<f64>,
    eps: Option<f64>,
    ept: Option<f64>,
    epx: Option<f64>,
    epy: Option<f64>,
    epv: Option<f64>,
    track: Option<f64>,
    speed: Option<f64>,
}

fn main() -> Result<(), Box<dyn error::Error>> {
    let gps_addr = "127.0.0.1:2947";
    let file_dir = "/var/log/gps-logger/";
    let file_name = "gps.log";

    // Request to gpsd server
    let mut stream = net::TcpStream::connect(gps_addr)?;

    let request_query = Query {
        class: "WATCH".to_string(),
        enable: true,
        json: true,
    };

    let query = format!("?WATCH={}\n", serde_json::to_string(&request_query)?);
    stream.write_all(query.as_bytes())?;

    // Parse the response and save it to a file
    fs::create_dir_all(file_dir)?;
    let file_path = file_dir.to_string() + file_name;

    let mut reader = io::BufReader::new(&stream);
    let mut buf = vec![];

    loop {
        reader.read_until(b'\n', &mut buf)?;

        let deserialized: Result<TPV, serde_json::Error> =
            serde_json::from_str(str::from_utf8(&buf)?);

        match deserialized {
            Ok(res) => match res.class.clone().unwrap().as_str() {
                "TPV" => {
                    let log = serde_json::to_string(&res)? + "\n";
                    let file = OpenOptions::new()
                        .append(true)
                        .create(true)
                        .open(&file_path)?;

                    let mut f = io::BufWriter::new(file);
                    f.write_all(log.as_bytes())?;
                    f.flush()?;
                }
                // Discard all classes except TPV
                _ => (),
            },
            Err(err) => println!("Unexpected error while deserialization, {:?}", err),
        }
        buf.clear();
    }
}
```

# 実装メモ

## サーバへのリクエスト

gpsd はリクエストのクエリに応じて様々なレポートを返す[[参考]](https://gpsd.gitlab.io/gpsd/gpsd_json.html)
位置情報を含む TPV クラスのレスポンスを得るには`{"class":"WATCH","enable":true,"json":true}`のクエリを打てば良い

今回クエリは構造体をシリアライズして生成した

```Rust:
let mut stream = net::TcpStream::connect(gps_addr)?;

let request_query = Query {
    class: "WATCH".to_string(),
    enable: true,
    json: true,
};

let query = format!("?WATCH={}\n", serde_json::to_string(&request_query)?);
stream.write_all(query.as_bytes())?;
```

## レスポンスの読み取り

WATCH クエリをリクエストすると、一定時間毎に gpsd_json 形式のレポートがストリーム配信される。TPV クラスのレポートは改行コードで区切られる

```Rust:
let mut reader = io::BufReader::new(&stream);
let mut buf = vec![];

loop {
    reader.read_until(b'\n', &mut buf)?;

    // デシリアライズ、フィルタ、ファイル保存処理

    buf.clear();
}
```

## レスポンスのデシリアライズ

serde クレートを使用してレスポンスをデシリアライズし、class フィールドが TPV のものをフィルタする

```Rust:
let deserialized: Result<TPV, serde_json::Error> =
    serde_json::from_str(str::from_utf8(&buf)?);
```

デシリアライズして得られる構造体の型を予め定義する必要がある

今回のケースではセンサの計測状態によっては構造体の全フィールド値が得られない状況が発生する(例えば mode フィールドが 1 のとき gpsd サーバは緯度経度のフィールドを返さない)[[参考]](https://gpsd.gitlab.io/gpsd/gpsd_json.html#_tpv)

そのためフィールドの値がないときはデフォルト値を代用して構造体を生成する(Default トレイトと、serde の Field attributes)。また値がない場合とデフォルトと同じ値を受け取った場合を区別するため、各フィールドを Option<T>型とする。これにより None と T 型のデフォルト値で区別できる

```Rust:
#[derive(Serialize, Default, Deserialize, Debug)]
#[serde(default)]
struct TPV {
    class: Option<String>,
    device: Option<String>,
...
```

デシリアライズの際に Field attribute を使用して特定のフィールド名を変換する。`alias`はデシリアライズのときのみ変換し、シリアライズする際は変換後のフィールド名で出力する。`rename`はシリアライズする際に変換前のフィールド名に戻して出力する[[参考]](https://serde.rs/field-attrs.html#field-attributes)

```Rust:
struct TPV {
...
    #[serde(alias = "time")]
    timestamp: Option<DateTime<Local>>,
...
```

## フィルタリング

デシリアライズしたレポートから class フィールドが TPV のものを抽出する
{class: Option\<Stirng\>}を match 式で評価するために若干力押しの変換をしている

```Rust:
match deserialized {
    Ok(res) => match res.class.clone().unwrap().as_str() {
        "TPV" => {
            // ファイル保存
        }
        _ => (),
    },
    Err(err) => println!("Unexpected error while deserialization, {:?}", err),
}
```

## ファイル保存

ファイルがなければ新規作成し、存在すれば追記する

```Rust:
let file = OpenOptions::new()
    .append(true)
    .create(true)
    .open(&file_path)?;

let mut f = io::BufWriter::new(file);
f.write_all(log.as_bytes())?;
f.flush()?;
```

# 残課題

- 例外処理
- 機能のモジュール分割
- テスト

# 参考

- https://gpsd.gitlab.io/gpsd/gpsd_json.html
- https://serde.rs/field-attrs.html#field-attributes