スマートIoTのススメ① MQTT通信でPWMファンを遠隔制御してみた 〜クライアント実装編〜

2025年は梅雨らしい気候がなく一気に暑くなって体調がイマイチ、水槽の水温も不安定で困っているgoldear@JiN_C125です。

※今回からスマートアクアリウムもスマートホームもまとめて、スマートIoTという括りに変更しました。

スポンサーリンク

背景/目的

スマートアクアリウムのススメ① Raspberry Pi で 水温測定【DS18B20】
北海道で買ってきたまりもをこぢんまりと育てるつもりが、どんどんアクアリウムにハマって後戻りできない状態のgoldear@goldear820です。アクアリウムで非常に重要になってくるのが「水温管理」ですが、我が家はNASが24時間稼働してい...

以前に水槽の水温測定は本ブログで紹介しましたが、水温を取得したところで手動でエアコンをつけているようでは全くスマートではありません。

今回はスマートIoTのベースとなるプラットフォームを作り、第一弾としてPC用のPWMファンを遠隔制御できるものを作っていきます。

ポイント
・スマートIoTの基盤を作成
・PWMファンを遠隔制御
・PWMファン状態を取得

MQTT通信

MQTT - The Standard for IoT Messaging
A lightweight messaging protocol for small sensors and mobile devices, optimized for high-latency or unreliable networks...

ベースとなる通信プロトコルはMQTT(Message Queuing Telemetry Transport)を使用します。MQTTはIoT向けの通信プロトコルで、通信ヘッダ処理によるオーバーヘッドが少ないのが特徴です。
WebSocketやTCPソケット通信くらいなら、私でも実装できそうですが、一番手っ取り早く、かつ楽に実装するならMQTT一択ですね。

システム全体像

簡単なシステム構成図です。サーバー/クライアント構成ですが、今回はクライアント側を設計・実装していきます。クライアント側ハードウェアで重要なのはPWMファン制御回路になりますので、まずはそこにフォーカスしていきます。W5100-EVA-PICOはEthernetを使用可能なRP2040搭載の評価基板です。面倒なの以降はマイコンと呼びます。

回路設計

KiCad
A Cross Platform and Open Source Electronics Design Automation Suite

PWM制御回路をKiCadで書いてみました。基本は以下の2つになります。

① +12V電源制御回路
② +3.3 -> +5Vレベルシフト回路

①はPoEスプリッターからDC ジャックを経由して最大+12V1Aの電源が供給されるので、この電源をPWMファンの駆動用電源に使用します。またPWMファンの回転をON/OFFできるように、マイコンのGPIOをMOSFETのゲート駆動用に使用します。

②はPWMファンの回転数を制御するPWM信号のレベル変換を行います。PWMファンの仕様に依存しますが、基本的に5VのPWM信号を入力します。マイコンでは3.3VのPWM信号しか生成できませんので、外部で5Vに変換します。

あまり大したことはしていません。部品点数も少ないので初心者でも安心ですね。

とは言うものの簡易のシミュレーションくらいは実施しておきましょう。私が最近使用しているのはCircuitJSという回路シミュレータです。JavaScriptで作られているのでブラウザ動作するのがポイントです。サクッと作れて実際の波形も確認できるので重宝しています。アプリ版もあるのでこちらでもOKです。

https://www.falstad.com/circuit/circuitjs.html
GitHub - sharpie7/circuitjs1: Electronic Circuit Simulator in the Browser
Electronic Circuit Simulator in the Browser. Contribute to sharpie7/circuitjs1 development by creating an account on Git...

とりあえず、KiCadの回路をそのまま作り、回路に適当に入力をセットすれば動作確認完了です。楽チンです。

部品表

クライアント実装で必要になる機材や電子部品の一覧表です。PoE (Power over Ethernet)を利用したいので少し価格は上がってしまいますが、電源を別で用意できるっていう方はそれでもOKです。

