全力で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チカ」をイメージしてみます。先程整理した項目の逆をまとめてみます。
コードが構造化されている・テストもある。
実行が自動(サービス化されている)。
プロダクト化を意識している。
良い感じに全力っぽくなってきました。予算とスケジュールの都合上、現実的な範囲でこれらを満たしていきたいと思います。
具体的にはこのあたりをスコープとします。
コードが構造化されている・テストもある。
実行が自動(サービス化されている)。
プロダクト化を意識している。
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チカ」をやってみました。もっとEnterpriseFizzBuzzのように突き抜けてみたいところでしたが、これが精一杯でした。
明日は私から名前の半分を持っていったゾネスさんです。
この記事が気に入ったらサポートをしてみませんか?