nyanpyou Note

主な目的は調べたり作ったりしたプログラミング備忘録(予定)

そうだ温度計を作ろう その2

そんなわけでその2ソフトウェア編です。

nyanpyou.hatenablog.com

構成の検討

初期案

温度計を作るにあたって、最初に考えた仕組みはこんな感じ。

f:id:nyanpyou106:20210208110241p:plain
温度計構成初期案
超単純で、温度センサーから得た情報を適当なファイルに書き出しておき、その中身をJavaScriptで読み込んで、リアルタイムにHTMLに反映させようという考え。

残念ながらこの方法は調べてみたところすぐに駄目だと判明した。全く知らなかったのだが、JavaScriptにはローカルにあるファイルを勝手に読むという機能が存在しないらしい。何かファイルを読ませようとした場合、いちいちファイルを手動で選択して開くという操作が必要になってしまう。これではリアルタイム更新の温度計として成り立たないため、別の方法を探すことにした。

改善案

要はJavaScriptに何かしらの方法で温度のデータを送信することが出来ればよいので、調べた結果次のような仕組みが良さそうだとわかった。

f:id:nyanpyou106:20210209145950p:plain
温度計構成改善案
どうやらサーバーからデータを送信し、それをJavaScriptで受けることが可能で、これを用いることでリアルタイムにHTMLの表示を更新することができるらしい。これは非同期通信と呼ばれるものらしい。身近な例でいうと、GoogleMapで画面上に映る地図の範囲を動かすと、読み込み動作を挟まずに地図が更新されていくが、それはこの非同期通信によって実現されているそうだ。

サーバー・クライアント間通信についての知識は全くないが、他に良さそうな選択肢が見つからなかったので、とりあえずこの構成で作成してみることに決定した。

また、せっかくなので室温以外に外気温も何かしらの方法で取得して画面に表示できればいいなと思い、その機能も実装することにした。

具体的な話

(正直な話、理解しきれていない部分が沢山あるため、ふわっとした記述が多い。
文章に書き起こそうとすると、嫌でも自分の浅さと向き合う必要があるためとてもつらい…つらくない?)

温度取得

室温

SHT31から温度データを取得する方法については、そのものずばりを記事にしてくれている方がいたため、有難く使わせて頂くことにした。

qiita.com

上記記事では周期的連続測定コマンドを使用しているが、今回は一定時間ごとに温度読み出しスクリプトを呼び出し、HTMLにデータを送る形のため、単発測定コマンドに変更している。単発測定コマンドを使用する場合は、クロックストレッチ設定を有効にするか選べるが、これは有効にするとセンサーからの出力の違いで、測定が完了して読み出し可能になったかどうかを判断できるようになり、測定時間を予想してsleep()で待たなくて良くなるらしい。

SHT31-DIS 仕様書(日本語)

参考:温湿度センサーSHT31をクロックストレッチで使う時の備忘録

今回は短期間で頻繁にアクセスしたいわけではないので、適当なsleep()を入れる方式にした。

# 温度取得コード抜粋
def get_temperature_data():
    """SHT31から温度データを取得し、温度をJSONで返す"""
    i2c = smbus.SMBus(1)
    i2c_addr = 0x45

    i2c.write_byte_data(i2c_addr, 0x24, 0x00)
    # 測定終了まで待つ
    time.sleep(1)
    data = i2c.read_i2c_block_data(i2c_addr, 0x00, 6)
    temperature = tempChanger(data[0], data[1])
    return {"room": str(temperature)[0:4]}

外気温

今回の構成では直接の取得はできないため、どこか別の所で公開されている値を引っ張ってくることにする。

ハードコーディングで現在地を設定して、気象庁などの気温を公開しているwebページやAPIから値を取得することも考えたが、それでは面白くないし汎用性もないので、今温度計がある場所を自動的に調べて外気温を取得させようと考えた。

現在地取得の方法だが、まず最初に考えたものは「ラズパイのGPSモジュールを使う」方法。 例えばこういうやつ

www.switch-science.com

しかしこれは採用を見送った。できる限り温度計をコンパクトに収めたいという願望があったことに加え、既に温度センサーを繋げることが確定しているため、追加でモジュールを接続するとなると、ケース作成で苦労するだろうと思ったからだ。あとお高い。
(改めて見てみると屋外で使用するためのモジュールらしい。どっちにしろ今回は使えなかった。)

次に考えた方法は、「GeoLite2を使って温度計の現在のIPアドレスから現在地(何市に存在しているか)を割り出す」方法。

dev.maxmind.com

GeoLite2はIPアドレスと地域情報を紐づけたデータベースで、IPアドレスから国名や都市名を知ることができる。そしてなんと無料で使える。

当初はこれを使う予定だったが、よくよく調べてみると2019年末に規約の変更があり、商用利用不可の無料版GeoLite2でもユーザー登録が必要になったらしい。