図番 メーカー 品名 購入先
WIZnet W5100S-EVB-Pico 秋月電子
秋月電子 SOT23変換基板 金フラッシュ 秋月電子
J1 SwitchScience ブレッドボードで使えるDCジャック(2.1mm) SwitchScience
Q1,Q4 International Rectifier NchMOSFET 30V5A IRLML6344TRPBFTR 秋月電子
Q2,Q3 International Rectifier PchチップMOSFET IRLML6402(20V3.7A) 秋月電子
R1 タクマン カーボン抵抗 1/4W 3.3KΩ±5% 千石電商
R2,R4,R5 タクマン カーボン抵抗 1/4W 10KΩ±5% 千石電商<
R3 タクマン カーボン抵抗 1/4W 1KΩ±5% 千石電商<
C1 村田製作所 積層セラミックコンデンサー 0.1μF50V X7R 2.54mm 秋月電子
ローム 三端子DCDCレギュレーター 5V BP5293-50 秋月電子
サンハヤト ブレッドボード SAD-101 Amazon
Foxneo FNS-1200P Amazon
10Gtek PoEスプリッター DC出力12V/1A Amazon

回路実装

(すでにファンが回り始めていますが。。。) ブレッドボード上に、さきほどの回路を組み上げるだけの簡単な作業になります。

補足事項としてマイコンの電源電圧について触れておきます。マイコン電源は12V → 5V の降圧DCDCコンバータで作成しています。ローム製のBP5293-50というDCDCコンバータで必要な部品がワンチップになっており、外付け部品を必要としません。3端子レギュレータのように使用できて、パーツ点数を減らしたい・発熱を減らしたいと言う理由で採用しています。

またMOSFETはチップ部品を使用しているので秋月電子で販売されている変換基板を使用しています。PCB基板に手を出してから基本がチップ部品になってしまった。

プログラミング

ハードウェア実装はできたので、次にソフトウェア実装にかかります。マイコンはRP2040チップを搭載しているので、MicroPythonかC言語のどちらかでの実装となります。
私はもっぱらMicroPythonを使用しているので、今回はMicroPythonで実装します。いずれC言語でも実装するかも。IDEはThonnyを使用します。ぶっちゃけ書ければ何でもいいのでメモ帳でも構いません。

Thonny, Python IDE for beginners

ツリー構造図

project/
│
├── lib
     ├── umqtt
           ├── robust.py
├── main.py
├── config.py
├── w5100_network.py
├── mqtt_client.py
├── fan_controller.py
├── gpio_control.py
├── w5100_network.py

・robust.py (umqtt)
MicroPython向けのMQTTライブラリです。GitHubで公開されているので、そのまま使わせていただきます。robust.pyの他にもsimple.py というのがあるのですが、ネットワーク切断後の再接続の機能を使いたいので今回はrobust.pyを使用します。サンプルコードもあるので使い方は難しくないと思います。

micropython-lib/micropython/umqtt.robust at master · micropython/micropython-lib
Core Python libraries ported to MicroPython. Contribute to micropython/micropython-lib development by creating an accoun...

・main.py
プログラム起動時に最初に読み込まれるメインスクリプトです。(ソフト的にはエントリポイントと言った方が良いのかな?)必要なモジュールの初期化と、システム全体の制御を担当します。

・config.py
プログラムで使用する設定情報(ピン番号・MQTT設定・ネットワーク設定など)をまとめて記述した設定ファイルです。すべてのモジュールが共通に利用します。

・w5100_network.py
W5100S-EVA-PICO をネットワーク接続するために初期化するモジュールです。

・mqtt_client.py
MQTT通信を担当するモジュールです。クライアントとしてサーバとの送受信を制御します。コマンドの送信(Publish)、受信(Subscribe)を実行します。

・fan_controller.py
PWMファンのON/OFF制御、回転速度の設定など、ファン動作を制御するモジュールです。

