全力でLチカしてみる

これは株式会社POL テックカレンダーの9日目の記事です。前回の記事はこちらです。

こんにちは!株式会社POLでエンジニアをしている牛木と申します。
入社してまだ半年に満たないので、会社のアドベントカレンダーは初参加です。

前置き

今回アドベントカレンダーを行うにあたって、社内で次のようなアナウンスがありました。

  • 技術的なことならば業務に関係なくてもOK

  • LチカしてみただけみたいなのはNG

それを踏まえてネタを考えようとしましたが、諸事情により思いつかなくて頭を抱えました。しょうがないので自分の引き出しからということで、私の主戦場はWebフロントエンドなのですが、前職で諸事情によりデバイス開発をやったこともありまして、そこから考えてみることにしました。

で、「LチカしてみただけみたいなのはNG」→「軟弱なLチカはNG」→「全力でLチカはOK」ということで、デバイス開発経験のあるWebフロントエンドのエンジニアとして「全力でLチカ」してみます。

「全力でLチカ」とは何か考える

はじめに「全力でLチカ」とは何か考えてみます。それには「軟弱なLチカ」とは何か?から考えると良さそうです。まずは自分のイメージする「軟弱なLチカ」を考えてみます。

自分のイメージする軟弱なLチカ

コードはこんな感じの一枚もの。​

import time
import RPi.GPIO as GPIO
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)

channel = 17

GPIO.setup(channel, GPIO.OUT)

try:
   while True:
       GPIO.output(channel, GPIO.HIGH)
       time.sleep(1)
       GPIO.output(channel, GPIO.LOW)
       time.sleep(1)
finally:
   GPIO.cleanup()

実行はRaspberry PIにSSHログインしてスクリプト実行。

$ python app.py

動いている様子はこう。

Lチカ

教科書通りのLチカですね。一旦整理してみます。

  • コードが一枚もの、テストも構造化もない。

  • 実行が手動。

  • プロダクト化を意識していない。

このあたりから「全力でLチカ」が見えてきそうです。

自分のイメージする全力のLチカ

「軟弱なLチカ」のイメージを踏まえて「全力のLチカ」をイメージしてみます。先程整理した項目の逆をまとめてみます。

  • コードが構造化されている・テストもある。

  • 実行が自動(サービス化されている)。

  • プロダクト化を意識している。

良い感じに全力っぽくなってきました。予算とスケジュールの都合上、現実的な範囲でこれらを満たしていきたいと思います。

具体的にはこのあたりをスコープとします。

  • コードが構造化されている・テストもある

  • 実行が自動(サービス化されている)

  • プロダクト化を意識している

    • OSのイメージ化まではやる、基板のアセンブリはしない(ブレッドボードまま)

それでは開発に取り掛かってみます。

開発

こちらのリポジトリで作業してます。

開発環境の構成

ひとまず次のような構成でローカルの開発環境を整えました。

├── README.md
├── app.py
├── bin
│   ├── enable-overlayfs.sh
│   ├── initialize.sh
│   ├── lint.sh
│   ├── setup-os.sh
│   ├── setup-systemd.sh
│   └── sync.sh
├── develop-requirements.txt
├── fpl
│   ├── __init__.py
│   └── py.typed
├── requirements.txt
├── tests
│   └── __init__.py
└── venv

開発環境(ローカル)のセットアップ

venv環境をセットアップして、依存モジュールをインストールします。

$ python3 -m venv venv
$ source venv/bin/activate
$ pip install -U pip
$ pip install -r develop-requirements.txt

開発の回し方

ローカルでコードを書いて、デバイスへ送って実行します。今回はbin/sync.shというスクリプトを用意して、コードを編集したらこれを実行してファイルを転送し、実機上で実行を繰り返します(中身はrsync)。
実行例は次のとおりです。

$ ./bin/sync.sh pi@192.168.1.10:~/app

実行環境のセットアップ

初回のみRaspberry PI上で環境構築用に用意したスクリプトを実行します。
まず、python3-venvをインストールするbin/setup-os.sh、次に依存モジュールをインストールするbin/initialize.shを実行します。venvを有効にしてapp.pyが実行できることを確認します。

$ sudo bin/setup-os.sh
$ bin/initialize.sh
$ source venv/bin/activate
$ python app.py

コーディング

それでは最初の一枚物コードから構造化してテスト可能にしてみます。なんとかしたいのはGPIOの扱いです。ハードウェア依存な部分になるので、そのままだと非常にテストしにくいものになります。

GPIOを分離

GPIOを直に使うのはやめて、次のようなIOInterfaceで抽象化してみます。

class IOInterface:

    def initialize(self) -> None:
        raise NotImplementedError

    def setup_out(self, channel: int) -> None:
        raise NotImplementedError

    def output_high(self, channel: int) -> None:
        raise NotImplementedError

    def output_low(self, channel: int) -> None:
        raise NotImplementedError

    def finalize(self) -> None:
        raise NotImplementedError

そして、これを実装する形でGPIOクラスを作成します。