www.tabimoba.net

これだけならまだよかったのだが、公式ページの利用規約を眺めているとData Privacyの項目に

データベースを最新の状態に保ち、更新されたバージョンがリリースされてから30日以内に古いバージョンのデータベースを破棄する必要があります。
(DeepL翻訳)

という記載を発見。どうやら逐一更新作業が必要らしい。仮に古いバージョンのまま使用を続けたとしても、個人利用ならば恐らく文句は言われまい。しかしあえてリスクを負って、更新作業をせずに使い続ける理由も無かったので、仕方なくこの方法も断念。

真面目に新バージョンが公開されるたびに更新作業を行うというパターンもあるが、正直保守作業があまりにも面倒である。

さらにこれら2つの手法にはもう1つ問題があった。例えば気象庁のHPには全ての市の外気温が掲載されているわけではないため、取得した位置情報ずばりの外気温が掲載されていなかった場合に、どの市の外気温を代わりに取得するのかを決めておく必要がある。これは地味に手間だ。

最後に考えた方法は、「Google検索から取得する」方法。Google検索にて、「現在地 天気」と検索すると、検索結果に現在地の外気温が表示される。こいつをPythonからスクレイピングして取得する方法である。

f:id:nyanpyou106:20210221093954p:plain
「東京 天気」で検索した例
「東京」を「現在地」にすると驚きの精度で現在地の外気温を表示してくれる
つまり面倒な現在地取得を全部Google先生にやってもらうことにしたのだ。なんてことはない方法だが、この方法を思いついた瞬間は正直天才かと思った。小さいことでも自己肯定感を得ることは大事。多分。今回はこの方法で外気温を取得している。

但し、ブラウザ上で検索して表示した時と、Pythonからスクレイピングした時でHTMLタグのclass名が異なっている場合があるため、注意が必要。さらに、どうも検索結果に外気温が掲載されていない場合があることが分かったため、外気温が見つからなかった場合は何もしない(外気温の表示を更新しない)処理を追加しておいた。

ローカルサーバー

既に温度取得でPythonを使うことは決定していたので、サーバー立てもPythonで実現できれば楽だ。そこで早速Pythonでのサーバーの立て方を調べてみた。その結果、少なくとも以下の4種類が存在することが分かった。

  1. 標準ライブラリのhttp.serverを使う
  2. Djangoを使う
  3. Flaskを使う
  4. Bottleを使う

本記事を執筆時点で改めて調べてみると、どうやらPythonのWebフレームワークにはもっとたくさん種類があるようだが、温度計の作成に取り掛かった時点で候補に挙がったのは上記4つだった。DjangoやFlaskは、中身を知らないけど名前だけは聞いたことはあるくらい有名なフレームワークである。

しかし、今回はこの中からBottleを選んだ。というのも、どうもDjangoやFlaskは便利で何でもできる分、学習コストが高いらしい。反対にBottleは前者2つほどの汎用性は無いもののシンプルで、ファイルも軽量(なんとbottle.pyの1ファイルのみ)という事が決め手になった。