・gpio_control.py
GPIOの状態(HIGH/LOW)を制御する補助モジュールです。ファン制御に使用していますが、その他の汎用用途にも使用可能です。

ソースコード

実装したソースコードを添付しておきます。ソフトウェア素人ですが、参考までに利用してください。

########################################################
# MicroPython
# for WIZnet W5100S-EVB-Pico
# Author:	goldear.net
# Date:		2025/06/28
# Rev:		00
########################################################

import time
import uasyncio as asyncio
from w5100_network import wiznet_init
from mqtt_client import MqttHandler

async def publish_loop():
    count = 0
    while True:
        #mqtt.publish(f"Hello {count}")
        #count += 1
        await asyncio.sleep(1)
    
async def subscribe_loop():
    while True:
        mqtt.check_msg()
        await asyncio.sleep(0.1)

async def main():
    mqtt.connect()
    await asyncio.gather(
        publish_loop(),
        subscribe_loop()
    )        

if __name__ == "__main__":
    # Create instance
    nic = wiznet_init()
    mqtt = MqttHandler()
    
    try:
        asyncio.run(main())
    finally:
        asyncio.new_event_loop()

asyncio を使った非同期ループで、MQTTメッセージ受信とセンサー読み取りを並列処理しています。送信(Publish)は1秒、受信(Subscribe)は0.1秒の周期で処理しています。

# Config.py

class NetworkConfig:
    ## network
    DEVICE_IP = "192.168.11.2"
    GATEWAY   = "192.168.11.1"
    NETMASK   = "255.255.255.0"
    DNS       = "8.8.8.8"

    @staticmethod
    def get():
        return (NetworkConfig.DEVICE_IP \
               ,NetworkConfig.GATEWAY \
               ,NetworkConfig.NETMASK \
               ,NetworkConfig.DNS)
    
class Spi0Config:
    ## SPI
    SPI0_ID    = 0
    SPI0_RX   = 16 # MISO
    SPI0_CSn  = 17
    SPI0_SCK  = 18
    SPI0_TX   = 19 # MOSI
    SPI0_RSTn = 20
    SPI0_BR   = 2000000
    
    @staticmethod
    def get():
        return (Spi0Config.SPI0_ID \
               ,Spi0Config.SPI0_RX \
               ,Spi0Config.SPI0_CSn \
               ,Spi0Config.SPI0_SCK \
               ,Spi0Config.SPI0_TX \
               ,Spi0Config.SPI0_RSTn \
               ,Spi0Config.SPI0_BR)
    
class MqttConfig:
    MQTT_BROKER		= "192.168.11.3" # RPi5(Broker)
    MQTT_PORT		= 1883
    MQTT_CLIENT_ID	= "w5100s-1"
    MQTT_TOPIC_PUB	= b"fan/stat"
    MQTT_TOPIC_SUB	= b"fan/cmd"
    MQTT_USER		= "<user_id>"
    MQTT_PASS		= "<user_passwd>"

class FanConfig:
    FAN_CTL_PIN = 9
    FAN_PWM_PIN = 14
    FAN_PWM_FREQ = 25000

使用環境に依存する設定項目は、NetworkConfig、MqttConfig、FanConfigの3つです。

NetworkConfigのDEVICE_IPは今回でいうとマイコン(W5100S-EVA-PICO)に設定するIPアドレスです。

MqttConfigはMQTTブローカー(サーバー)となる端末のIPアドレス、使用するポート番号、任意のクライアントID、Publish/Subscribeのトピック、ユーザー認証を採用しているのでユーザーIDとパスワードを設定します。 (詳細は次回のサーバー実装編を参照)

FanConfigはGPIOのピン設定です。FAN_CTL_PINはGPIO番号9、FAN_PWN_PINはGPIO番号14に設定しています。任意なので好きなGPIO番号を設定してください。ピン番号ではなくてGPIO番号なので注意です。

