スマートIoTのススメ② MQTT通信でPWMファンを遠隔制御してみた 〜サーバー実装編〜

関東甲信がついに梅雨明けしましたが、「とてもじゃないけど外出できる暑さじゃない」とウンザリしているgoldear@JiN_C125です。

今回は前回作成したクライアント側の対向となるサーバー側を実装します。2つセットで遠隔制御できるようになるので、頑張って作っていきましょう。

スマートIoTのススメ① MQTT通信でPWMファンを遠隔制御してみた 〜クライアント実装編〜
2025年は梅雨らしい気候がなく一気に暑くなって体調がイマイチ、水槽の水温も不安定で困っているgoldear@JiN_C125です。※今回からスマートアクアリウムもスマートホームもまとめて、スマートIoTという括りに変更しました。背景/目的...
スポンサーリンク

システム全体像

前回に引き続き簡単なシステム構成図です。サーバー側にはMQTT Broker・Flaskで構成してブラウザ上からコマンドを送信・ファン状態を監視できるようにします。またTailScaleを起動しておくことで、外出先から自由に制御できるようにします。

TailScaleは以前に紹介したことがあるので、詳細はそちらの記事を参照してください。公式ページにLinuxディストリビューション向けの導入手順も記載されていますので、非常にお手軽で簡単に導入できます。

Tailscale メッシュ型VPN で外出先からTVをリモート視聴してみる
地上波放送、BS/CS放送をPCで試聴している人は、「出張先・帰省先で試聴したいなぁ」と思ったことがあるのではないでしょうか。外出先から自宅LANにアクセスする場合は、VPNを張ってアクセスするのが無難ですが、通信ネットワークに関する知識が...

機材表

サーバー実装で必要になる機材一覧表です。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) を選択します。

Raspberry Pi Zero WH でGPSロガーを作ってみる - 第01回 スタートアップ編
このブログで旅行のお供にGPSロガーアプリをオススメしてきたgoldear@goldear820です。以前から気にはなっていたものの手を出さずにいた自作GPSロガーだったのですが、仕事でLinuxソフトに触れる機会が多くなり、慣れるという意...
Just a moment...

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 をインストールします。

$ sudo apt install mosquitto mosquitto-clients

mosquitto broker の設定をします。今回はMQTTメッセージのやり取りにユーザー認証を使用するので設定します。追加作成の場合は -c オプションを外します。

$ sudo mosquitto_passwd -c /etc/mosquitto/pwfile <\user_name\>

設定したパスワードは以下に暗号化されて保存されます

$ cat /etc/mosquitto/pwfile
<\user_name\>:<\hash\>

さらに設定ファイルを修正します。今回はTLSは使用しないので無効化して、さらにユーザー認証を有効化します。以上でmosquitto broker の設定は完了です。

$ vim /etc/mosquitto/conf.d/mosquitto.conf

# 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を使用します。

MQTT Explorer
An all-round MQTT client that provides a structured topic overview

使い方は簡単で、mosquitto broker のIPアドレス/ポート番号、先ほど作成したユーザ名/パスワードで接続できます。

broker がやりとしているメッセージを自動で取得してきます。また指定したtopicにメッセージを送信することもできるので、デバッグがかなり捗ります。

Flask

Welcome to Flask — Flask Documentation (3.1.x)

Flask(フラスク/フラスコ)は、Pythonで作られた軽量なWebアプリケーションフレームワークです。シンプルで柔軟性が高く、小規模なアプリから本格的なサービスまで幅広く使われています。セットアップが簡単なので私はもっぱらこいつを使ってます。TailScaleとの組み合わせで利便性が向上します。

最近のdebian系は aptとpipで明確にパッケージを分離しているようなので、いきなり pip install するエラーが出ます。適当な仮想環境を作って作業します。所詮、仮想環境でいくらでもやり直しが効くので、とりあえずえいやで作ってみましょう。

$ python3 -m venv .venv
$ source .venv/bin/activate
(.venv) $ pip install flask

Bootstrap5

Bootstrap5は、WebサイトやWebアプリのデザインを効率よく作成できるCSSフレームワークです。せっかくなので見た目にもこだわりたいので、こちらを使用します。CDN版とダウンロード版が用意されていますが、個人的に外部要因に依存したくないので、ダウンロード版を使用します。使用方法は後述します。

ダウンロード
コンパイルされたCSS、JavaScript、ソースコード、npmやRubyGemsといった好きなパッケージマネージャでBootstrapをインストールできます。

プログラミング

基本機能は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対応させます。ページ作成者様に感謝します。

FlaskでPWAを作るための最小実装 - Qiita
PWAとはProgressive Web Appsのことで、アプリストアを通さずに端末のホーム画面にインストールすることができます。 Flaskで作ったサイトをPWA化しよう調べていたところ、ReactやVueの実装例ばかりだったので、Fl...

ソースコード

#!/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を起動して動作を確認してみます。

$ python3 server.py

config.pyで設定したIPアドレスをブラウザに入力して、以下のようなに表示されればHTMLは問題ないです。

Fan ON/OFFを押して、クライアント側のPWMファンを制御できるか確認してください。この時点で制御できない場合はコンソール画面にログが表示されるので、該当箇所を修正してください。

まとめ

今回はMQTT通信でPWMファンを遠隔操作するためのシステムの「サーバー」側の実装が完了しました。これでMQTT通信システムが完成したので、このシステムをベースに機能を拡張できます。

すでに私の環境では様々な機能を拡張しているので、後日紹介したいと思います。

コメント