関東甲信がついに梅雨明けしましたが、「とてもじゃないけど外出できる暑さじゃない」とウンザリしているgoldear@JiN_C125です。
今回は前回作成したクライアント側の対向となるサーバー側を実装します。2つセットで遠隔制御できるようになるので、頑張って作っていきましょう。

目次
システム全体像
前回に引き続き簡単なシステム構成図です。サーバー側にはMQTT Broker・Flaskで構成してブラウザ上からコマンドを送信・ファン状態を監視できるようにします。またTailScaleを起動しておくことで、外出先から自由に制御できるようにします。
TailScaleは以前に紹介したことがあるので、詳細はそちらの記事を参照してください。公式ページにLinuxディストリビューション向けの導入手順も記載されていますので、非常にお手軽で簡単に導入できます。

機材表
サーバー実装で必要になる機材一覧表です。PoE (Power over Ethernet)を利用したいので少し価格は上がってしまいますが、電源を別で用意できるっていう方はそれでもOKです。またRaspberry Pi 5がそこそこ熱くなるので外部から冷却するためのPCファンがあると安心できます。
メーカー | 品名 | 購入先 |
---|---|---|
RaspberryPi財団 | Raspberry Pi 5 8GB | Amazon |
Lexar | NM620 M.2 2280 PCIe SSD | Amazon |
UGREEN | M.2 SSD 外付けケース SATA/PCIe 両対応 | Amazon |
オウルテック | ヒートシンク OWL-SSDHS03PS | Amazon |
Waveshare | Raspberry Pi 5 用 PoE/PCIe0M.2 SSD変換HAT+ | SwitchScience |
FOXNEO | 2.5GbE+PoEスイッチングハブ | Amazon |
導入
早速、Raspberry Pi 5 のセットアップを順に進めていきます。
Image作成
まず、Raspberry Pi OSをNVMe SSDにセットアップします。今回はPCIe SSD を使用する理由として、24時間稼働を前提としているためmicroSDカードでは耐久性に難アリのためです。「そんなわけ無いだろ」と考えていた頃もありましたが、現に壊れて起動しなくなった経験があります。
セットアップにはRaspberry Pi Imager を使用します。こちらも以前に紹介したことがあるので、詳細はそちらを参照してください。補足としてGPIOを使用する予定がない方はRaspberry Pi OS に限りません。UbuntuでもOKです。
GUIは使用するつもりはないので、今回は Raspberry PI OS Lite (64bit) を選択します。

Mosquitto Broker & Client
簡単に説明すると、Mosquitto Broker は MQTTメッセージの受信と送信を仲介するサーバー機能を持つアプリです。一方 Mosquitto Client は MQTTメッセージをPublish(送信)・Subscribe(受信)するクライアント機能を持つアプリです。
サーバーとなるRaspberry Pi 5 に両方のアプリをインストールする理由ですが、あくまでも Mosquitto Broker は仲介役なので、Brocker自身がPublish・Subscribeする事はありません。なのでサーバーでもPublish・SubscribeするにはClientとなるアプリが必要となるわけです。
以下のコマンドでMosquitto Broker & Client をインストールします。
mosquitto broker の設定をします。今回はMQTTメッセージのやり取りにユーザー認証を使用するので設定します。追加作成の場合は -c オプションを外します。
設定したパスワードは以下に暗号化されて保存されます
<\user_name\>:<\hash\>
さらに設定ファイルを修正します。今回はTLSは使用しないので無効化して、さらにユーザー認証を有効化します。以上でmosquitto broker の設定は完了です。
# Disable TLS
listener 1883
require_certificate false
# Enable cert User/Pass
allow_anonymous false
password_file /etc/mosquitto/pwfile
MQTT Explorer
すこし脱線しますが、MQTTを使ったシステムの開発ではメッセージを自由にPublishできると、動作確認が非常に楽になります。一応、さきほどインストールしたmosquitto-clientには mosquitto-pubというコマンドが用意されているのですが、いちいちコマンドで叩くのが面倒なのでGUIでpublishできるツールMQTT Explorerを使用します。
使い方は簡単で、mosquitto broker のIPアドレス/ポート番号、先ほど作成したユーザ名/パスワードで接続できます。
broker がやりとしているメッセージを自動で取得してきます。また指定したtopicにメッセージを送信することもできるので、デバッグがかなり捗ります。
Flask
Flask(フラスク/フラスコ)は、Pythonで作られた軽量なWebアプリケーションフレームワークです。シンプルで柔軟性が高く、小規模なアプリから本格的なサービスまで幅広く使われています。セットアップが簡単なので私はもっぱらこいつを使ってます。TailScaleとの組み合わせで利便性が向上します。
最近のdebian系は aptとpipで明確にパッケージを分離しているようなので、いきなり pip install するエラーが出ます。適当な仮想環境を作って作業します。所詮、仮想環境でいくらでもやり直しが効くので、とりあえずえいやで作ってみましょう。
$ source .venv/bin/activate
(.venv) $ pip install flask
Bootstrap5
Bootstrap5は、WebサイトやWebアプリのデザインを効率よく作成できるCSSフレームワークです。せっかくなので見た目にもこだわりたいので、こちらを使用します。CDN版とダウンロード版が用意されていますが、個人的に外部要因に依存したくないので、ダウンロード版を使用します。使用方法は後述します。

