KOTET'S PERSONAL BLOG

#log Raspberry Piで家の物を管理したらいろいろ勉強になった

Created: , Last modified:
#log #tech #linux #golang #react

これは1年以上前の記事です

ここに書かれている情報、見解は現在のものとは異なっている場合があります。

今年に入ってから全く記事を書いていなかったらしい。 昨年も 3 月以降は更新がない。 これは主に卒業研究で忙しかったことが原因だ。

愛知県立大学情報科学部は、いろんな事情で 1 月の初めに卒論を提出することになっていた。 研究がうまくいっていなかったので、正月はただ引きこもって泣いて過ごしていた。 結果的に卒業できそうなのでいいのだが、今まで生きてきた中で一番暗い新年だったと思う。 正直に言うと、いまだに立ち直れていない。 生活リズムは壊れたままだし、今後のことを考えると体に力が入らなくなる。 そういうわけで今はリハビリ的時期だと考えて過ごしている。 研究中はできなかった趣味プロジェクトを行い、プログラミングの楽しさを再確認している。

ブログの更新頻度が落ちた原因は他にもある。 自分の中で記事を書くことのハードルが上がっていることだ。 研究で心がへし折られているのもあって、だいぶ自己評価が低くなっており、 そのため自分がインターネットに放流する情報に価値を感じられなくなっている。 過去の記事も可能な限り消したいと思っているが、ギリギリのところで持ちこたえている感じだ。 ちょくちょく自分の記事が活用されているところを観測できているので無価値というわけはないのだが、 それで納得しない自分がいる。 ここで「きっと承認が足りないから納得できないのだろう」と考えるとおかしな方向に突き進んでいくのだろう。

というわけで、ブログ更新のハードルを下げていろいろ書いていこうと思う。 ここは自分の日記帳だ。 役に立たないことを書いたって別にいいはずだ。 思ったことを自由に書くことで気分を落ち着けることもできるかもしれない。

今回は、家の中の物を管理するシステムを作ったときに新しく学んだことをだらだら書いてみる。

前提

半年以上前に買った Raspberry Pi Zero W の活用法をずっと考えていた。 自分は実家で家族と暮らしているため、自分についての情報を家族と共有できると便利だなと思った。 そこで、自分のステータスランプを作ることにした。 親がだいぶ前のさらに前に Raspberry Pi で遊んでいたときの blinkt! を使って、各種情報を表示する。

いろいろ増やすと管理が面倒なので、Raspberry Pi 側で管理しなければいけないファイルは減らしたい。 そこで、Go でいろいろなサーバーを記述し、それらを全部ひとつのアプリケーションにして送ることにした。 依存ライブラリのことをあまり考えなくていいこともいいところだ。 また、ファイルの埋め込みも言語機能として組み込まれているので、配信する静的ファイルも容易に組み込める。 そのため、Raspberry Pi 側に特に何も用意しなくても、クロスコンパイルしたバイナリを rsync で送ればそのまま実行できる。

blinkt の制御は GPIO を通して行う。 公式のライブラリが Python 製なので、制御部分だけ Python で書いた。 Go で書かれたサーバーが情報を収集して、その情報をもとに Python で blinkt を光らせるという構成だ。

Raspberry Pi Zero にとって Python はそれなりに負荷が大きく、常時 5%程度の CPU 時間を消費している。 Go で書いたサーバーは家庭で発生するアクセスの範囲内ではほぼ無負荷なので結構気になる。 でも Go で blinkt!の制御を行おうとすると割と大変そうなのでとうぶんこのまま。 cgo はギリギリまで使わないでおきたい。

というわけで、自分のステータスランプが完成した。 Habitica から取得した日課の完了状況等が表示されている。 しばらく運用しているが、家族との情報共有が若干スムーズになった気がする。

作りたいもの

せっかく常時起動のコンピュータができたので、他にもサーバー的プログラムを動かしたい。 身近な悩みといえば、自分は物をいつ買ったかなどをよく忘れる。 メモを書き込んだり貼り付けたりすればいいのだが、小さすぎて表現したい内容を書ききれなかったり、 平らな面が少なくてうまく書き込めないことが多い。 そこで、小さなタグを貼り付けて、それを読み込むことでメモを参照できるようにしようと思った。

できるだけ簡単に実装しようと思ったら、QR コードがひとつの選択肢となるだろう。 何らかの方法で QR コードのシールを作って、それを管理したいものに貼り付けるわけだ。 QR コードは iPhone 組み込みのカメラアプリで読み込んで、その内容を確認できる。 使い勝手のことを考えると、スマートフォンさえ持っていれば数ステップで内容の確認ができるのは大きい。