f:id:nyanpyou106:20210209140943p:plain
公式のドキュメントもこれなら目を通せそうだと思える分量
(https://bottlepy.org/docs/dev/)
正直に言うと、サーバー部分以外にも使ったことのない技術や、やったことのない作業が多かったため、出来る限りそれぞれの学習コストは軽くしたかった、という理由もある。消極的ではあるが、あまりにややこしくて取り掛かることが億劫になってしまったり、途中で挫折してしまい完成できずに終わる、という事態は一番避けたかった。そのため、あまり深い事(汎用性とか)は考えずに、この部分に関してはとにかく分かりそうなものからやり始めよう、という姿勢で取り組むことにした。

本当に知識が全くなかったため、最初はただサーバーを起動して、そこにHTMLと画像ファイルなどを一緒に置いておけば、HTMLに各種ファイルを読み込んで画面に表示できると思っていた。しかしどうもそれだけではだめで、静的ルーティングをコードに書いておかないと、画像や動画等のファイルへアクセス出来ないらしい。今回は動画や画像を入れたフォルダとCSSを入れたフォルダへのルーティングを追加した。

kitabatech.blogspot.com

# send_temperature.py
# 静的ファイルへのルーティング部分
@route("/img/<file_path:path>")
def server_static(file_path):
    return static_file(file_path, root="./img")

@route("/static/<file_path:path>")
def css(file_path):
    return static_file(file_path, root="./static")

非同期通信

これも調べたところ、実現方法は以下の3パターンがありそうだということが分かった。

  1. Ajax
  2. WebSocket
  3. SSE

今回選択したものはSSE(Server-Sent Events)。これも理由は単純で、一番簡単に実装できそうだったから。但し、参考資料が予想外に少なく困った。何とか探して参考にした記事は以下。

note.com

labs.gree.jp

ja.javascript.info

すんなり実装出来ればよかったのだが、問題も発生した。SSEを組み込むと、なぜかHTML上に配置した動画の読み込みが途中で止まってしまう(数秒再生されて停止してしまう)ことが分かったのだ。後述するが、この動画はデザインの問題で配置する必要があったものである。この問題は、例えば以下のような書き方をした場合に発生した。

# send_temperature.py
# SSE部分
@route("/sse")
def sse():
    while True:
        response.headers['Access-Control-Allow-Origin'] = "*"
        response.headers['Content_Type'] = "text/event-stream"

        #temperature_json = sht31.get_temperature_data()
        temperature_json = {"room":"1"}
        outside_temperature = scraping_outside_temperature.scraping_currentlocation_outside_temp()
        temperature_json["outside"] = outside_temperature
        temperature_json_str = json.dumps(temperature_json)

        yield "data: {}\n\n".format(temperature_json_str)

原因が分からず結構困ったが、ブラウザのネットワークモニターを眺めた限りでは、SSEの処理が始まると他のファイルのDLが止まってしまうようだった。

f:id:nyanpyou106:20210214164310p:plain
SSEの下のcaptain_movie.mp4がずっとpendingのまま

つまりSSEで送られてくる情報を受け取る、という処理にかかりっきりになってしまっているのではないかと考え(HTTPは一度に1つのリクエスト/レスポンスしかできないと見た覚えがあったため)、上記コード中のwhile Trueを削除しyieldをreturnに変更したところ、動画読み込みが途切れることはなくなり、サーバーから継続的にデータ送信が可能になった。

# send_temperature.py
# SSE部分
@route("/sse")
def sse():
    response.headers['Access-Control-Allow-Origin'] = "*"
    response.headers['Content_Type'] = "text/event-stream"

    temperature_json = sht31.get_temperature_data()
    outside_temperature = scraping_outside_temperature.scraping_currentlocation_outside_temp()
    temperature_json["outside"] = outside_temperature
    temperature_json_str = json.dumps(temperature_json)

    # 初回も10秒待たせると読み込み途中の動画も10秒止まってしまうので、初回は待ち時間なしにする
    # FIXME 正しい処理をしているとは思えない うまいやり方を知りたい
    first = True
    if first:
        first = False
    else:
        time.sleep(10)
    return "data: {}\n\n".format(temperature_json_str)

while Trueを削除した場合、一度実行した後にサーバーとの接続が途切れてしまうが、SSEには接続が途切れると自動的に再接続を試みる機能が搭載されているらしく(参考:Server Sent Event:再接続) 、これのおかげで見た目上上手く動くようになったと思われる。しかしコメントとしてコード内にも記載しているが、正しい処理をしているとは正直思えないため、上手い方法があれば知りたいところである。

自分の予想が正しければ、SSEの通信に入る前に全てのリソースを読み込み切れば問題なく動くはずなので、これら

JavaScript初心者脱却計画(addEventListenerについて)|Kuu|note

Window: load イベント - Web API | MDN

を参考にonloadイベントを使って読み込み順を制御しようとも試みたが、どうも上手くいかなかった。

画面表示

マリン船長要素を入れつついい感じに作った(雑)

www.youtube.com

まず、メインの温度表示画面は全画面表示にしたいため、それを実現する仕組みが必要だった。JavaScriptで強制的に全画面表示にできればよかったのだが、調べたところファイル読み込みと同様これも不可で、必ずユーザーからの操作が必要になるらしい。

そこで、起動画面を1枚挟むことにした。ここに全画面表示を実行するためのボタンを用意し、ボタンを押すことで全画面表示の温度表示画面に移行するようにする。(参考:[HTML5] フルスクリーンの開始と解除)しかし、ただボタンを置くだけでは殺風景で寂しいので、動画を配置してみることにした(これが上述のデザインの関係で配置したかった動画である)。

youtu.be

温度計画面には、SSEでPythonから受け取った室温と、スクレイピングGoogle検索から取得した外気温、キャラクターの台詞、キャラクターの画像、全画面表示を終了するためのボタンを配置することにした。また、これだけだと画面が余ってしまったため、ついでに時刻表示もつけることにした。時刻はJavaScriptでラズパイの時計から読み込む。(参考:現在の日時を表示する)

また、そのまま温度を表示するだけでは面白くないので、現在の室温によってキャラクターの台詞と画像が変化するようにした。

f:id:nyanpyou106:20210219183711p:plain
画像と台詞の温度変化
左から~15℃、16~19(26~29)℃、20~25℃、30℃~

完成した温度計画面は以下。

f:id:nyanpyou106:20210220141007p:plain
温度計画面(全体)

各種素材は、以下のものを使わせて頂いた。

最終回へ続く

色々書いていると滅茶苦茶長くなってしまったが、最後は温度計の外観の話。