from __future__ import annotations

try:
    import RPi.GPIO as _GPIO  # type: ignore
except RuntimeError:
    pass  # ignore

from ._io import IOInterface


class GPIO(IOInterface):

    def initialize(self) -> None:
        _GPIO.setmode(_GPIO.BCM)
        _GPIO.setwarnings(False)

    def setup_out(self, channel: int) -> None:
        _GPIO.setup(channel, _GPIO.OUT)

    def output_high(self, channel: int) -> None:
        _GPIO.output(channel, _GPIO.HIGH)

    def output_low(self, channel: int) -> None:
        _GPIO.output(channel, _GPIO.LOW)

    def finalize(self) -> None:
        _GPIO.cleanup()

    @classmethod
    def create(cls) -> GPIO:
        o = cls()
        o.initialize()
        return o

RPi.GPIOはインポート時のRuntimeErrorを無視していますが、インポートできない状態で使おうとすると「_GPIO」のNameErrorで落ちるので気付ける想定です。

LEDをクラス化

また、LEDの点灯制御をコード上でもLEDとして表現したいので、クラスにしておきます。

from ._io import IOInterface


class LED:

    def __init__(self, channel: int, io: IOInterface):
        self._channel = channel
        self._io = io
        self._io.setup_out(channel)

    def turn_on(self) -> None:
        self._io.output_high(self._channel)

    def turn_off(self) -> None:
        self._io.output_low(self._channel)

Applicationとしてまとめる

取りまとめて使うアプリケーションとして、次のようにしてみます。

from __future__ import annotations

import time
from typing import Callable

from ._exception import TerminatedException
from ._gpio import GPIO
from ._io import IOInterface
from ._led import LED

AppProc = Callable[[LED, bool], bool]


def app_process(led: LED, led_status: bool) -> bool:
    if led_status:
        led.turn_off()
    else:
        led.turn_on()
    return not led_status


class Application:

    def __init__(self, io: IOInterface, led_channel: int, app_proc: AppProc = app_process, wait_sec: int = 1):
        self._io = io
        self._led_channel = led_channel
        self._app_process = app_proc
        self._wait_sec = wait_sec

    def run(self) -> None:
        led_status = False  # On: True, Off: False
        try:
            led = LED(self._led_channel, self._io)
            while True:
                led_status = self._app_process(led, led_status)
                time.sleep(self._wait_sec)
        except (KeyboardInterrupt, TerminatedException):
            pass  # ignore
        finally:
            self._io.finalize()

    @classmethod
    def create(cls, led_channel: int) -> Application:
        io = GPIO.create()
        return cls(io, led_channel)

起動してから繰り返し動かす部分(Application.run)と、現在の状態に応じてLEDの点灯制御をする部分(app_process)を分けてみました。

自動起動・停止のためのシグナル対応

スクリプトを手動で実行しているうちはCtrl+Cで停止するのでKeyboardInterruptをハンドリングすれば良いですが、自動起動・停止(systemdを使います)ではSIGTERMで止まれるようにしなければいけません。これはTerminatedExceptionを定義してハンドリングすることにします。次のように例外とそれを投げるための関数を用意します。

class TerminatedException(Exception):
    pass


def raise_terminated():
    raise TerminatedException

アプリケーションのエントリポイントでsignalを利用してSIGTERMを検知したらTerminatedException投げるようにします。

import signal
from fpl import Application, raise_terminated

if __name__ == '__main__':
    signal.signal(signal.SIGTERM, raise_terminated)
    channel = 17
    Application.create(channel).run()

テスト

GPIOをIOInterfaceで抽象化したので、テストでは次のようなモッククラスを実装して使います。テスト用に便利プロパティも持たせます。

from typing import List, Tuple

from fpl._io import IOInterface


class MockIO(IOInterface):

    def __init__(self):
        self.setup_out_channels: List[int] = []
        self.output_channel_histories: List[Tuple[int, int]] = []
        self.finalized = False

    def initialize(self) -> None:
        raise NotImplementedError

    def setup_out(self, channel: int) -> None:
        self.setup_out_channels.append(channel)

    def output_high(self, channel: int) -> None:
        self.output_channel_histories.append((channel, 1))

    def output_low(self, channel: int) -> None:
        self.output_channel_histories.append((channel, 0))

    def finalize(self) -> None:
        self.finalized = True

例えばLEDクラスのテストでは次のように使っています。

from fpl._led import LED

from ._helper import MockIO


def test_turn_on():
    channel = 17
    io = MockIO()
    LED(channel, io).turn_on()
    assert io.setup_out_channels == [channel]
    assert io.output_channel_histories == [(channel, 1)]


def test_turn_off():
    channel = 17
    io = MockIO()
    LED(channel, io).turn_off()
    assert io.setup_out_channels == [channel]
    assert io.output_channel_histories == [(channel, 0)]

パッケージング

ひとまず形になったので、プロダクト化を意識してOSイメージを作成します。ベースにするOSイメージはRaspberry PI OS Liteの2021-05-07を使用します(ハマり防止のため)。