FAN_PWN_FREQは25kHzのPWM信号を生成するため、25000を設定しています。

# w5100_network.py

import time
import network
from machine import SPI
from machine import Pin
from config import NetworkConfig
from config import Spi0Config

def wiznet_init():
    # Create Instance
    spi = SPI(Spi0Config.SPI0_ID \
            ,baudrate=Spi0Config.SPI0_BR \
            ,mosi=Pin(Spi0Config.SPI0_TX) \
            ,miso=Pin(Spi0Config.SPI0_RX) \
            ,sck=Pin(Spi0Config.SPI0_SCK))
    
    nic = network.WIZNET5K(spi \
                          ,Pin(Spi0Config.SPI0_CSn) \
                          ,Pin(Spi0Config.SPI0_RSTn))
    # Active
    nic.active(True)
    DEV_IP, NETMASK, GATEWAY, DNS = NetworkConfig.get()
    nic.ifconfig((DEV_IP, NETMASK, GATEWAY, DNS))
    
    timeout=10
    while not nic.isconnected() and timeout > 0:
        print (f"Wait Connecting...{timeout}")
        time.sleep(1)
        timeout -= 1
    
    if nic.isconnected():
        print(f"IP address: {nic.ifconfig()}")
        return nic
    else:
        return RuntimeError("Failed Connect")

特記事項は特になく、ネットワーク接続する時の定型文です。Config.pyから設定を読み取って、ネットワーク接続します。

# mqtt_client.py

import ujson
from lib.umqtt.robust import MQTTClient
from config import MqttConfig
from fan_controller import FanController

class MqttHandler:
    def __init__(self):
        self.client = MQTTClient(
             client_id=MqttConfig.MQTT_CLIENT_ID
            ,server=MqttConfig.MQTT_BROKER
            ,port=MqttConfig.MQTT_PORT
            ,user=MqttConfig.MQTT_USER
            ,password=MqttConfig.MQTT_PASS
        )
        self.client.set_callback(self._on_message)
        self.fan = FanController()

    def connect(self):
        try:
            print("Connecting to MQTT broker...")
            self.client.connect()
            self.client.subscribe(MqttConfig.MQTT_TOPIC_SUB)
            print("Connected and subscribed to:", MqttConfig.MQTT_TOPIC_SUB)
        except Exception as e:
            print("MQTT connection failed:", e)

    def publish(self, msg: str):
        try:
            self.client.publish(MqttConfig.MQTT_TOPIC_PUB, msg)
            print("Published:", msg)
        except Exception as e:
            print("Publish failed:", e)
            self.reconnect()

    def check_msg(self):
        try:
            self.client.check_msg()
        except Exception as e:
            print("Error checking massages:", e)
            self.reconnect()

    def reconnect(self):
        try:
            print("Attempting MQTT reconnect...")
            self.client.reconnect()
        except Exception as e:
            print("Reconnect failed:", e)

    def disconnect(self):
        try:
            self.client.disconnect()
        except Exception as e:
            print("Disconnect failed:", e)       

    def _on_message(self, topic, msg):
        print("Received:", topic, msg)
        try:
            # Analysis json 
            try:
                data = ujson.loads(msg)
                cmd = data.get("cmd")
                val = data.get("val")
                self._handle_command(cmd, val)
                return
            except Exception:
                pass

            # Analysis key:value
            decoded = msg.decode("utf-8").strip()
            if ":" in decoded:
                cmd, val = decoded.split(":", 1)
                self._handle_command(cmd.strip(), val.strip())
            else:
                self._handle_command(decoded.lower())

        except Exception as e:
            print("Message handling error:", e)

    def _handle_command(self, cmd, val=None):
        print("Handling:", cmd, val)

        if cmd == "fan_on":
            self.fan.on()
            
        elif cmd == "fan_off":
            self.fan.off()
            
        elif cmd == "fan_speed" and val is not None:
            try:
                speed = int(val)
                self.fan.set_speed(speed)
            except:
                print("Invalid fan_speed value:", val)
                
        elif cmd == "fan_status":
            status = self.fan.get_status()
            self.publish(status)
            
        else:
            print("Unknown command:", cmd)

