関東甲信がついに梅雨明けしましたが、「とてもじゃないけど外出できる暑さじゃない」とウンザリしている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通信システムが完成したので、このシステムをベースに機能を拡張できます。
すでに私の環境では様々な機能を拡張しているので、後日紹介したいと思います。







コメント