QR コードを採用するとして、どのようなデータを載せるべきだろうか? QR コードにメモの内容を直接載せることができれば、データの保存・管理をする必要がなくなる。 しかし、メモを作るたびに新たな QR コードを生成して、印刷しなければならない。 テプラは QR コードのような画像データを印刷できるらしい。 しかし、調べた感じテプラの制御は簡単ではなさそうだ。 それに、これだけのために新しくガジェットを買うほど需要があるわけでもない。

QR コードにはメモの ID だけ書いておいて、サーバーでメモを管理するようにしたらどうだろう? たとえば小英字の組み合わせを使えば、2 文字で 676 通り、3 文字で 17576 通りの ID が作れる。 家庭で個人的に使うと考えると 3 文字あれば十分だろう。 QR コードにはhttp://kotet.local/q/abcのような ID を含む URL を書き込んでおいて、 それを読み込んでその URL を開くとメモを閲覧、管理するページにアクセスできるという感じだ。 これならどの物品がどの QR コードと対応するかを知らない段階で QR コードを印刷できる。 また、データ長が短く一定になるので、生成した QR コードをきれいに並べて 1 つの画像として印刷できる。 QR コードをまとめて印刷できるので、物理的なモノを用意するコストが下げられるのもメリットだ。

QR コードのシールを作る

というわけで、QR コードを作る。 この部分のコードは公開されている。 雑に作ったのでデフォルト値以外でちゃんと動作するかはわからないが、いろいろパラメータを付けて動かせる。

kotet/home-qr-generator

QR コードの生成についてはqrcodeというそのものズバリな Python ライブラリがある。 パラメータをいろいろ調整する場合は、QRCodeというクラスを生成する。

qr = qrcode.QRCode(
    error_correction=qrcode.ERROR_CORRECT_M, border=0, box_size=box_size
)
qr.add_data(prefix+id, optimize=len(prefix+id))
qr.make()
qr_img = qr.make_image(fill_color=fill, back_color=back)

prefix+idhttp://kotet.local/q/abcのような文字列である。 けっこう自由に作れて便利だと思う。 あとはこの QR コードがどの ID を表しているかわかるようにラベルをつければこんな感じの画像ができる。

これをひたすら並べて正方形の画像を作る。 なんとなく連番の ID を使うのが嫌なので、1 文字目を固定した ID をシャッフルして並べている。 676 通りの QR コードを 4 分割して、1 枚の画像に 169 枚の QR コードを載せた。

この画像はコンビニに行けば 1 枚 250 円でシール紙に写真プリントできる 。 余白ありで印刷しないと端のほうが切れてしまうので注意だ。 約 1 センチ四方の QR コードが 169 枚印刷される。 QR コード 1 つの製作費が 1.3 円になる計算だ。 なかなか低コストに抑えられたと思う。 滲みなくきれいに印刷されていたので、もっと QR コードを小さくして詰め込んでもいいかもしれない。

実際に貼り付けてみた。 なかなか使い勝手のいいサイズだと思う。

クライアント

クライアントは React で作った。 MUI で素直な構造を組むだけでいい感じの UI がほぼ完成するのですごい。 自作ツールはボタンの押し心地とか細かいところでの使い勝手が良くないことが多かったので、 これでちゃんとしたサービスと自前ツールとの差がほぼ埋まった感じがある。 API をあらかじめ決めておけばサーバーは決まったデータを返すものを作っておくだけでテストできる。

ただの掲示板と同じくらい単純な物を作っているはずだが、これを作るだけで 1000 近い依存がダウンロードされ、 数 GB のメモリを消費してビルドが行われる。 重い処理は好きなのでわりと楽しんでやれてはいるが、それはそれとしてどうかしてると思う。

名前解決

最近まで知らなかったのだが、ネームサーバー無しで使える DNS というものが存在する。 マルチキャスト DNS (MDNS) といって、 ローカルネットワーク内でクエリメッセージをマルチキャストすることによって名前解決を行う。 Raspberry Pi OS は{ホスト名}.localというドメイン名でアクセスできるように最初から設定されている。 インストール直後に ssh 接続を行うには以下のようにすればいいわけだ。 これを知るまではがんばってポートスキャンしたりして Raspberry Pi のアドレスを探していた……

$ ssh pi@raspberrypi.local

ホスト名を変えればドメイン名も変わる。 自分のサーバーをkotet.localでアクセスできるようにした。

サーバー

Go で書いた 1 つのサーバーで各種コンテンツの配信をすべて行う。 chi を使って URL ごとにルーティングしていく。

今回は静的ファイルの配信を/static、API の提供を/q/{id}以下で行う。

静的ファイル

作成した静的ファイルを Go で書いたサーバーに埋め込み、配信する。 gzip で圧縮するとサイズが 4 分の 1 程度になるので、埋め込む前に圧縮をしてみる。 全部のファイルをgzip -9で圧縮した。