MQTT通信処理を記述しています。MqttHandlerクラスでは、MQTTブローカー(サーバー)との接続処理、MQTTメッセージの送信、MQTTメッセージ受信確認処理、再接続処理、切断処理、MQTTメッセージ受信時処理を記述。_handle_commandクラスで特定のコマンドを受信した時の処理を記述。

# fan_controller.py

from machine import Pin
from machine import PWM
from config import FanConfig
from gpio_control import GPIOController

class FanController:
    def __init__(self):
        self.state = "off"
        self.speed = 0
        # init
        self.ctl_pin_no = FanConfig.FAN_CTL_PIN
        self.pwm_pin_no = FanConfig.FAN_PWM_PIN
        self.pin0 = Pin(self.ctl_pin_no, Pin.OUT)
        self.pin1 = Pin(self.pwm_pin_no, Pin.OUT)
        self.pwm = PWM(self.pin1)
        self.pwm.freq(FanConfig.FAN_PWM_FREQ)
        
        # create instance
        self.gpio_ctl = GPIOController(self.ctl_pin_no)

    def on(self):
        self.state = "on"
        self.set_speed(50)
        self.gpio_ctl.high()
        print("Fan ON")

    def off(self):
        self.state = "off"
        self.set_speed(0)
        self.gpio_ctl.low()
        print("Fan OFF")

    def set_speed(self, speed):
        # set PWM duty
        duty = int(65535 * (speed / 100))
        self.pwm.duty_u16(duty)

    def get_status(self):
        return '{"fan_state":"%s", "fan_speed":%d}' % (self.state, self.pwm.duty_u16())

PWMファン制御処理を記述しています。FanControllerクラスは ファン起動、ファン停止、回転数設定、ファン状態の取得処理を記述しています。さきほどの_handle_commandでこれらの処理が呼び出されて、実行されます

# gpio_control.py

from machine import Pin

class GPIOController:
    def __init__(self, pin_no):
        self.pin = Pin(pin_no, Pin.OUT)
        self.state = False
        self.pin.value(0)

    def high(self):
        self.pin.value(1)
        self.state = True
        print("GPIO HIGH")

    def low(self):
        self.pin.value(0)
        self.state = False
        print("GPIO LOW")

    def toggle(self):
        self.state = not self.state
        self.pin.value(1 if self.state else 0)

    def get_status(self):
        return "on" if self.state else "off"

FanController内のファン起動、ファン停止処理の補助を行うGPIO処理を記述しています。GPIOをHIGH/LOWレベルに設定する処理、トグルさせる処理、状態を取得する処理を記述しています。

まとめ

今回はMQTT通信でPWMファンを遠隔操作するためのシステムの「クライアント」側の実装が完了しました。次回はMQTTブローカーとWebブラウザ経由の遠隔操作を実装します。以下のようなUIでWeb経由でPWMファンを遠隔操作できるようにします。システム自体は完成しているので、近日中に紹介記事を公開します。

スマートアクアリウムのススメ② Raspberry Pi で 室温&湿度測定【DHT22】
冬場はPC暖房で過ごすgoldear@goldear820です。前回ススメ①で水槽の水温を測定できるようにしましたが、今回は水槽を配置している部屋の温度・湿度を測定できるようにします。私の水槽は25x17x21cmの小型水槽なので、特に室温...
スマートアクアリウムのススメ③ Raspberry Pi で TDS測定【KS0429】
Raspberry Pi のセンシングにハマっているgoldear@goldear820です。今回はTDS(Total Dissolved Solids 総溶解固形物)をラズパイで測定するのですが、しっかり意味を理解していないと全く意味のな...

コメント