自動リサイズ対策とSSHの有効化

Raspberry PI OSのデフォルトイメージは初回起動時に自動的でSDカードの容量までイメージを拡張します。今回はこの挙動を止めて置きたい(理由は後述)ので調整します。まず、SDカードにイメージを焼いてPCで /boot 領域をマウントします。/boot/cmdline.txtというファイルがありますので、エディタで末尾の「init=/usr/lib/raspi-config/init_resize.sh」を削って保存します。

console=serial0,115200 console=tty1 root=PARTUUID=9730496b-02 rootfstype=ext4 elevator=deadline fsck.repair=yes rootwait quiet

SSHでログインできるように /boot/ssh ファイルも置いておきます。

環境設定とアプリケーションのデプロイ

調整したSDカードで起動し、SSHログインしたらローカル環境から「bin/sync.sh」でアプリケーションファイル一式を転送します。実行環境の構築と同様にbin/setup-os.sh、bin/initialize.shを実行してアプリケーションが動かせるようにします。
次にbin/setup-systemd.shを実行してsystemdにサービス登録、自動起動を有効化します。

$ sudo bin/setup-systemd.sh

ファイルシステムを書き込み保護する

Raspberry PIはSDカードで運用しますが、SDカードのNANDフラッシュは書き込み回数に上限があり、何も対策せずに長期間使い続けると壊れます。

幸い、あるバージョンからraspi-configでOverlayFSを利用した書き込み保護機能が搭載され、有効にすることでDiskへの書き込みを0にできます。今回bin/enable-overlayfs.shとしてスクリプト化してありますので、実行します。

$ sudo bin/enable-overlayfs.sh

再起動してファイルシステムがOverlayFS化されていることを確認します。mountを実行して、次のように「overlay on / type overlay」となっていればOKです。

$ mount
sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,relatime)
proc on /proc type proc (rw,relatime)
udev on /dev type devtmpfs (rw,nosuid,relatime,size=435432k,nr_inodes=108858,mode=755)
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000)
tmpfs on /run type tmpfs (rw,nosuid,noexec,relatime,size=94640k,mode=755)
overlay on / type overlay (rw,noatime,lowerdir=/lower,upperdir=/upper/data,workdir=/upper/work)
(以下略)

また、先程systemdで自動起動を有効にしたので、再起動後にLEDが点滅することを確認します。

アプリケーションのOSイメージを作成する

アプリケーションをデプロイして設定を終えたSDカードからOSイメージを作成します。

ここで単純にddコマンドを利用してディスクイメージを作るとSDカードの容量と同じサイズのイメージファイルになってしまいます。

一見問題なさそうに見えますが、実は市販のSDカードは同じ容量のサイズでカテゴリ化されていてもメーカーの違いなどで若干実サイズに差があります。8GBのSDカード同士だから大丈夫…と油断すると焼けないことがあります。

今回はディスクサイズを自動拡張させずに2GB程度にしてありますので、このサイズで切り出します。まずはSDカードのパーティション情報をfdiskで確認します。

$ sudo fdisk -l /dev/mmcblk0
ディスク /dev/mmcblk0: 14.43 GiB, 15485370368 バイト, 30244864 セクタ
単位: セクタ (1 * 512 = 512 バイト)
セクタサイズ (論理 / 物理): 512 バイト / 512 バイト
I/O サイズ (最小 / 推奨): 512 バイト / 512 バイト
ディスクラベルのタイプ: dos
ディスク識別子: 0x9730496b

デバイス       起動 開始位置 最後から  セクタ サイズ Id タイプ
/dev/mmcblk0p1          8192   532479  524288   256M  c W95 FAT32 (LBA)
/dev/mmcblk0p2        532480  3661823 3129344   1.5G 83 Linux

/dev/mmcblk0p2の終了位置「3661823」を使用します。これをcountで指定してddコマンドを実行します。これでパーティションの最後までで切り出してイメージファイル化できます。

$ sudo dd if=/dev/mmcblk0 of=fullpower-led.img bs=512 count=3661823
3661823+0 レコード入力
3661823+0 レコード出力
1874853376 bytes (1.9 GB, 1.7 GiB) copied, 30.124 s, 62.2 MB/s

作成したイメージを改めてSDカードに書き込みます。

$ sudo dd if=fullpower-led.img of=/dev/mmcblk0 bs=8M
223+1 レコード入力
223+1 レコード出力
1874853376 bytes (1.9 GB, 1.7 GiB) copied, 115.12 s, 16.3 MB/s

Raspberry PIに差し込み動作を確認します。動いている様子はこちらです。

全力でLチカ

以上で、全力でLチカ完成です。

まとめ

以上、「全力でLチカ」をやってみました。もっとEnterpriseFizzBuzzのように突き抜けてみたいところでしたが、これが精一杯でした。

明日は私から名前の半分を持っていったゾネスさんです。

この記事が気に入ったらサポートをしてみませんか?