$ ls server/dist/ -lh
-rw-r--r-- 1 kotet kotet  309  2月 20 02:09 qr.bundle.js.LICENSE.txt.gz
-rw-r--r-- 1 kotet kotet 816K  2月 20 02:09 qr.bundle.js.gz
-rw-r--r-- 1 kotet kotet  156  2月 20 02:09 qr.css.gz
-rw-r--r-- 1 kotet kotet  278  2月 20 02:09 qr.html.gz

バイナリに埋め込まれたファイルにしかアクセスできないとはいえ、 URL から適切なファイルを持ってくる処理を自分で書くのはめんどくさいしこわい。 そこで、statigz というライブラリを使う。 これは nginx と同じルールで圧縮済みのファイルを探してくれるらしい。 もしブラウザが gzip に対応していない場合は解凍してくれるらしい。

//go:embed dist
var fsDist embed.FS

router := chi.NewRouter()
internal.RouteStatic(router, fsDist)
err = http.ListenAndServe(LISTENING_PORT, router)
if err != nil {
	log.Println("fatal server error: ", err.Error())
}
package internal

import (
	"embed"
	"io/fs"
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/vearutop/statigz"
)

func RouteStatic(router chi.Router, fsDist embed.FS) {
	root, _ := fs.Sub(fsDist, "dist")
	router.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
		http.Redirect(rw, r, "/static/", http.StatusMovedPermanently)
	})
	router.HandleFunc("/static", func(rw http.ResponseWriter, r *http.Request) {
		http.Redirect(rw, r, "/static/", http.StatusMovedPermanently)
	})

	srv := statigz.FileServer(root.(fs.ReadDirFS))

	router.Handle("/static/*", http.StripPrefix("/static/", srv))
}

api

こんな感じの API を考えた。

家で使う分には問題ないだろうという発想で雑に作っているところが多々ある。 LAN 内でのみ公開していて、利用者は数人で、アクセス頻度も高くないからこそできる設計なのであんまり参考にしない方がいいかもしれない。

GET /q/{id}

/q/{id}は静的ファイルを配信しているだけだ。 クライアント側で自分の URL をもとにアクセスする ID を決める。 この記事を書いてて思ったが、エラーが発生したときにその内容を送り付けるのはセキュリティ的によろしくない気がする。 まあ家で使う分には問題ないだろう。 気が向いたら直す。

router.Get("/q/{id}", func(rw http.ResponseWriter, r *http.Request) {
	// qr.htmlをそのまま送るだけ
	fsRoot, _ := fs.Sub(fsDist, "dist")
	file, err := fsRoot.Open("qr.html.gz")
	if err != nil {
		rw.WriteHeader(http.StatusInternalServerError)
		fmt.Fprintln(rw, err.Error())
		return
	}
	defer file.Close()
	b, err := ioutil.ReadAll(file)
	if err != nil {
		rw.WriteHeader(http.StatusInternalServerError)
		fmt.Fprintln(rw, err.Error())
		return
	}
	rw.Header().Set("content-encoding", "gzip")
	rw.Write(b)
})
更新処理

更新は Go のデータ構造に対して行う。 更新があるたびにその内容を JSON に marshaling してファイルに書き込んでいる。 更新処理が行われる頻度が十分低いと考えての雑設計だ。 データ量も多くないしいけるいける。 万がいち同時に複数の更新が走ったりしないようにロックをかけて、 さらに書き込み中にサーバーが落ちたときのことを考えてアトミックなファイル更新を行うようにした。 ちゃんとしたデータベースと比べて性能は良くないが、安全性は十分だろう。

以下はアトミックなファイル更新を行う関数である。 rename を使うことで、 ファイルの内容の更新をアトミックに行うことができるf.Write(b)の途中でプログラムが停止しても、元のファイルは変更前の状態に保たれる。

func atomicWrite(b []byte, path string) error {
	f, err := ioutil.TempFile(filepath.Dir(path), "edit")
	if err != nil {
		return err
	}
	_, err = f.Write(b)
	if err != nil {
		return err
	}

	err = os.Rename(f.Name(), path)
	return err
}

削除は論理削除オンリーである。 バグって必要なデータまで消失したらこわいし、家で使う分には手動削除でも問題ないだろう。 データファイルは JSON なので、エディタで開いて更新できちゃうのが便利だ。 いつでもサーバーを止められるからこそできる所業だ。

おわりに

ずっと動かしておける Linux コンピュータがなかなか用意できなかったので、 こういったサーバーが必要になるプログラムを書いた経験があまりなかった。 HTTP ヘッダー等についての知識が身についてなかなか勉強になった。 自分に自信を付けるためにも、今後も家庭の困りごとを解決していこうと思う。