Python×シリアル通信で、組み込みデバイスのアップデートツールを実装してみた

こんにちは！
この投稿では、組み込みデバイスのLoRaモジュール「LRA1」のファームウェアをアップデートするツール「lra1_tool.py」について解説します。
シリアル通信の基本やCRC計算、タイムアウト処理など、組み込み用のツールをPythonで実装する例にちょうどいいかと思います。

- シリアル通信と `pySerial` の使い方
- ファームウェア転送の仕組み
- PythonでのCRC計算やタイムアウト処理の実装例
- 実行方法やコマンドライン引数の使い方

など、**Pythonで組込み機器のツールを開発する実践的なテクニック**が満載です！

このコードは、以下のGitHubリポジトリで公開されています。
**[GitHubリポジトリ: shachi-lab/lra1_tool](https://github.com/shachi-lab/lra1_tool)**

## 1. LRA1 Toolとは？

LRA1 Toolは、i2-electronicsが提供するLoRaデバイス「LRA1」のファームウェアを、PCからシリアル通信経由で更新、検証、または初期化するためのPythonスクリプトです。低消費電力で長距離通信が可能な無線通信技術LoRaWANに対応したLRA1デバイスのメンテナンスを、コマンドラインから簡単に行うことができます。

通常、組み込みデバイスのファームウェア更新には専用ツールや特定の開発環境が必要となることが多いですが、このPythonツールを使えば、より柔軟に、そしてPythonの知識があれば誰でも手軽にLRA1デバイスのファームウェアを操作できます。

---

## 2. なぜPythonでこのツールを作るのか？

* **クロスプラットフォーム性** : PythonはWindows, macOS, Linuxなど様々なOSで動作するため、幅広い環境で利用できるツールを作成できます。
* **豊富なライブラリ** : シリアル通信を行うための`pySerial`など、便利なライブラリが豊富に用意されており、開発効率が高いです。
* **可読性の高さ** : Pythonは文法がシンプルで読みやすく、初学者でもコードの内容を理解しやすいです。

---
## 3. 事前準備：`pySerial`のインストール

このスクリプトを実行するには、`pySerial`ライブラリが必要です。
以下のコマンドでインストールしておきましょう。

```bash
pip install pyserial
```

---

## 4. LRA1 Toolの主要な機能とコードの解説

`lra1_tool.py`は、いくつかの重要なクラスや関数で構成されています。
それぞれを見ていきましょう。

### 4.1. 定数定義

スクリプトの冒頭には、各種定数が定義されています。

```python
# 定数定義
VERSION         = "1.01"
INIT_TIMEOUT    = 0.05     # DFU初期化待ちタイムアウト (50 ms)
RESP_TIMEOUT    = 1.0      # レスポンス待ちタイムアウト (1000 ms)
FILE_MIN_SIZE   = 4096     # ファームウェアファイル最小サイズ (4kB)
FILE_MAX_SIZE   = 120000   # ファームウェアファイル最大サイズ (120kB)

UPDATE_ADRS = 0x002000     # アップデート用フラッシュアドレス
INIT_ADRS   = 0x01fe00     # 初期化用フラッシュアドレス
INIT_SIZE   = 256 + 256    # 初期化モードで書き込むサイズ

# ブートローダー用コマンド等の定数
BSL_HEADER                     = 0x80
BSL_CMD_RX_DATA_BLOCK          = 0x10
BSL_CMD_RX_DATA_BLOCK_VERIFY   = 0x12
BSL_CMD_LOAD_PC                = 0x17
BSL_CMD_RX_DATA_BLOCK_FAST     = 0x1b

# ファイル内に含まれるべきマジックバイト ("i2-ele ")
MAGIC_BYTES = bytes([0x69, 0x32, 0x2d, 0x65, 0x6c, 0x65, 0x20])
```

ここでは、タイムアウト時間、ファームウェアファイルのサイズ制限、フラッシュメモリのアドレス、ブートローダーコマンドの定義、そしてファームウェアファイルに含まれるべき「マジックバイト」などが定義されています。マジックバイトは、ファイルがLRA1用のファームウェアであるかどうかを識別するために使われます。

### 4.2. `LRA1Tool`クラス

このスクリプトの核となるのが `LRA1Tool` クラスです。
デバイスとのシリアル通信、ファームウェアファイルの読み込み、そして実際の転送処理のロジックがここに集約されています。

#### `__init__`メソッド（コンストラクタ）

```python
class LRA1Tool:
    def __init__(self, port: str, use_reset: bool, sw_reset: bool, mode: str, filename: str = None):
        """
        コンストラクタ
          port      : 使用するシリアルポート
          use_reset : DTRリセットを使用するかどうか
          mode      : 動作モード ('update', 'verify', 'init')
          filename  : ファームウェアファイル名（initモード以外で必須）
        """
        self.port = port
        self.use_reset = use_reset
        self.sw_reset = sw_reset
        self.mode = mode
        self.filename = filename
        self.flash_adrs = UPDATE_ADRS if mode in ('update', 'verify') else INIT_ADRS
        self.file_buff = None
        self.file_size = 0
        self.mode_flag = None
        if mode == 'update':
            self.mode_flag = BSL_CMD_RX_DATA_BLOCK
        elif mode == 'verify':
            self.mode_flag = BSL_CMD_RX_DATA_BLOCK_VERIFY
```

コンストラクタでは、シリアルポート名、リセットオプション、動作モード（`update`, `verify`, `init`）、ファームウェアファイル名などの初期設定を行います。選択されたモードに基づいて、書き込み先のフラッシュアドレス（`flash_adrs`）と転送モードフラグ（`mode_flag`）が設定されます。

#### シリアル通信・CRC計算・タイムアウト付き読み出し関連のメソッド

  * **`reset_dtr(ser: serial.Serial)`** : DTR (Data Terminal Ready) 信号を使ってデバイスをハードウェアリセットします。これは、特定のデバイスでブートローダーモードに入るために利用されます。
  * **`reset_cmd(ser: serial.Serial)`** : "RESET"コマンドをシリアルポートに送信してソフトウェアリセットを試みます。
  * **`calc_crc(data: bytes) -> int`** : データのCRC-CCITT（巡回冗長検査）を初期値0xffffで計算します。データの破損を検出するために重要な役割を果たします。`binascii.crc_hqx`関数を使用することで、効率的にCRCを計算しています。
  * **`serial_getchar_to(ser: serial.Serial, timeout_sec: float) -> int`** : 指定されたタイムアウト時間内でシリアルポートから1バイトを読み取ります。タイムアウトが発生した場合は-1を返します。このタイムアウト処理は、シリアル通信においてデバイスからの応答がない場合にプログラムが停止しないようにするために非常に重要です。
  * **`recv_response(ser: serial.Serial, expected_len: int) -> int`** : 指定した長さのレスポンスを受信し、レスポンスコードを返します。ヘッダーチェックも行い、エラーの場合は負の値を返します。
  * **`send_command(ser: serial.Serial, cmd: bytes)`** : コマンドにヘッダー、長さ、コマンド本体、CRCを付加してシリアルポートに送信します。

#### ファームウェア転送関連のメソッド

  * **`send_rx_data_block(self, ser: serial.Serial, cmd: bytearray, adrs: int, num: int) -> int`**: データブロックをデバイスに送信します。ファームウェアの各チャンク（256バイト単位）がこのメソッドで送られます。転送モードに応じて適切なコマンドをセットします。
  * **`update_progress(total: int, remain: int)`**: プログレスバーを表示し、ファームウェア転送の進捗を視覚的にユーザーに伝えます。転送進捗を50文字幅のバーで表示します。
  * **`load_firmware(self)`**: 指定されたファームウェアファイルを読み込み、ファイルサイズが適切か（最小4kB、最大120kB）、そしてマジックバイトが含まれているかを確認します。これにより、間違ったファイルが転送されるのを防ぎます。
  * **`loRa_update(self, ser: serial.Serial) -> int`** : ファームウェア転送のメインロジックです。
      * **DFUモード待ち**: デバイスがDFU (Device Firmware Update) モードに入るのを待ちます。これはデバイスのリセット後に特定のシーケンスを送信することで行われます。
      * **データブロックの送信**: ファイルの内容を256バイトごとのブロックに分割し、`send_rx_data_block`を使ってデバイスに送信します。この際、各バイトのチェックサムも計算されます。
      * **最終コマンド (PCロード)**: 全てのデータブロックの送信が完了したら、デバイスにPC (Program Counter) をロードするコマンドを送信し、新しいファームウェアで起動するように指示します。

#### `run` メソッド

```python
    def run(self):
        """
        ツールのメイン処理:
          - モードに応じたファイル読み込みまたは初期化データの生成
          - シリアルポートのオープンと必要なリセット
          - ファームウェア転送の実行と結果表示
        """
        try:
            ser = serial.Serial(self.port, 115200, timeout=0)
        except Exception:
            print(f"{self.port} device not open.")
            sys.exit(-1)
        ser.dtr = True  # 初期状態に設定
        if self.sw_reset:
            self.reset_cmd(ser)
        if self.use_reset:
            self.reset_dtr(ser)
        ret = self.loRa_update(ser)
        ser.close()
        if ret:
            print(f"\nError occurred. ({ret})")
            sys.exit(ret)
        print("\nSuccessful.")
```

`run`メソッドは、ツールのエントリポイントです。

1.  モード（`init`, `update`, `verify`）に応じて、ファームウェアファイルを読み込むか、初期化用のゼロバイト配列を生成します。
2.  指定されたシリアルポートをオープンします。ここでは、ボーレートが指定されても115200固定でオープンされます。
3.  必要に応じてDTRリセットまたはソフトウェアリセットを実行し、デバイスを転送可能な状態にします。
4.  `loRa_update`メソッドを呼び出して、実際のファームウェア転送処理を実行します。
5.  処理の成否に応じてメッセージを表示し、シリアルポートを閉じます。ファイルオープンエラーやシリアルポートオープンエラーなど、様々なエラーが考慮され、適切な終了コードでプログラムが終了します。

### 4.3. コマンドライン引数解析

```python
def parse_arguments():
    parser = argparse.ArgumentParser(
        usage="%(prog)s [options]",
        description=f"LRA1 Tool\nVersion: {VERSION}"
    )
    parser.add_argument('-p', '--port', type=str, required=True, help='Specify the serial port (e.g. com0, /dev/ttyS0)')
    parser.add_argument('-r', '--reset', action='store_true', help='Use DTR to reset before transfer')
    parser.add_argument('-s', '--swreset', action='store_true', help='Softwere reset before transfer')
    parser.add_argument('-b', '--baud', type=int, default=115200, help='Specify baud rate (default 115200; value is ignored)')
    group = parser.add_mutually_exclusive_group()
    group.add_argument('-u', '--update', action='store_true', help='Update LRA1 firmware (default mode)')
    group.add_argument('-v', '--verify', action='store_true', help='Verify LRA1 firmware')
    group.add_argument('-i', '--init', action='store_true', help='Initialize the settings (No file needed)')
    parser.add_argument('-f', '--file', type=str, help='Firmware file name (required for update/verify modes)')
    
    if len(sys.argv) == 1:
        parser.error("Use --help option to see usage")
    args = parser.parse_args()

    if not (args.update or args.verify or args.init):
        args.update = True

    if not args.init and not args.file:
        parser.error("No update file specified. Use -f or --file to specify the firmware file.")
    return args
```

`argparse`モジュールを使って、コマンドライン引数を解析しています。
これにより、ユーザーはモードやシリアルポート、ファームウェアファイルなどを指定してツールを実行できます。

### 4.4. `main` 関数

```python
def main():
    args = parse_arguments()
    if args.update:
        mode = "update"
    elif args.verify:
        mode = "verify"
    else:
        mode = "init"
    tool = LRA1Tool(port=args.port, use_reset=args.reset, sw_reset=args.swreset, mode=mode, filename=args.file)
    tool.run()

if __name__ == '__main__':
    main()
```

`main`関数は、`parse_arguments`で引数を解析し、それらの引数を使って`LRA1Tool`オブジェクトを生成し、`run`メソッドを呼び出して処理を開始します。`if __name__ == '__main__':`ブロックは、スクリプトが直接実行された場合にのみ`main`関数が呼び出されるようにするためのPythonの慣習です。

---

## 5. LRA1 Toolの使い方（実行例）

LRA1 Toolのコマンドライン引数や、より詳細な操作説明については、[GitHubリポジトリのREADME](https://github.com/shachi-lab/lra1_tool/blob/main/README.md)をご覧ください。

ここでは、基本的な実行例をいくつかご紹介します。

#### ファームウェアの更新

```bash
python lra1_tool.py -p COMx -u -f firmware.bin
```

（`COMx`はご自身の環境に合わせて適宜変更してください。例: `COM3`, `/dev/ttyUSB0`など）

#### ファームウェアの検証

```bash
python lra1_tool.py -p COMx -v -f firmware.bin
```

#### 設定の初期化

```bash
python lra1_tool.py -p COMx -i
```

---

## 6. まとめ

今回は、PythonでLoRaデバイス「LRA1」のファームウェアを操作するツール`lra1_tool.py`のコードを詳しく見てきました。`pySerial`を使ったシリアル通信の具体的な実装例、バイトデータの扱い、CRC計算、そしてコマンドライン引数の解析など、Pythonを使った実践的な開発の多くの要素が詰まっています。

このコードはGitHubで公開されており、皆さんも実際に動かしてみたり、コードを読んでみたりすることで、より深く学ぶことができるでしょう。このコードを参考に、ご自身のIoTデバイスや組み込みシステムをPythonで制御するツール開発に挑戦してみてはいかがでしょうか！

> 🎀「実際に動くツールで学べると、コードの意味がグッとわかりやすくなるよね！
> それに、PythonってWebだけじゃなくて、こういうツールにもすごく使えるんだな〜って実感したよ！」

---

ちなみに──
こちらのブログで実際の活用事例を紹介しているので、よかったらのぞいてみてください📡✨

👉 [https://blog.shachi-lab.com](https://blog.shachi-lab.com)