プログラミング
基本機能はPythonで実装して、Webに関しては HTML+JavaScript+CSSで実装します。
ツリー構造
Flaskを中心として以下のようなツリー構造で作成していきます。
vflask/ ├── .venv/ ← 仮想環境(python -m venv .venv で作成) │ └── bin/, lib/, etc... ├── app/ ← Flask アプリケーション本体 │ │── config.py ← アプリの設定ファイル │ └── mqtt_client.py ← MQTTメッセージのpub/sub制御 ├── templates/ │ └── index.html ├── static/ │ │── css │ │ └── <Bootstrap5 cssファイル群> │ │── icon │ │ └── <PWA用アイコン画像> │ └── js │ └── <Bootstrap5 jsファイル群> └── server.py ← Flask起動スクリプト(Flaskのエントリポイント) └── manifest.json ← PWA用ファイル └── sw.js ← PWA用ファイル
補足としては、先ほどダウンロードしたコンパイル済みのBootstrap5はcss/jsにそれぞれ格納します。またFlaskはスマートフォンから使用することが想定されるので、webアプリをPWA化します。webアプリを実際のアプリのように使用できるようにします。
以下のページを参考にPWA対応させます。ページ作成者様に感謝します。

ソースコード
#!/usr/bin/env python3 # encoding: utf-8 # server.py # import library from flask import Flask from flask import render_template from flask import send_file from flask import request from flask import jsonify from app import mqtt_client import app.config as config # Create instance app = Flask(__name__) mqtt_client.init_mqtt() @app.route("/") def index(): return render_template("index.html") @app.route('/manifest.json') def serve_manifest(): return send_file('manifest.json', mimetype='application/manifest+json') @app.route('/sw.js') def serve_sw(): return send_file('sw.js', mimetype='application/javascript') @app.route("/command", methods=["POST"]) def handle_command(): data = request.json cmd = data.get("cmd") val = data.get("val") mqtt_client.send_command(cmd, val) return jsonify({"result": "ok", "cmd": cmd, "val": val}) @app.route("/status", methods=["GET"]) def status(): mqtt_client.send_command("fan_status", "None") return jsonify(mqtt_client.get_latest_status()) if __name__ == "__main__": app.run(host=config.FLASK_IP, port=config.FLASK_PORT, debug=True)@app.route('/sw.js')
Flask起動用スクリプトです。debug=Trueとしているので、起動確認ができた後はdebug=Falseに変更してください。
# Config MQTT Broker MQTT_BROKER = "192.168.11.1" MQTT_PORT = 1883 MQTT_PUB_TOPIC = "fan/cmd" MQTT_SUB_TOPIC = "fan/stat" MQTT_USERNAME = <username> MQTT_PASSWORD = <password> # Config Flask FLASK_IP = "0.0.0.0" FLASK_PORT = 5000
MQTTとFLASKの設定ファイルです。注意点として FLASK_IP=0.0.0.0 にしておかないとTailScaleでアクセスできないので注意です。その他に関しては各々の環境に合わせて設定してください。
from datetime import datetime import threading import json import paho.mqtt.client as mqtt import app.config as config # init latest_status = {"fan_state": "unknown", "fan_speed": 0} latest_status_time = datetime.min client = mqtt.Client() def on_connect(client, userdata, flags, rc): print("Connected to MQTT broker") client.subscribe(config.MQTT_SUB_TOPIC) def on_message(client, userdata, msg): global latest_status global latest_status_time try: latest_status = json.loads(msg.payload.decode()) latest_status_time = datetime.now() print("Received status:", latest_status) except Exception as e: print("Error decoding status:", e) def init_mqtt(): client.username_pw_set(config.MQTT_USERNAME ,config.MQTT_PASSWORD) client.on_connect = on_connect client.on_message = on_message client.connect(config.MQTT_BROKER, config.MQTT_PORT) threading.Thread(target=client.loop_forever, daemon=True).start() def send_command(cmd, val=None): payload = {"cmd": cmd} if val is not None: payload["val"] = val msg = json.dumps(payload) print(f"[MQTT PUBLISH] {config.MQTT_PUB_TOPIC} -> {msg}") client.publish(config.MQTT_PUB_TOPIC, msg) def get_latest_status(): global latest_status global latest_status_time timeout_sec = 15 # Check Disconnect delta = (datetime.now() - latest_status_time).total_seconds() if delta > timeout_sec: return { "fan_state": "disconnected", "fan_speed": 0 } raw = latest_status percent = int(raw["fan_speed"] * 100 / 65535) return { "fan_state": raw["fan_state"], "fan_speed": percent }
MQTT制御スクリプトです。MQTT初期化処理、pub/sub制御、ファン状態の取得処理を記述しています。
async function sendCommand(cmd, val=null) { const response = await fetch("/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cmd, val }) }); const data = await response.json(); console.log("Command response:", data); } function updateSpeed(val) { document.getElementById('speedDisplay').innerText = `設定速度: ${val}%`; sendCommand("fan_speed", val); } async function getStatus() { try { const res = await fetch('/status'); const json = await res.json(); document.getElementById("statusText").innerText = `State: ${json.fan_state}, Speed: ${json.fan_speed}%`; } catch (e) { document.getElementById("statusText").innerText = "取得エラー"; } } getStatus(); setInterval(getStatus, 3000);
HTMLからPythonスクリプトを起動させるためのJavaScript記述です。言うなれば仲介ファイルです。
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width,initial-scale=1.0"> <link rel="icon" href="{{url_for('static', filename='icons/icon_144x144.png')}}" type="image/png"> <link rel="icon" href="{{url_for('static', filename='icons/icon_192x192.png')}}" type="image/png"> <link rel="icon" href="{{url_for('static', filename='icons/icon_512x512.png')}}" type="image/png"> <link rel="apple-touch-icon" href="{{url_for('static', filename='icons/icon_144x144.png')}}" type="image/png"> <link rel="apple-touch-icon" href="{{url_for('static', filename='icons/icon_192x192.png')}}" type="image/png"> <link rel="apple-touch-icon" href="{{url_for('static', filename='icons/icon_512x512.png')}}" type="image/png"> <link rel="manifest" href="/manifest.json"> <!-- <link rel="stylesheet" href="static/css/style.css"> --> <title>GenIoT</title> <link rel="stylesheet" href="{{url_for('static', filename='css/bootstrap.min.css')}}"> <link rel="stylesheet" href="{{url_for('static', filename='css/style.css')}}"> <script> if ('serviceWorker' in navigator) { window.addEventListener('load', function() { navigator.serviceWorker.register("/sw.js").then(function(registration) { console.log('ServiceWorkerが正常に登録されました: ', registration.scope); }, function(err) { console.log('ServiceWorkerの登録に失敗しました: ', err); }); }); } </script> </head> <body data-bs-theme="dark"> <script src="{{url_for('static', filename='js/bootstrap.bundle.min.js')}}"></script> <script src="{{url_for('static', filename='js/control.js')}}?v=2"></script> <div class="container mt-4"> <h2 class="text-light">Control PWM Fan</h2> <!-- ON/OFF --> <div class="mb-3"> <button class="btn btn-success" onclick="sendCommand('fan_on')">Fan ON</button> <button class="btn btn-danger" onclick="sendCommand('fan_off')">Fan OFF</button> </div> <!-- Set Fan Speed --> <div class="mb-3"> <label class="form-label text-light">速度設定(0〜100%)</label> <input type="range" class="form-range" min="0" max="100" step="1" id="fanSpeed" onchange="updateSpeed(this.value)"> <div id="speedDisplay" class="form-text">現在の速度: 50%</div> </div> <!-- Get Fan Status --> <div class="container mt-4"> <div class="card text-white bg-secondary mb-3" style="max-width: 20rem;"> <div class="card-header">ファンの状態</div> <div class="card-body"> <h4 class="card-title" id="statusText">読み込み中...</h4> </div> </div> </div> </div> </body> </html>
動作確認
以下のコマンドでFlaskを起動して動作を確認してみます。
config.pyで設定したIPアドレスをブラウザに入力して、以下のようなに表示されればHTMLは問題ないです。
Fan ON/OFFを押して、クライアント側のPWMファンを制御できるか確認してください。この時点で制御できない場合はコンソール画面にログが表示されるので、該当箇所を修正してください。
まとめ
今回はMQTT通信でPWMファンを遠隔操作するためのシステムの「サーバー」側の実装が完了しました。これでMQTT通信システムが完成したので、このシステムをベースに機能を拡張できます。
すでに私の環境では様々な機能を拡張しているので、後日紹介したいと思います。
コメント