第20章 Raspberry Pi 綜合專題:智慧環境監控與遠端控制系統

發布日期: |更新日期:
文章主圖:第20章 Raspberry Pi 綜合專題:智慧環境監控與遠端控制系統

前面 19 章,我們已經分別學習了 Raspberry Pi OS、Linux、Python、GPIO、DHT11 / DHT22、LCD1602 I²C、LED、蜂鳴器、Relay、Camera、Flask、MQTT、SQLite 與 SSH。

現在來到整套 Raspberry Pi 教學系列最重要的一章。

這一次不再只控制一顆 LED,也不是單獨讀取一顆感測器,而是要把前面學過的技術真正整合成一套:

Raspberry Pi 智慧環境監控中心

完整系統將具備:

  1. 讀取室內溫度、濕度
  2. LCD1602 即時顯示
  3. 溫度過高 LED 警示
  4. 蜂鳴器高溫警報
  5. 自動啟動 Relay/風扇
  6. SQLite 記錄歷史資料
  7. Flask Web 顯示即時狀態
  8. 手機遠端控制風扇
  9. MQTT 發布 Sensor Data
  10. Camera 遠端拍照
  11. SSH 遠端維護
  12. systemd 開機自動啟動

最後整套系統會形成:

Raspberry Pi
                              │
        ┌─────────────────────┼─────────────────────┐
        │                     │                     │
      DHT22               Camera Module           GPIO
        │                     │                     │
        ▼                     ▼            ┌────────┼────────┐
 Temperature/Humidity      Picamera2       LED    Buzzer   Relay
        │                                           │
        │                                          Fan
        │
        └─────────────────────┬─────────────────────┘
                              │
                            Python
                              │
        ┌──────────────┬──────┼───────┬──────────────┐
        │              │      │       │              │
        ▼              ▼      ▼       ▼              ▼
      LCD1602        SQLite  MQTT    Flask          Camera
        │              │      │       │              │
        ▼              ▼      ▼       ▼              ▼
 Local Display     History   IoT    Browser        Photo
                           Message   Dashboard
                                      │
                                      ▼
                                  Smartphone

這就是一套完整的:

Raspberry Pi IoT Edge Monitoring System。

一、本章學習目標

完成本章後,你將能夠把前面學過的技術真正整合,而不只是個別測試。

核心技術包括:

  • Raspberry Pi
  • Raspberry Pi OS
  • Linux
  • Python
  • GPIO
  • DHT11 / DHT22
  • LCD1602 I²C
  • LED
  • Active Buzzer
  • Relay Module
  • Fan
  • Camera Module
  • Picamera2
  • Flask
  • HTML
  • JavaScript
  • HTTP
  • MQTT
  • Mosquitto
  • SQLite
  • SSH
  • systemd
  • Background Thread
  • IoT Dashboard
  • Database Logging

本章最終完成:

Raspberry Pi 智慧環境監控與遠端控制系統

二、完整系統功能

我們先把需求定義清楚。

功能 1:溫度與濕度

DHT11 / DHT22:

DHT22
  │
  ├── Temperature
  │
  └── Humidity

Python 每隔數秒讀取一次。

功能 2:LCD1602

LCD 即時顯示:

T:28.5C H:67%
Fan:AUTO OFF

不用開手機,也可以直接從 Raspberry Pi 看見目前環境狀態。

功能 3:LED 高溫警示

當:

Temperature >= 30°C

LED:

ON

表示進入高溫狀態。

三、蜂鳴器警報

當溫度超過設定值:

Temperature >= 30°C

蜂鳴器啟動:

Buzzer ON

Web Dashboard 另外提供:

Mute

功能,可以暫時停止聲音警報。

四、自動風扇

當高溫時:

DHT22
  │
  ▼
Temperature >= 30°C
  │
  ▼
Python
  │
  ▼
Relay ON
  │
  ▼
Fan ON

當溫度下降:

Temperature <= 29°C

風扇關閉。

也就是加入:

1°C Hysteresis

避免溫度剛好在 30°C 附近時 Relay 不斷:

ON
OFF
ON
OFF

五、風扇 AUTO / ON / OFF

這一次不只提供自動控制。

我們設計三種模式:

AUTO
ON
OFF

AUTO

依溫度自動控制。

ON

強制開啟 Relay/風扇。

OFF

強制關閉。

手機 Dashboard 可以直接切換。

六、SQLite 歷史資料

每隔固定時間將:

Timestamp
Temperature
Humidity
Alarm
Fan Mode
Fan Status

寫進:

environment.db

因此即使 Raspberry Pi 重新開機,歷史資料仍然可以繼續查詢。

SQLite 是自包含式 SQL Database Engine,很適合這種 Raspberry Pi 本機資料保存用途。

七、Flask Web Dashboard

手機開啟:

http://Raspberry-Pi-IP:8000

可以看到:

Raspberry Pi
Smart Environment Center

Temperature
28.5 °C

Humidity
67.2 %

Alarm
NORMAL

Fan
AUTO / OFF

[ AUTO ]
[ FAN ON ]
[ FAN OFF ]

[ MUTE BUZZER ]

[ TAKE PHOTO ]

這就是 Raspberry Pi 的:

Web HMI。

八、MQTT IoT

Sensor Data 同時發布:

home/room1/temperature

home/room1/humidity

home/room1/environment

設備狀態:

home/room1/fan/status

home/room1/alarm/status

控制:

home/room1/fan/set

home/room1/buzzer/set

home/room1/camera/set

因此其他:

Raspberry Pi
ESP32
Notebook
Server
Home Assistant
IoT Dashboard

都能透過 MQTT 接收資料或發布控制命令。

九、Camera 遠端拍照

Web:

[ TAKE PHOTO ]

按下後:

Browser
   │
   ▼
Flask
   │
   ▼
Picamera2
   │
   ▼
Camera Module
   │
   ▼
20260818_090000.jpg

MQTT 也能:

Topic:
home/room1/camera/set

Payload:
PHOTO

觸發 Camera。

目前 Raspberry Pi OS 的 Camera 軟體架構以 rpicam-* 與 Picamera2 為主;Picamera2 是 Raspberry Pi 官方提供的 Python Camera Library。

十、SSH 遠端管理

即使沒有:

Keyboard
Mouse
Monitor

仍然可以從其他電腦:

ssh username@192.168.1.100

進入 Raspberry Pi。

Raspberry Pi OS 預設不啟用 SSH Server,需要在 Imager、系統設定或其他官方支援方式中啟用。

十一、systemd 自動啟動

最後把 Python 系統變成:

smart-environment.service

Raspberry Pi 開機:

Power ON
   │
   ▼
Raspberry Pi OS
   │
   ▼
systemd
   │
   ▼
smart-environment.service
   │
   ▼
Python
   │
   ├── DHT22
   ├── LCD
   ├── GPIO
   ├── Camera
   ├── MQTT
   ├── SQLite
   └── Web Server

不需要每次 SSH 進去再手動:

python app.py

十二、準備材料

完整專題建議準備:

第 20 章智慧環境監控專題材料
材料 數量
Raspberry Pi 4 / 5 1
microSD / SSD 1
Raspberry Pi OS 1
DHT11 / DHT22 Module 1
LCD1602 I²C Module 1
LED 1
220Ω~330Ω 電阻 1
Active Buzzer 1
Relay Module 1
小型 DC Fan 1
Raspberry Pi Camera 1
Breadboard 1
Dupont Wire 若干
Raspberry Pi 電源 1

十三、安全提醒:Relay 與風扇

本章建議使用:

低電壓 DC Fan

進行實驗。

不要為了學習本章就直接操作:

110V / 220V AC

市電。

如果控制市電設備,Relay 絕緣、保險絲、接地、外殼、線材規格與電氣安全都必須另外設計。

十四、GPIO 規劃

本章統一使用 BCM GPIO 編號:

第 20 章 BCM GPIO 規劃
功能 BCM GPIO
DHT11 / DHT22 DATA GPIO4
Warning LED GPIO17
Relay GPIO27
Buzzer GPIO22
LCD1602 SDA GPIO2
LCD1602 SCL GPIO3
Camera CSI

因此:

GPIO4  → DHT22

GPIO17 → LED

GPIO22 → Buzzer

GPIO27 → Relay

GPIO2  → I²C SDA

GPIO3  → I²C SCL

十五、完整接線架構

Raspberry Pi
                             │
       ┌─────────────────────┼─────────────────────┐
       │                     │                     │
     GPIO4                 GPIO17                GPIO22
       │                     │                     │
       ▼                     ▼                     ▼
     DHT22                Resistor              Buzzer
       │                     │
       │                    LED
       │
       │
     GPIO27
       │
       ▼
     Relay
       │
       ▼
      Fan


      GPIO2 SDA ─────────── LCD1602 I²C

      GPIO3 SCL ─────────── LCD1602 I²C

      CSI ───────────────── Camera Module

十六、LCD1602 I²C 接線

常見 I²C LCD1602:

LCD1602 I²C 與 Raspberry Pi 接線
LCD Raspberry Pi
VCC 依模組規格供電
GND GND
SDA GPIO2 / SDA
SCL GPIO3 / SCL

先使用:

i2cdetect -y 1

查看 Address。

常見會看到:

27

或:

3f

因此程式預設:

0x27

如果你的 LCD 是:

0x3F

只需要修改設定。

十七、Relay Active High / Active Low

這一點非常重要。

不同 Relay Module 有:

Active HIGH

也有:

Active LOW

因此本章程式不把 Relay 電位邏輯寫死。

使用:

RELAY_ACTIVE_HIGH

設定。

常見 Active-Low Relay 可以設定:

RELAY_ACTIVE_HIGH=0

如果你的 Relay 是 Active-High:

RELAY_ACTIVE_HIGH=1

十八、建立專案目錄

先執行:

mkdir -p ~/smart_env

進入:

cd ~/smart_env

建立:

mkdir -p templates
mkdir -p data
mkdir -p photos

最後:

smart_env/
│
├── app.py
├── smart_env.env
│
├── templates/
│   └── index.html
│
├── data/
│   └── environment.db
│
├── photos/
│
└── venv/

十九、安裝系統套件

先更新:

sudo apt update

安裝基本套件:

sudo apt install -y \
python3-full \
python3-venv \
python3-picamera2 \
python3-gpiozero \
i2c-tools \
mosquitto \
mosquitto-clients

Picamera2 可以直接操作 Raspberry Pi Camera;Bookworm 之後 Raspberry Pi Camera Command Line 工具則使用 rpicam-* 名稱。

二十、建立 Python Virtual Environment

執行:

cd ~/smart_env

建立:

python3 -m venv \
--system-site-packages \
venv

使用:

--system-site-packages

是因為我們希望 Virtual Environment 可以使用 Raspberry Pi OS 已安裝的:

Picamera2
GPIO Zero

等系統 Python Package。

啟用:

source venv/bin/activate

二十一、安裝 Python Package

執行:

python -m pip install \
flask \
waitress \
paho-mqtt \
adafruit-circuitpython-dht \
RPLCD \
smbus2

本章使用:

Flask

建立 Web Application。

使用:

Waitress

提供 WSGI Server。

Flask 官方明確提醒 Development Server 不應直接作為 Production Server;正式部署應使用適合的 WSGI Server。

二十二、為什麼本章不用 app.run() 當最終 Server?

開發時經常看到:

app.run()

但最終系統需要:

長時間運作
+
systemd 管理

因此本章最後改用:

Waitress

流程:

Browser
   │
   ▼
Waitress
   │
   ▼
Flask
   │
   ▼
Python Hardware Controller

這樣比直接把 Flask Development Server 當正式 Server 更合理。

二十三、建立環境設定檔

建立:

nano ~/smart_env/smart_env.env

內容:

DHT_TYPE=DHT22

HIGH_TEMP=30.0
TEMP_HYSTERESIS=1.0

LCD_ENABLED=1
LCD_ADDRESS=0x27

RELAY_ACTIVE_HIGH=0

MQTT_BROKER=127.0.0.1
MQTT_PORT=1883
MQTT_USERNAME=
MQTT_PASSWORD=

WEB_HOST=0.0.0.0
WEB_PORT=8000

SENSOR_INTERVAL=5
DATABASE_INTERVAL=60
MQTT_INTERVAL=10

如果使用 DHT11:

DHT_TYPE=DHT11

二十四、系統控制策略

我們將使用:

HIGH_TEMP=30.0

以及:

TEMP_HYSTERESIS=1.0

因此:

Temperature >= 30.0
        │
        ▼
     ALARM ON
        │
        ├── LED ON
        ├── Buzzer ON
        └── AUTO Fan ON

解除:

Temperature <= 29.0
        │
        ▼
     ALARM OFF
        │
        ├── LED OFF
        ├── Buzzer OFF
        └── AUTO Fan OFF

二十五、為什麼需要 Shared State?

我們同時有:

Sensor Thread
MQTT Thread
Web Thread
LCD Thread

它們都需要知道:

temperature
humidity
alarm
fan
camera

所以建立:

state = {}

保存目前狀態。

再利用:

threading.RLock()

保護多 Thread 存取。

二十六、完整 Python 主程式

建立:

nano ~/smart_env/app.py

輸入以下完整程式:

import os
import json
import time
import atexit
import sqlite3
import threading
from datetime import datetime
from pathlib import Path

from flask import (
    Flask,
    jsonify,
    render_template,
    request,
    send_from_directory
)

from waitress import serve

from gpiozero import (
    LED,
    Buzzer,
    OutputDevice
)

import board
import adafruit_dht

import paho.mqtt.client as mqtt


# =========================================================
# Optional LCD
# =========================================================

try:
    from RPLCD.i2c import CharLCD
except Exception:
    CharLCD = None


# =========================================================
# Optional Camera
# =========================================================

try:
    from picamera2 import Picamera2
except Exception:
    Picamera2 = None


# =========================================================
# Path
# =========================================================

BASE_DIR = Path(__file__).resolve().parent

DATA_DIR = BASE_DIR / "data"

PHOTO_DIR = BASE_DIR / "photos"

DB_FILE = DATA_DIR / "environment.db"


DATA_DIR.mkdir(
    parents=True,
    exist_ok=True
)

PHOTO_DIR.mkdir(
    parents=True,
    exist_ok=True
)


# =========================================================
# Load simple environment file
# =========================================================

ENV_FILE = BASE_DIR / "smart_env.env"


def load_env_file(path):

    if not path.exists():
        return

    with open(
        path,
        "r",
        encoding="utf-8"
    ) as file:

        for raw_line in file:

            line = raw_line.strip()

            if not line:
                continue

            if line.startswith("#"):
                continue

            if "=" not in line:
                continue

            key, value = line.split(
                "=",
                1
            )

            os.environ.setdefault(
                key.strip(),
                value.strip()
            )


load_env_file(
    ENV_FILE
)


# =========================================================
# Helper
# =========================================================

def env_bool(
    name,
    default=False
):

    value = os.getenv(
        name,
        "1" if default else "0"
    )

    return value.lower() in (
        "1",
        "true",
        "yes",
        "on"
    )


def now_text():

    return (
        datetime.now()
        .astimezone()
        .isoformat(
            timespec="seconds"
        )
    )


# =========================================================
# Configuration
# =========================================================

DHT_TYPE = os.getenv(
    "DHT_TYPE",
    "DHT22"
).upper()


HIGH_TEMP = float(
    os.getenv(
        "HIGH_TEMP",
        "30.0"
    )
)


TEMP_HYSTERESIS = float(
    os.getenv(
        "TEMP_HYSTERESIS",
        "1.0"
    )
)


SENSOR_INTERVAL = float(
    os.getenv(
        "SENSOR_INTERVAL",
        "5"
    )
)


DATABASE_INTERVAL = float(
    os.getenv(
        "DATABASE_INTERVAL",
        "60"
    )
)


MQTT_INTERVAL = float(
    os.getenv(
        "MQTT_INTERVAL",
        "10"
    )
)


LCD_ENABLED = env_bool(
    "LCD_ENABLED",
    True
)


LCD_ADDRESS = int(
    os.getenv(
        "LCD_ADDRESS",
        "0x27"
    ),
    0
)


RELAY_ACTIVE_HIGH = env_bool(
    "RELAY_ACTIVE_HIGH",
    False
)


MQTT_BROKER = os.getenv(
    "MQTT_BROKER",
    "127.0.0.1"
)


MQTT_PORT = int(
    os.getenv(
        "MQTT_PORT",
        "1883"
    )
)


MQTT_USERNAME = os.getenv(
    "MQTT_USERNAME",
    ""
)


MQTT_PASSWORD = os.getenv(
    "MQTT_PASSWORD",
    ""
)


WEB_HOST = os.getenv(
    "WEB_HOST",
    "0.0.0.0"
)


WEB_PORT = int(
    os.getenv(
        "WEB_PORT",
        "8000"
    )
)


# =========================================================
# GPIO
# =========================================================

WARNING_LED_GPIO = 17

RELAY_GPIO = 27

BUZZER_GPIO = 22


warning_led = LED(
    WARNING_LED_GPIO
)


buzzer = Buzzer(
    BUZZER_GPIO
)


relay = OutputDevice(
    RELAY_GPIO,
    active_high=RELAY_ACTIVE_HIGH,
    initial_value=False
)


# =========================================================
# Shared State
# =========================================================

state_lock = threading.RLock()


state = {

    "temperature": None,

    "humidity": None,

    "sensor_time": None,

    "sensor_error": None,

    "alarm": False,

    "fan_mode": "AUTO",

    "fan_on": False,

    "buzzer_muted": False,

    "mqtt_connected": False,

    "camera_ready": False,

    "last_photo": None,

    "last_photo_time": None
}


stop_event = threading.Event()


# =========================================================
# SQLite
# =========================================================

def database_connect():

    connection = sqlite3.connect(
        DB_FILE,
        timeout=5
    )

    connection.execute(
        "PRAGMA busy_timeout = 5000"
    )

    return connection


def initialize_database():

    with database_connect() as connection:

        connection.execute(
            "PRAGMA journal_mode=WAL"
        )

        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS
            environment_data
            (
                id INTEGER
                    PRIMARY KEY
                    AUTOINCREMENT,

                timestamp TEXT
                    NOT NULL,

                temperature REAL,

                humidity REAL,

                alarm INTEGER
                    NOT NULL,

                fan_mode TEXT
                    NOT NULL,

                fan_on INTEGER
                    NOT NULL
            )
            """
        )

        connection.execute(
            """
            CREATE INDEX IF NOT EXISTS
            idx_environment_timestamp

            ON environment_data(
                timestamp
            )
            """
        )


        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS
            system_events
            (
                id INTEGER
                    PRIMARY KEY
                    AUTOINCREMENT,

                timestamp TEXT
                    NOT NULL,

                event_type TEXT
                    NOT NULL,

                message TEXT
            )
            """
        )


def save_environment():

    with state_lock:

        temperature = state[
            "temperature"
        ]

        humidity = state[
            "humidity"
        ]

        alarm = state[
            "alarm"
        ]

        fan_mode = state[
            "fan_mode"
        ]

        fan_on = state[
            "fan_on"
        ]


    if temperature is None:
        return

    if humidity is None:
        return


    with database_connect() as connection:

        connection.execute(
            """
            INSERT INTO environment_data
            (
                timestamp,
                temperature,
                humidity,
                alarm,
                fan_mode,
                fan_on
            )

            VALUES
            (
                ?,
                ?,
                ?,
                ?,
                ?,
                ?
            )
            """,
            (
                now_text(),
                temperature,
                humidity,
                int(alarm),
                fan_mode,
                int(fan_on)
            )
        )


def save_event(
    event_type,
    message=""
):

    try:

        with database_connect() as connection:

            connection.execute(
                """
                INSERT INTO system_events
                (
                    timestamp,
                    event_type,
                    message
                )

                VALUES
                (
                    ?,
                    ?,
                    ?
                )
                """,
                (
                    now_text(),
                    event_type,
                    message
                )
            )

    except Exception as error:

        print(
            "Event DB error:",
            error
        )


# =========================================================
# DHT
# =========================================================

def create_dht():

    if DHT_TYPE == "DHT11":

        return adafruit_dht.DHT11(
            board.D4,
            use_pulseio=False
        )

    return adafruit_dht.DHT22(
        board.D4,
        use_pulseio=False
    )


dht = create_dht()


# =========================================================
# LCD1602
# =========================================================

lcd = None

lcd_lock = threading.Lock()


def initialize_lcd():

    global lcd


    if not LCD_ENABLED:
        return


    if CharLCD is None:

        print(
            "RPLCD unavailable"
        )

        return


    try:

        lcd = CharLCD(
            i2c_expander="PCF8574",

            address=LCD_ADDRESS,

            port=1,

            cols=16,

            rows=2,

            charmap="A00",

            auto_linebreaks=False
        )


        lcd.clear()


        print(
            "LCD ready:",
            hex(LCD_ADDRESS)
        )


    except Exception as error:

        lcd = None

        print(
            "LCD init error:",
            error
        )


def lcd_write(
    line1,
    line2
):

    if lcd is None:
        return


    try:

        with lcd_lock:

            line1 = (
                str(line1)[:16]
                .ljust(16)
            )

            line2 = (
                str(line2)[:16]
                .ljust(16)
            )


            lcd.cursor_pos = (
                0,
                0
            )

            lcd.write_string(
                line1
            )


            lcd.cursor_pos = (
                1,
                0
            )

            lcd.write_string(
                line2
            )


    except Exception as error:

        print(
            "LCD error:",
            error
        )


# =========================================================
# Camera
# =========================================================

camera = None

camera_lock = threading.Lock()


def initialize_camera():

    global camera


    if Picamera2 is None:

        print(
            "Picamera2 unavailable"
        )

        return


    try:

        camera = Picamera2()


        config = (
            camera
            .create_still_configuration(
                main={
                    "size":
                    (
                        1920,
                        1080
                    )
                }
            )
        )


        camera.configure(
            config
        )


        camera.start()


        time.sleep(2)


        with state_lock:

            state[
                "camera_ready"
            ] = True


        print(
            "Camera ready"
        )


    except Exception as error:

        camera = None


        with state_lock:

            state[
                "camera_ready"
            ] = False


        print(
            "Camera init error:",
            error
        )


def capture_photo(
    source="web"
):

    if camera is None:

        raise RuntimeError(
            "Camera is not ready"
        )


    filename = (
        datetime.now()
        .strftime(
            "%Y%m%d_%H%M%S.jpg"
        )
    )


    filepath = (
        PHOTO_DIR
        / filename
    )


    with camera_lock:

        camera.capture_file(
            str(filepath)
        )


    with state_lock:

        state[
            "last_photo"
        ] = filename

        state[
            "last_photo_time"
        ] = now_text()


    save_event(
        "CAMERA_PHOTO",
        f"{source}: {filename}"
    )


    return filename


# =========================================================
# Hardware Control
# =========================================================

def set_fan_output(
    on
):

    if on:

        relay.on()

    else:

        relay.off()


    with state_lock:

        state[
            "fan_on"
        ] = bool(on)


def update_alarm_control():

    with state_lock:

        temperature = state[
            "temperature"
        ]

        alarm = state[
            "alarm"
        ]

        fan_mode = state[
            "fan_mode"
        ]

        muted = state[
            "buzzer_muted"
        ]


    if temperature is None:
        return


    new_alarm = alarm


    if not alarm:

        if temperature >= HIGH_TEMP:

            new_alarm = True


    else:

        if temperature <= (
            HIGH_TEMP
            -
            TEMP_HYSTERESIS
        ):

            new_alarm = False


    if new_alarm != alarm:

        save_event(
            "ALARM_ON"
            if new_alarm
            else "ALARM_OFF",

            (
                "Temperature "
                f"{temperature:.1f} C"
            )
        )


    with state_lock:

        state[
            "alarm"
        ] = new_alarm


    # Warning LED

    if new_alarm:

        warning_led.on()

    else:

        warning_led.off()


    # Buzzer

    if (
        new_alarm
        and
        not muted
    ):

        buzzer.on()

    else:

        buzzer.off()


    # Relay / Fan

    if fan_mode == "AUTO":

        set_fan_output(
            new_alarm
        )


    elif fan_mode == "ON":

        set_fan_output(
            True
        )


    elif fan_mode == "OFF":

        set_fan_output(
            False
        )


def set_fan_mode(
    mode
):

    mode = (
        mode
        .strip()
        .upper()
    )


    if mode not in (
        "AUTO",
        "ON",
        "OFF"
    ):

        raise ValueError(
            "Invalid fan mode"
        )


    with state_lock:

        state[
            "fan_mode"
        ] = mode


    save_event(
        "FAN_MODE",
        mode
    )


    update_alarm_control()


def set_buzzer_muted(
    muted
):

    with state_lock:

        state[
            "buzzer_muted"
        ] = bool(muted)


    save_event(
        "BUZZER_MUTE"
        if muted
        else "BUZZER_UNMUTE"
    )


    update_alarm_control()


# =========================================================
# Sensor Thread
# =========================================================

def sensor_worker():

    last_db_time = 0

    last_mqtt_time = 0


    while not stop_event.is_set():

        try:

            temperature = (
                dht.temperature
            )

            humidity = (
                dht.humidity
            )


            if (
                temperature is not None
                and
                humidity is not None
            ):

                with state_lock:

                    state[
                        "temperature"
                    ] = round(
                        temperature,
                        1
                    )

                    state[
                        "humidity"
                    ] = round(
                        humidity,
                        1
                    )

                    state[
                        "sensor_time"
                    ] = now_text()

                    state[
                        "sensor_error"
                    ] = None


                update_alarm_control()


                current = (
                    time.monotonic()
                )


                if (
                    current
                    -
                    last_db_time
                    >=
                    DATABASE_INTERVAL
                ):

                    save_environment()

                    last_db_time = (
                        current
                    )


                if (
                    current
                    -
                    last_mqtt_time
                    >=
                    MQTT_INTERVAL
                ):

                    mqtt_publish_environment()

                    last_mqtt_time = (
                        current
                    )


        except RuntimeError as error:

            with state_lock:

                state[
                    "sensor_error"
                ] = str(error)


            print(
                "DHT temporary error:",
                error
            )


        except Exception as error:

            with state_lock:

                state[
                    "sensor_error"
                ] = str(error)


            print(
                "Sensor error:",
                error
            )


        stop_event.wait(
            SENSOR_INTERVAL
        )


# =========================================================
# LCD Thread
# =========================================================

def lcd_worker():

    while not stop_event.is_set():

        with state_lock:

            temperature = state[
                "temperature"
            ]

            humidity = state[
                "humidity"
            ]

            fan_mode = state[
                "fan_mode"
            ]

            fan_on = state[
                "fan_on"
            ]


        if temperature is None:

            line1 = (
                "Waiting Sensor"
            )

        else:

            line1 = (
                f"T:{temperature:.1f}C "
                f"H:{humidity:.0f}%"
            )


        fan_text = (
            "ON"
            if fan_on
            else "OFF"
        )


        line2 = (
            f"{fan_mode} FAN:{fan_text}"
        )


        lcd_write(
            line1,
            line2
        )


        stop_event.wait(
            1
        )


# =========================================================
# MQTT
# =========================================================

MQTT_BASE = (
    "home/room1"
)


TEMP_TOPIC = (
    MQTT_BASE
    +
    "/temperature"
)


HUMIDITY_TOPIC = (
    MQTT_BASE
    +
    "/humidity"
)


ENV_TOPIC = (
    MQTT_BASE
    +
    "/environment"
)


FAN_SET_TOPIC = (
    MQTT_BASE
    +
    "/fan/set"
)


FAN_STATUS_TOPIC = (
    MQTT_BASE
    +
    "/fan/status"
)


ALARM_STATUS_TOPIC = (
    MQTT_BASE
    +
    "/alarm/status"
)


BUZZER_SET_TOPIC = (
    MQTT_BASE
    +
    "/buzzer/set"
)


CAMERA_SET_TOPIC = (
    MQTT_BASE
    +
    "/camera/set"
)


mqtt_client = mqtt.Client(
    mqtt.CallbackAPIVersion.VERSION2
)


def mqtt_on_connect(
    client,
    userdata,
    flags,
    reason_code,
    properties
):

    print(
        "MQTT connected:",
        reason_code
    )


    with state_lock:

        state[
            "mqtt_connected"
        ] = True


    client.subscribe(
        FAN_SET_TOPIC
    )

    client.subscribe(
        BUZZER_SET_TOPIC
    )

    client.subscribe(
        CAMERA_SET_TOPIC
    )


    mqtt_publish_environment()


def mqtt_on_disconnect(
    client,
    userdata,
    disconnect_flags,
    reason_code,
    properties
):

    with state_lock:

        state[
            "mqtt_connected"
        ] = False


    print(
        "MQTT disconnected:",
        reason_code
    )


def mqtt_on_message(
    client,
    userdata,
    message
):

    payload = (
        message
        .payload
        .decode(
            "utf-8",
            errors="replace"
        )
        .strip()
        .upper()
    )


    print(
        "MQTT:",
        message.topic,
        payload
    )


    try:

        if (
            message.topic
            ==
            FAN_SET_TOPIC
        ):

            set_fan_mode(
                payload
            )


        elif (
            message.topic
            ==
            BUZZER_SET_TOPIC
        ):

            if payload in (
                "MUTE",
                "OFF"
            ):

                set_buzzer_muted(
                    True
                )


            elif payload in (
                "UNMUTE",
                "ON"
            ):

                set_buzzer_muted(
                    False
                )


        elif (
            message.topic
            ==
            CAMERA_SET_TOPIC
        ):

            if payload == "PHOTO":

                threading.Thread(
                    target=capture_photo,
                    args=("mqtt",),
                    daemon=True
                ).start()


    except Exception as error:

        print(
            "MQTT command error:",
            error
        )


def mqtt_publish_environment():

    with state_lock:

        snapshot = dict(
            state
        )


    temperature = snapshot[
        "temperature"
    ]

    humidity = snapshot[
        "humidity"
    ]


    if temperature is not None:

        mqtt_client.publish(
            TEMP_TOPIC,
            str(temperature),
            retain=True
        )


    if humidity is not None:

        mqtt_client.publish(
            HUMIDITY_TOPIC,
            str(humidity),
            retain=True
        )


    payload = {

        "timestamp":
            snapshot[
                "sensor_time"
            ],

        "temperature":
            temperature,

        "humidity":
            humidity,

        "alarm":
            snapshot[
                "alarm"
            ],

        "fan_mode":
            snapshot[
                "fan_mode"
            ],

        "fan_on":
            snapshot[
                "fan_on"
            ]
    }


    mqtt_client.publish(
        ENV_TOPIC,

        json.dumps(
            payload,
            ensure_ascii=False
        ),

        retain=True
    )


    mqtt_client.publish(
        FAN_STATUS_TOPIC,

        json.dumps(
            {
                "mode":
                    snapshot[
                        "fan_mode"
                    ],

                "on":
                    snapshot[
                        "fan_on"
                    ]
            }
        ),

        retain=True
    )


    mqtt_client.publish(
        ALARM_STATUS_TOPIC,

        "ALARM"
        if snapshot[
            "alarm"
        ]
        else "NORMAL",

        retain=True
    )


def initialize_mqtt():

    mqtt_client.on_connect = (
        mqtt_on_connect
    )

    mqtt_client.on_disconnect = (
        mqtt_on_disconnect
    )

    mqtt_client.on_message = (
        mqtt_on_message
    )


    mqtt_client.reconnect_delay_set(
        min_delay=1,
        max_delay=30
    )


    if MQTT_USERNAME:

        mqtt_client.username_pw_set(
            MQTT_USERNAME,
            MQTT_PASSWORD
        )


    mqtt_client.connect_async(
        MQTT_BROKER,
        MQTT_PORT,
        60
    )


    mqtt_client.loop_start()


# =========================================================
# Flask
# =========================================================

app = Flask(
    __name__
)


@app.get("/")
def index():

    return render_template(
        "index.html"
    )


@app.get("/api/status")
def api_status():

    with state_lock:

        snapshot = dict(
            state
        )


    snapshot[
        "high_temp"
    ] = HIGH_TEMP


    snapshot[
        "hysteresis"
    ] = TEMP_HYSTERESIS


    return jsonify(
        snapshot
    )


@app.post("/api/fan")
def api_fan():

    data = (
        request.get_json(
            silent=True
        )
        or
        request.form
    )


    mode = data.get(
        "mode",
        ""
    )


    try:

        set_fan_mode(
            mode
        )


        mqtt_publish_environment()


        return jsonify(
            {
                "ok": True,
                "mode": mode.upper()
            }
        )


    except ValueError as error:

        return jsonify(
            {
                "ok": False,
                "error": str(error)
            }
        ), 400


@app.post("/api/buzzer")
def api_buzzer():

    data = (
        request.get_json(
            silent=True
        )
        or
        request.form
    )


    action = (
        data.get(
            "action",
            ""
        )
        .upper()
    )


    if action == "MUTE":

        set_buzzer_muted(
            True
        )


    elif action == "UNMUTE":

        set_buzzer_muted(
            False
        )


    else:

        return jsonify(
            {
                "ok": False,
                "error":
                    "Invalid action"
            }
        ), 400


    return jsonify(
        {
            "ok": True
        }
    )


@app.post("/api/camera")
def api_camera():

    try:

        filename = capture_photo(
            "web"
        )


        return jsonify(
            {
                "ok": True,
                "filename": filename,
                "url":
                    "/photos/"
                    +
                    filename
            }
        )


    except Exception as error:

        return jsonify(
            {
                "ok": False,
                "error": str(error)
            }
        ), 500


@app.get("/photos/<path:filename>")
def photo_file(
    filename
):

    return send_from_directory(
        PHOTO_DIR,
        filename
    )


@app.get("/api/history")
def api_history():

    limit = request.args.get(
        "limit",
        default=50,
        type=int
    )


    limit = max(
        1,
        min(
            limit,
            500
        )
    )


    with database_connect() as connection:

        cursor = connection.execute(
            """
            SELECT
                id,
                timestamp,
                temperature,
                humidity,
                alarm,
                fan_mode,
                fan_on

            FROM environment_data

            ORDER BY id DESC

            LIMIT ?
            """,
            (
                limit,
            )
        )


        rows = cursor.fetchall()


    result = []


    for row in rows:

        result.append(
            {
                "id": row[0],

                "timestamp":
                    row[1],

                "temperature":
                    row[2],

                "humidity":
                    row[3],

                "alarm":
                    bool(
                        row[4]
                    ),

                "fan_mode":
                    row[5],

                "fan_on":
                    bool(
                        row[6]
                    )
            }
        )


    return jsonify(
        result
    )


# =========================================================
# Start
# =========================================================

def start_services():

    initialize_database()

    initialize_lcd()

    initialize_camera()

    initialize_mqtt()


    threading.Thread(
        target=sensor_worker,
        daemon=True
    ).start()


    threading.Thread(
        target=lcd_worker,
        daemon=True
    ).start()


    save_event(
        "SYSTEM_START",
        "Smart environment started"
    )


# =========================================================
# Cleanup
# =========================================================

cleanup_done = False


def cleanup():

    global cleanup_done


    if cleanup_done:
        return


    cleanup_done = True


    print(
        "Cleaning up..."
    )


    stop_event.set()


    try:
        mqtt_client.loop_stop()
        mqtt_client.disconnect()
    except Exception:
        pass


    try:
        warning_led.off()
        buzzer.off()
        relay.off()
    except Exception:
        pass


    try:
        dht.exit()
    except Exception:
        pass


    try:

        if camera is not None:

            camera.stop()

            camera.close()

    except Exception:
        pass


    try:

        if lcd is not None:

            lcd.clear()

    except Exception:
        pass


atexit.register(
    cleanup
)


# =========================================================
# Main
# =========================================================

if __name__ == "__main__":

    print(
        "Raspberry Pi "
        "Smart Environment Center"
    )


    print(
        "Database:",
        DB_FILE
    )


    print(
        "Photos:",
        PHOTO_DIR
    )


    start_services()


    serve(
        app,
        host=WEB_HOST,
        port=WEB_PORT,
        threads=4
    )

二十七、這個 Python 程式做了什麼?

主程式可以拆成:

app.py
 │
 ├── Configuration
 │
 ├── GPIO
 │
 ├── DHT Sensor
 │
 ├── LCD1602
 │
 ├── Camera
 │
 ├── SQLite
 │
 ├── MQTT
 │
 ├── Flask
 │
 ├── Sensor Thread
 │
 ├── LCD Thread
 │
 └── Cleanup

不是把所有功能寫在:

while True

裡面。

這是從「練習程式」走向「系統程式」的重要改變。

二十八、SQLite 使用 WAL

程式初始化:

connection.execute(
    "PRAGMA journal_mode=WAL"
)

原因是我們同時可能有:

Sensor Thread → INSERT

Flask → SELECT

SQLite 官方指出 WAL 可以改善 Reader 與 Writer 的並行情況;不過 SQLite 同一時間仍然只會有一個 Write Transaction,因此仍應避免長時間持有寫入 Transaction。

本章每次:

Connect
→ INSERT
→ Commit
→ Close

都很短。

二十九、SQLite Parameterized Query

我們沒有把資料直接拼進 SQL。

而是:

VALUES
(
    ?,
    ?,
    ?,
    ?,
    ?,
    ?
)

然後傳入 Tuple。

Python 官方 sqlite3 文件建議使用 Placeholder / Parameter Substitution,而不是使用 String Formatting 把資料直接拼進 SQL。

三十、建立 Web Dashboard

建立:

nano ~/smart_env/templates/index.html

輸入:

<!DOCTYPE html>

<html lang="zh-TW">

<head>

    <meta charset="UTF-8">

    <meta
        name="viewport"
        content="width=device-width, initial-scale=1.0"
    >

    <title>
        Raspberry Pi 智慧環境監控中心
    </title>


    <style>

        * {
            box-sizing: border-box;
        }


        body {
            margin: 0;

            font-family:
                Arial,
                "Microsoft JhengHei",
                sans-serif;

            background: #f3f5f7;

            color: #222;
        }


        header {
            background: #222;

            color: white;

            padding: 24px;

            text-align: center;
        }


        .container {
            width: 94%;

            max-width: 1100px;

            margin: 30px auto;
        }


        .grid {
            display: grid;

            grid-template-columns:
                repeat(
                    auto-fit,
                    minmax(
                        220px,
                        1fr
                    )
                );

            gap: 18px;
        }


        .card {
            background: white;

            padding: 24px;

            border-radius: 14px;

            box-shadow:
                0 3px 14px
                rgba(
                    0,
                    0,
                    0,
                    0.10
                );
        }


        .title {
            font-size: 15px;

            color: #666;
        }


        .value {
            margin-top: 8px;

            font-size: 32px;

            font-weight: bold;
        }


        .normal {
            color: #16883d;
        }


        .alarm {
            color: #d32626;
        }


        button {
            border: 0;

            border-radius: 8px;

            padding: 13px 18px;

            margin:
                5px
                3px;

            font-size: 15px;

            cursor: pointer;
        }


        .auto {
            background: #1976d2;
            color: white;
        }


        .on {
            background: #1b9a4a;
            color: white;
        }


        .off {
            background: #d43b3b;
            color: white;
        }


        .secondary {
            background: #555;
            color: white;
        }


        .photo {
            max-width: 100%;

            border-radius: 10px;

            margin-top: 15px;
        }


        table {
            width: 100%;

            border-collapse: collapse;

            margin-top: 15px;
        }


        th,
        td {
            padding: 10px;

            border-bottom:
                1px solid #ddd;

            text-align: left;
        }


        .section {
            margin-top: 25px;
        }


        .small {
            color: #777;

            font-size: 14px;
        }

    </style>

</head>


<body>


<header>

    <h1>
        Raspberry Pi
        智慧環境監控中心
    </h1>

    <div>
        Flask + MQTT + SQLite
        + Camera
    </div>

</header>


<div class="container">


    <div class="grid">


        <div class="card">

            <div class="title">
                Temperature
            </div>

            <div
                id="temperature"
                class="value"
            >
                --
            </div>

        </div>


        <div class="card">

            <div class="title">
                Humidity
            </div>

            <div
                id="humidity"
                class="value"
            >
                --
            </div>

        </div>


        <div class="card">

            <div class="title">
                Alarm
            </div>

            <div
                id="alarm"
                class="value"
            >
                --
            </div>

        </div>


        <div class="card">

            <div class="title">
                Fan
            </div>

            <div
                id="fan"
                class="value"
            >
                --
            </div>

        </div>


    </div>


    <div class="card section">

        <h2>
            Fan Control
        </h2>


        <button
            class="auto"
            onclick="fanMode('AUTO')"
        >
            AUTO
        </button>


        <button
            class="on"
            onclick="fanMode('ON')"
        >
            FAN ON
        </button>


        <button
            class="off"
            onclick="fanMode('OFF')"
        >
            FAN OFF
        </button>


    </div>


    <div class="card section">

        <h2>
            Alarm Control
        </h2>


        <button
            class="secondary"
            onclick="buzzer('MUTE')"
        >
            MUTE BUZZER
        </button>


        <button
            class="secondary"
            onclick="buzzer('UNMUTE')"
        >
            UNMUTE
        </button>


    </div>


    <div class="card section">

        <h2>
            Camera
        </h2>


        <button
            class="auto"
            onclick="takePhoto()"
        >
            TAKE PHOTO
        </button>


        <div
            id="photoStatus"
            class="small"
        >
        </div>


        <img
            id="photo"
            class="photo"
            style="display:none"
            alt="Raspberry Pi Camera Photo"
        >

    </div>


    <div class="card section">

        <h2>
            System Status
        </h2>


        <p>
            MQTT:
            <strong id="mqtt">
                --
            </strong>
        </p>


        <p>
            Camera:
            <strong id="camera">
                --
            </strong>
        </p>


        <p>
            Fan Mode:
            <strong id="fanMode">
                --
            </strong>
        </p>


        <p>
            Sensor Time:
            <strong id="sensorTime">
                --
            </strong>
        </p>


        <p>
            Sensor Error:
            <strong id="sensorError">
                None
            </strong>
        </p>

    </div>


    <div class="card section">

        <h2>
            Latest History
        </h2>


        <table>

            <thead>

                <tr>

                    <th>
                        Time
                    </th>

                    <th>
                        Temp
                    </th>

                    <th>
                        Humidity
                    </th>

                    <th>
                        Fan
                    </th>

                </tr>

            </thead>


            <tbody id="history">

            </tbody>

        </table>

    </div>


</div>


<script>


async function loadStatus()
{

    try
    {

        const response =
            await fetch(
                "/api/status"
            );


        const data =
            await response.json();


        document
            .getElementById(
                "temperature"
            )
            .textContent =
                data.temperature === null
                ?
                "--"
                :
                data.temperature
                + " °C";


        document
            .getElementById(
                "humidity"
            )
            .textContent =
                data.humidity === null
                ?
                "--"
                :
                data.humidity
                + " %";


        const alarm =
            document.getElementById(
                "alarm"
            );


        if (data.alarm)
        {

            alarm.textContent =
                "ALARM";

            alarm.className =
                "value alarm";

        }
        else
        {

            alarm.textContent =
                "NORMAL";

            alarm.className =
                "value normal";

        }


        document
            .getElementById(
                "fan"
            )
            .textContent =
                data.fan_on
                ?
                "ON"
                :
                "OFF";


        document
            .getElementById(
                "fanMode"
            )
            .textContent =
                data.fan_mode;


        document
            .getElementById(
                "mqtt"
            )
            .textContent =
                data.mqtt_connected
                ?
                "CONNECTED"
                :
                "DISCONNECTED";


        document
            .getElementById(
                "camera"
            )
            .textContent =
                data.camera_ready
                ?
                "READY"
                :
                "NOT READY";


        document
            .getElementById(
                "sensorTime"
            )
            .textContent =
                data.sensor_time
                || "--";


        document
            .getElementById(
                "sensorError"
            )
            .textContent =
                data.sensor_error
                || "None";


        if (data.last_photo)
        {

            const photo =
                document
                .getElementById(
                    "photo"
                );


            photo.src =
                "/photos/"
                +
                data.last_photo
                +
                "?t="
                +
                Date.now();


            photo.style.display =
                "block";

        }

    }
    catch (error)
    {

        console.error(
            error
        );

    }

}


async function fanMode(
    mode
)
{

    await fetch(
        "/api/fan",
        {
            method:
                "POST",

            headers:
            {
                "Content-Type":
                    "application/json"
            },

            body:
                JSON.stringify(
                    {
                        mode: mode
                    }
                )
        }
    );


    loadStatus();

}


async function buzzer(
    action
)
{

    await fetch(
        "/api/buzzer",
        {
            method:
                "POST",

            headers:
            {
                "Content-Type":
                    "application/json"
            },

            body:
                JSON.stringify(
                    {
                        action: action
                    }
                )
        }
    );


    loadStatus();

}


async function takePhoto()
{

    const status =
        document
        .getElementById(
            "photoStatus"
        );


    status.textContent =
        "Capturing...";


    const response =
        await fetch(
            "/api/camera",
            {
                method:
                    "POST"
            }
        );


    const data =
        await response.json();


    if (data.ok)
    {

        status.textContent =
            "Saved: "
            +
            data.filename;


        const photo =
            document
            .getElementById(
                "photo"
            );


        photo.src =
            data.url
            +
            "?t="
            +
            Date.now();


        photo.style.display =
            "block";

    }
    else
    {

        status.textContent =
            data.error;

    }

}


async function loadHistory()
{

    try
    {

        const response =
            await fetch(
                "/api/history?limit=20"
            );


        const rows =
            await response.json();


        const tbody =
            document
            .getElementById(
                "history"
            );


        tbody.innerHTML =
            "";


        for (const row of rows)
        {

            const tr =
                document
                .createElement(
                    "tr"
                );


            tr.innerHTML =
                "<td>"
                +
                row.timestamp
                +
                "</td>"
                +
                "<td>"
                +
                row.temperature
                +
                " °C</td>"
                +
                "<td>"
                +
                row.humidity
                +
                " %</td>"
                +
                "<td>"
                +
                (
                    row.fan_on
                    ?
                    "ON"
                    :
                    "OFF"
                )
                +
                "</td>";


            tbody.appendChild(
                tr
            );

        }

    }
    catch (error)
    {

        console.error(
            error
        );

    }

}


loadStatus();

loadHistory();


setInterval(
    loadStatus,
    3000
);


setInterval(
    loadHistory,
    30000
);


</script>


</body>

</html>

三十一、啟動 Mosquitto

執行:

sudo systemctl enable mosquitto

啟動:

sudo systemctl start mosquitto

查看:

systemctl status mosquitto

本章主程式預設連:

127.0.0.1:1883

也就是 Raspberry Pi 自己的 Broker。

三十二、為什麼這次 MQTT Broker 用 localhost?

因為:

Python
+
Mosquitto

都在同一台 Raspberry Pi。

所以最基本架構:

Python
   │
   ▼
127.0.0.1:1883
   │
   ▼
Mosquitto

即使外部 LAN Client 暫時不能直接連 Broker,Raspberry Pi 主程式本身仍可正常運作。

如果需要讓其他電腦直接連入 MQTT,再依第 18 章建立:

Listener
Authentication
ACL
TLS

Mosquitto 的 Listener 與 Authentication 行為應明確設定;官方也提供 Password、ACL 與其他驗證方式。

三十三、不要把 Anonymous MQTT 直接放到 Internet

如果設定:

allow_anonymous true

再把:

1883

Port Forward 到 Internet,就可能讓未授權使用者:

讀取溫度
控制風扇
控制蜂鳴器
觸發 Camera

本章的 IoT Control Topic 已經具備實際硬體控制能力,因此正式外網部署一定要加入:

Authentication
ACL
TLS
Firewall
VPN

三十四、先測試 Camera

在執行完整程式前:

rpicam-hello

如果 Camera 正常,應可以測試。

拍照:

rpicam-jpeg \
-o test.jpg

Bookworm 之後 Raspberry Pi 官方 Camera Apps 使用 rpicam-* 名稱。

三十五、先測試 I²C LCD

執行:

i2cdetect -y 1

例如:

0  1  2  3  4  5  6  7
20: -- -- -- -- -- -- -- 27

表示:

0x27

如果得到:

3f

修改:

LCD_ADDRESS=0x3F

三十六、執行完整系統

啟用 Virtual Environment:

cd ~/smart_env
source venv/bin/activate

執行:

python app.py

可能看到:

Raspberry Pi Smart Environment Center

Database:
/home/user/smart_env/data/environment.db

Photos:
/home/user/smart_env/photos

LCD ready: 0x27

Camera ready

MQTT connected: Success

三十七、查 Raspberry Pi IP

執行:

hostname -I

假設:

192.168.1.100

三十八、手機開啟 Dashboard

手機與 Raspberry Pi 位於可以彼此通訊的 LAN。

Browser 輸入:

http://192.168.1.100:8000

即可看到:

Raspberry Pi 智慧環境監控中心

三十九、Sensor Data Flow

完整溫溼度資料流:

DHT22
  │
  ▼
GPIO4
  │
  ▼
Python
  │
  ├── Shared State
  │
  ├── LCD1602
  │
  ├── Alarm Control
  │
  ├── SQLite
  │
  ├── MQTT
  │
  └── Flask

同一筆 Sensor Data 可以服務多個系統。

四十、LCD 資料流

DHT22
 │
 ▼
Python State
 │
 ▼
LCD Thread
 │
 ▼
LCD1602

例如:

T:28.5C H:67%
AUTO FAN:OFF

四十一、高溫警報流程

假設:

HIGH_TEMP = 30°C

當:

Temperature = 30.2°C

流程:

DHT22
 │
 ▼
30.2°C
 │
 ▼
Python
 │
 ▼
Alarm = True
 │
 ├── Warning LED ON
 │
 ├── Buzzer ON
 │
 ├── AUTO Fan ON
 │
 ├── SQLite
 │
 ├── MQTT ALARM
 │
 └── Web ALARM

這就是完整 Event Response。

四十二、遲滯 Hysteresis

如果:

HIGH_TEMP = 30°C
HYSTERESIS = 1°C

代表:

30°C
 │
 ▼
Alarm ON

但不會在:

29.9°C

馬上關閉。

必須降到:

29°C

才解除。

形成:

Temp >= 30
     │
     ▼
    ON
     │
  29~30
     │
 保持狀態
     │
Temp <= 29
     │
     ▼
    OFF

這能降低 Relay 在臨界值附近頻繁切換。

四十三、AUTO Fan

當:

Fan Mode = AUTO

控制權交給:

Temperature Logic

高溫:

Relay ON

恢復:

Relay OFF

四十四、Manual ON

手機:

[ FAN ON ]

會:

POST /api/fan

mode = ON

Python:

fan_mode = ON

Relay:

ON

即使目前溫度只有:

25°C

風扇仍會保持開啟。

四十五、Manual OFF

按:

[ FAN OFF ]

則:

Fan Mode = OFF

Relay 強制:

OFF

注意:

如果風扇負責真正設備安全,不應允許遠端 OFF 覆蓋安全保護。

本章是教學環境,因此保留 Manual Override。

正式工業系統應另外設計:

Safety Interlock
Emergency Rule
Fail-Safe

四十六、恢復 AUTO

按:

[ AUTO ]

控制權重新交給溫度演算法。

如果此刻:

Temperature = 31°C

則風扇會立即:

ON

如果:

Temperature = 26°C

則:

OFF

四十七、Buzzer Mute

高溫時:

Alarm = True

LED 仍會保持警示。

Fan 仍正常工作。

但是按:

MUTE BUZZER

只停止:

Buzzer

而不是把整個 Alarm 清除。

這比把:

Alarm=False

更合理。

四十八、SQLite Database Schema

本章建立:

environment_data

欄位:

id
timestamp
temperature
humidity
alarm
fan_mode
fan_on

例如:

SQLite environment_data 紀錄範例
id timestamp temp hum alarm mode fan
1 08:00 28.5 67 0 AUTO 0
2 08:01 29.6 66 0 AUTO 0
3 08:02 30.2 65 1 AUTO 1

從這些資料就能看出:

什麼時候變熱?

什麼時候開始警報?

什麼時候 Fan ON?

四十九、System Event Table

另外建立:

system_events

記錄:

SYSTEM_START

ALARM_ON

ALARM_OFF

FAN_MODE

BUZZER_MUTE

CAMERA_PHOTO

例如:

2026-08-18T09:10:00+08:00
ALARM_ON
Temperature 30.2 C

這讓 Database 不只是 Sensor Log,也具有:

Event Log。

五十、查看 SQLite

安裝:

sudo apt install -y sqlite3

查看:

sqlite3 \
~/smart_env/data/environment.db

輸入:

.headers on
.mode column

然後:

SELECT *
FROM environment_data
ORDER BY id DESC
LIMIT 20;

五十一、查最高溫

SELECT
    MAX(temperature)
FROM environment_data;

五十二、查平均溫度

SELECT
    AVG(temperature)
FROM environment_data;

五十三、查高溫事件

SELECT
    timestamp,
    temperature,
    humidity,
    fan_mode,
    fan_on
FROM environment_data
WHERE alarm = 1
ORDER BY timestamp DESC;

五十四、MQTT 監控

另開 Terminal:

mosquitto_sub \
-h localhost \
-t 'home/room1/#' \
-v

可能看到:

home/room1/temperature 28.5

home/room1/humidity 67.2

home/room1/alarm/status NORMAL

home/room1/fan/status {"mode":"AUTO","on":false}

五十五、MQTT 環境 JSON

Topic:

home/room1/environment

可能得到:

{
  "timestamp": "2026-08-18T09:20:00+08:00",
  "temperature": 28.5,
  "humidity": 67.2,
  "alarm": false,
  "fan_mode": "AUTO",
  "fan_on": false
}

這種 JSON 很適合:

Node-RED
Home Assistant
Server
Dashboard
Database Gateway

進一步處理。

五十六、MQTT 遠端控制 Fan

AUTO:

mosquitto_pub \
-h localhost \
-t home/room1/fan/set \
-m AUTO

Fan ON:

mosquitto_pub \
-h localhost \
-t home/room1/fan/set \
-m ON

Fan OFF:

mosquitto_pub \
-h localhost \
-t home/room1/fan/set \
-m OFF

五十七、MQTT 控制 Buzzer

靜音:

mosquitto_pub \
-h localhost \
-t home/room1/buzzer/set \
-m MUTE

恢復:

mosquitto_pub \
-h localhost \
-t home/room1/buzzer/set \
-m UNMUTE

五十八、MQTT Camera 拍照

輸入:

mosquitto_pub \
-h localhost \
-t home/room1/camera/set \
-m PHOTO

Python:

MQTT
 │
 ▼
camera/set
 │
 ▼
PHOTO
 │
 ▼
Picamera2
 │
 ▼
Capture

照片存到:

~/smart_env/photos/

五十九、查看照片

執行:

ls -lh \
~/smart_env/photos

可能看到:

20260818_092000.jpg

20260818_092500.jpg

20260818_093000.jpg

六十、Web Camera Data Flow

手機:

TAKE PHOTO
     │
     ▼
POST /api/camera
     │
     ▼
Flask
     │
     ▼
capture_photo()
     │
     ▼
Picamera2
     │
     ▼
Camera
     │
     ▼
JPEG
     │
     ▼
Browser

因此第 16 章的 Camera 已正式整合到 Web IoT 系統。

六十一、為什麼 Camera 使用 Lock?

程式:

camera_lock

是因為 Camera 可能同時被:

Web
MQTT

要求拍照。

如果兩個 Request 同時操作 Camera,可能造成競爭。

所以:

Web ──┐
      ├── Camera Lock ── Camera
MQTT ─┘

確保同一時間只進行一個 Camera Capture。

六十二、為什麼 GPIO 不應使用多個 Web Worker?

這套系統有:

GPIO
DHT
Camera

等硬體 Singleton。

如果開:

4 Processes

每個 Process 都初始化:

GPIO17
GPIO27
Camera

容易產生衝突。

因此本章使用:

Single Process
+
Multiple Threads

架構。

六十三、完整 Thread 架構

Python Process
                         │
       ┌─────────────────┼─────────────────┐
       │                 │                 │
 Sensor Thread        MQTT Loop        Web Threads
       │                 │                 │
       ▼                 ▼                 ▼
     DHT22          Mosquitto          Flask API
       │
       ▼
Shared State
       │
       └──────────► LCD Thread

這種架構非常適合 Raspberry Pi 單機 IoT 專題。

六十四、Web API

目前建立:

GET
/api/status

取得目前狀態。

POST
/api/fan

控制 Fan。

POST
/api/buzzer

控制 Buzzer Mute。

POST
/api/camera

拍照。

GET
/api/history

取得 SQLite 歷史資料。

六十五、測試 Status API

在另一台電腦:

http://192.168.1.100:8000/api/status

會得到 JSON:

{
  "temperature": 28.5,
  "humidity": 67.2,
  "alarm": false,
  "fan_mode": "AUTO",
  "fan_on": false,
  "buzzer_muted": false,
  "mqtt_connected": true,
  "camera_ready": true
}

這代表未來其他 Application 也可以呼叫 Raspberry Pi API。

六十六、查看 History API

例如:

http://192.168.1.100:8000/api/history?limit=20

取得最近 20 筆資料。

因此架構又多了一層:

SQLite
  │
  ▼
Python
  │
  ▼
REST-like API
  │
  ▼
Other Application

六十七、SSH 遠端維護

先啟用 SSH。

Raspberry Pi 官方文件提供透過 Raspberry Pi Imager、系統介面或命令列等方式啟用 SSH。

也可以在 Raspberry Pi 設定工具中啟用 SSH。

之後查 IP:

hostname -I

例如:

192.168.1.100

六十八、Windows SSH

Windows Terminal / PowerShell:

ssh jason@192.168.1.100

將:

jason

換成 Raspberry Pi 實際 Username。

六十九、SSH 金鑰

長期管理建議改用:

SSH Key Authentication

Raspberry Pi 官方安全文件也建議使用 Key-Based Authentication 提高 SSH 安全性。

因此未來可以逐步從:

Password Login

進階到:

SSH Public Key

七十、建立 systemd Service

先確認 Username:

whoami

假設:

jason

則專案:

/home/jason/smart_env

建立:

sudo nano \
/etc/systemd/system/smart-environment.service

七十一、systemd Service 內容

假設 Username 是:

jason

內容:

[Unit]
Description=Raspberry Pi Smart Environment Center
After=network-online.target mosquitto.service
Wants=network-online.target


[Service]
Type=simple

User=jason

WorkingDirectory=/home/jason/smart_env

ExecStart=/home/jason/smart_env/venv/bin/python /home/jason/smart_env/app.py

Restart=on-failure

RestartSec=5


[Install]
WantedBy=multi-user.target

如果你的 Username 不叫:

jason

必須全部換掉。

七十二、為什麼不要固定寫 pi?

很多舊 Raspberry Pi 教學會直接寫:

User=pi

但現在安裝 Raspberry Pi OS 時 Username 可以由使用者自行設定。

所以應先:

whoami

再把真正帳號放入:

User=

以及:

/home/USERNAME/

七十三、重新讀取 systemd 設定

建立 Service 後:

sudo systemctl daemon-reload

systemd 官方文件指出修改 Unit 後可使用 daemon-reload 重新載入設定。

七十四、啟動 Service

sudo systemctl start \
smart-environment.service

查看:

systemctl status \
smart-environment.service

應該看到:

active (running)

七十五、設定開機啟動

執行:

sudo systemctl enable \
smart-environment.service

Unit 使用:

WantedBy=multi-user.target

可以讓 Service 被相應 Target 啟用;systemd 官方文件也以 WantedBy=multi-user.target 作為一般開機服務啟用方式。

七十六、重新開機驗收

執行:

sudo reboot

等待 Raspberry Pi 回到網路。

然後手機直接開:

http://192.168.1.100:8000

如果不需要 SSH 手動執行:

python app.py

就可以開啟 Dashboard,代表:

開機自動啟動成功

七十七、查看 Service Log

如果沒有啟動:

journalctl \
-u smart-environment.service \
--no-pager

查看最近:

journalctl \
-u smart-environment.service \
-n 100 \
--no-pager

即時追蹤:

journalctl \
-u smart-environment.service \
-f

七十八、修改程式後如何重新啟動?

修改:

nano ~/smart_env/app.py

然後:

sudo systemctl restart \
smart-environment.service

查看:

systemctl status \
smart-environment.service

七十九、完整開機流程

現在整個 Raspberry Pi 啟動:

Power ON
  │
  ▼
Firmware
  │
  ▼
Linux Kernel
  │
  ▼
Raspberry Pi OS
  │
  ▼
systemd
  │
  ├── Mosquitto
  │
  └── smart-environment.service
           │
           ▼
         app.py
           │
           ├── SQLite
           ├── LCD
           ├── Camera
           ├── MQTT
           ├── Sensor Thread
           └── Waitress / Flask

這已經是一個真正可以:

無螢幕獨立運作的 Raspberry Pi IoT Appliance。

八十、完整 Sensor Pipeline

DHT22
   │
   ▼
GPIO4
   │
   ▼
Python
   │
   ▼
Shared State
   │
   ├────► LCD
   │
   ├────► Alarm
   │
   ├────► Relay
   │
   ├────► SQLite
   │
   ├────► MQTT
   │
   └────► Flask

這是本章最重要的資料流。

八十一、完整控制 Pipeline

Web:

Smartphone
    │
    ▼
HTTP POST
    │
    ▼
Waitress
    │
    ▼
Flask
    │
    ▼
Python
    │
    ▼
GPIO
    │
    ▼
Relay
    │
    ▼
Fan

MQTT:

MQTT Client
    │
    ▼
Mosquitto
    │
    ▼
Python Subscriber
    │
    ▼
GPIO
    │
    ▼
Relay

本機自動控制:

DHT22
  │
  ▼
Temperature Logic
  │
  ▼
Relay

所以同一個設備同時具有:

Local Automatic Control

Web Control

MQTT Control

八十二、為什麼 Local Control 最重要?

假設:

Wi-Fi Down

MQTT Remote Client 不能連。

手機 Web 也不能連。

但是:

DHT22
  │
  ▼
Python
  │
  ▼
Temperature Alarm
  │
  ▼
Relay Fan

仍然可以工作。

這是智慧設備很重要的設計觀念:

核心安全功能應盡量能在本機獨立運作

而不是網路斷線後,連最基本的溫度控制都停止。

八十三、系統三層架構

可以把本專案理解成三層。

Device Layer

DHT22
LED
Buzzer
Relay
Camera
LCD

Application Layer

Python
Control Logic
SQLite
MQTT
Flask

User / Network Layer

Browser
Smartphone
MQTT Client
SSH

形成:

User Layer
    │
    ▼
Application Layer
    │
    ▼
Hardware Layer

八十四、SQLite 為什麼適合這個專案?

這個專案主要是:

1 個 Raspberry Pi

1 個 Sensor Logger

少量 Web Reader

SQLite 非常適合。

SQLite 支援同時存在多個 Reader,但單一時間只有一個 Writer;這和本章「單一 Sensor Logger 寫入、Web 查詢」的模式相當吻合。

八十五、什麼時候應該換 MySQL / PostgreSQL?

如果未來變成:

100 Raspberry Pi

1000 IoT Devices

很多使用者

高頻寫入

中央 Server

架構可以從:

Local SQLite

升級成:

MySQL
MariaDB
PostgreSQL
Time-Series Database

但對目前:

單台 Raspberry Pi Edge Monitoring

SQLite 能讓架構保持非常簡潔。

八十六、Database 成長問題

如果:

DATABASE_INTERVAL=60

表示:

每分鐘一筆

一天:

1440 筆

一年:

525600 筆

因此長期系統應開始考慮:

Retention
Backup
Archive
CSV Export
Database Maintenance

八十七、照片容量更值得注意

Database 記錄相對小。

Camera JPEG 則可能快速佔用 Storage。

因此正式系統應加入:

Photo Retention

例如只保留:

30 Days

或者定期同步到:

NAS
Server
Cloud Storage

八十八、建議加入 Disk Monitoring

未來可以增加:

import shutil


total, used, free = (
    shutil.disk_usage("/")
)

並顯示:

Disk Usage
65 %

當:

Disk > 90%

發出:

MQTT Alarm

這會讓智慧監控系統更加完整。

八十九、建議增加 CPU Temperature

Raspberry Pi 本身也有運作溫度。

未來 Dashboard 可以增加:

Room Temperature

Raspberry Pi CPU Temperature

如此可以同時監控:

Environment
+
Raspberry Pi Health

九十、建議加入 Online Status

MQTT 還可以加入:

home/room1/system/status

例如:

ONLINE

搭配:

Last Will and Testament

可進一步判斷 Raspberry Pi 是否突然離線。

這是下一階段 IoT 系統很值得加入的功能。

九十一、建議加入 Camera Event

例如:

Temperature >= 35°C

不只是:

Alarm
Fan

而是:

Temperature >= 35°C
        │
        ├── LED
        ├── Buzzer
        ├── Fan
        ├── MQTT Alarm
        └── Camera Photo

形成:

High Temperature Evidence Capture。

九十二、建議加入 Email / LINE / Telegram Notification

下一階段:

Alarm
 │
 ▼
Notification Service
 │
 ├── Email
 ├── LINE
 └── Telegram

如此即使不開 Dashboard,也可以收到:

Server Room Temperature:
35.2°C

Fan:
ON

Alarm:
ACTIVE

九十三、Web 安全

目前:

/api/fan
/api/buzzer
/api/camera

沒有 Login。

所以本章應限定:

可信任 LAN

不要直接做:

Router Port Forward
Internet
    │
    ▼
Raspberry Pi :8000

Flask 官方也明確區分 Development 與 Production Deployment;真正外網服務應使用正式 WSGI Deployment,並另外加入 HTTPS、Authentication、Authorization 等安全層。

九十四、如果真的要從 Internet 控制

比較合理的架構:

Internet
   │
   ▼
VPN
   │
   ▼
Home LAN
   │
   ▼
Raspberry Pi

或者:

Internet
   │
   ▼
HTTPS Reverse Proxy
   │
   ▼
Authentication
   │
   ▼
Flask Application

而不是直接把控制 Port 暴露。

九十五、MQTT 正式架構

測試:

LAN
+
localhost MQTT

正式可以變成:

MQTT Client
    │
    ▼
TLS
    │
    ▼
Authentication
    │
    ▼
Mosquitto
    │
    ▼
ACL
    │
    ▼
Allowed Topic

Mosquitto 官方文件提供 Listener、Anonymous Access、Password / ACL 等驗證與存取控制機制。

九十六、SSH 安全

SSH 也不要停留在:

Weak Password

建議:

Strong Password
      ↓
SSH Key
      ↓
限制登入帳號
      ↓
Firewall

Raspberry Pi 官方 Operational Security 文件也建議使用 Key-Based Authentication 改善 SSH 安全性。

九十七、故障排除:DHT 沒有資料

先獨立測試 DHT。

不要一開始就 Debug:

DHT
+
LCD
+
MQTT
+
SQLite
+
Flask

先確認:

VCC
GND
GPIO4
Sensor Type
Library

九十八、故障排除:LCD 不顯示

執行:

i2cdetect -y 1

如果沒有:

27

或:

3f

優先檢查:

I²C
SDA
SCL
VCC
GND

如果有 Address,但沒有文字:

檢查:

LCD_ADDRESS
PCF8574
Library
Contrast

九十九、故障排除:Relay 動作相反

如果程式顯示:

Fan OFF

但 Relay 卻吸合。

修改:

RELAY_ACTIVE_HIGH

例如:

0

改:

1

或反過來。

不要用:

把程式 ON/OFF 全部倒過來

的方式硬改。

一百、故障排除:Camera Not Ready

先:

rpicam-hello

如果這一步都失敗,就先檢查:

Camera Cable
CSI Connector
Camera Module
Raspberry Pi OS

不要先 Debug Flask。

Raspberry Pi 官方 Camera 文件也建議以現行 Camera Stack 與 rpicam-* 工具進行 Camera 操作。

一百零一、故障排除:MQTT Disconnected

查看:

systemctl status mosquitto

測試:

mosquitto_sub \
-h localhost \
-t test

另一個 Terminal:

mosquitto_pub \
-h localhost \
-t test \
-m hello

如果這一步不正常,先修 Broker。

一百零二、故障排除:Web 無法開啟

查看 Service:

systemctl status \
smart-environment.service

查 IP:

hostname -I

再確認網址:

http://IP:8000

例如:

http://192.168.1.100:8000

一百零三、故障排除:Service 一直 Restart

查看:

journalctl \
-u smart-environment.service \
-n 100 \
--no-pager

通常會看到真正 Python Error,例如:

ModuleNotFoundError

PermissionError

Camera Error

GPIO Error

Database Error

一百零四、故障排除:Database locked

本章已使用:

短 Transaction
+
busy_timeout
+
WAL

降低發生機率。

SQLite WAL 可以改善 Reader / Writer 併行情況,但仍只有單一 Writer,因此不要讓某個 Thread 長時間開著 Write Transaction。

一百零五、建議完整驗收順序

不要一次把全部零件裝上去才開始測試。

建議:

1. Raspberry Pi OS
       ↓
2. GPIO LED
       ↓
3. Buzzer
       ↓
4. Relay
       ↓
5. DHT11 / DHT22
       ↓
6. LCD1602
       ↓
7. Camera
       ↓
8. SQLite
       ↓
9. MQTT
       ↓
10. Flask
       ↓
11. 完整 app.py
       ↓
12. systemd
       ↓
13. Reboot Test

這是本章非常重要的實作原則:

分層驗收

一百零六、第一階段驗收

確認:

DHT22
PASS

看到:

28.5°C
67%

一百零七、第二階段驗收

確認:

Temperature >= 30°C

可以:

LED ON
Buzzer ON
Relay ON

一百零八、第三階段驗收

確認 SQLite:

SELECT *
FROM environment_data
ORDER BY id DESC
LIMIT 5;

有持續新增。

一百零九、第四階段驗收

確認:

mosquitto_sub \
-h localhost \
-t 'home/room1/#' \
-v

有:

Temperature
Humidity
Fan Status
Alarm Status

一百一十、第五階段驗收

手機:

http://Raspberry-Pi-IP:8000

可以看到即時資料。

一百一十一、第六階段驗收

手機:

FAN ON
FAN OFF
AUTO

Relay 正確操作。

一百一十二、第七階段驗收

按:

TAKE PHOTO

照片:

~/smart_env/photos/

正確產生。

一百一十三、第八階段驗收

執行:

sudo reboot

重新開機後:

Mosquitto
+
Smart Environment Service

自動起來。

一百一十四、最後完整驗收表

第 20 章完整功能驗收表
功能 驗收
DHT 溫度 PASS
DHT 濕度 PASS
LCD 顯示 PASS
Warning LED PASS
Buzzer PASS
Relay PASS
AUTO Fan PASS
Web Fan ON PASS
Web Fan OFF PASS
Web AUTO PASS
SQLite PASS
MQTT Publish PASS
MQTT Control PASS
Camera PASS
SSH PASS
systemd PASS
Reboot Auto Start PASS

全部 PASS:

專題完成

一百一十五、回顧前 20 章的學習路徑

這個系列不是 20 個互不相關的小實驗。

真正的目的,是逐步形成這個架構:

Raspberry Pi OS
      │
      ▼
    Linux
      │
      ▼
    Python
      │
      ▼
     GPIO
      │
      ├── Input
      ├── Output
      ├── PWM
      ├── Sensor
      ├── I²C
      ├── SPI
      └── UART
             │
             ▼
          Camera
             │
             ▼
           Flask
             │
             ▼
            MQTT
             │
             ▼
           SQLite
             │
             ▼
    Smart Environment Center

一百一十六、從 GPIO 到完整 IoT

一開始:

Python
 ↓
LED

後來:

Sensor
 ↓
Python
 ↓
GPIO

再來:

Smartphone
 ↓
Flask
 ↓
Python
 ↓
GPIO

接著:

IoT Client
 ↓
MQTT
 ↓
Raspberry Pi

最後:

Raspberry Pi
                          │
         ┌────────────────┼────────────────┐
         │                │                │
      Hardware          Network           Data
         │                │                │
         ▼                ▼                ▼
   Sensor / Relay     Flask / MQTT      SQLite
         │                │                │
         └────────────────┼────────────────┘
                          │
                          ▼
                    Smart System

這就是完整的 Raspberry Pi 學習成果。

一百一十七、這套專題可以實際用在哪裡?

只要更換 Sensor 或 Relay 後端,就可以變成很多產品。

例如:

機房監控

Temperature
Humidity
Fan
Alarm
Camera

溫室監控

Temperature
Humidity
Pump
Fan
Camera

智慧家庭

Room Sensor
Light
Fan
Camera
Web
MQTT

電子設備櫃

Temperature
Fan
Buzzer
Remote Dashboard
History

實驗室

Environment
Alert
History
Camera
Remote Control

一百一十八、加入更多 Sensor

未來可以增加:

BME280
BMP280
CO2 Sensor
PM2.5 Sensor
Light Sensor
PIR
Water Sensor
Gas Sensor
Door Sensor

架構完全不用推翻。

只需要:

Sensor Adapter
      │
      ▼
Shared State

接入現有系統即可。

一百一十九、加入 AI Camera

第 16 章的 Camera 還可以再進一步:

Camera
   │
   ▼
OpenCV
   │
   ▼
YOLO
   │
   ▼
Object Detection

變成:

Smart Environment
+
AI Vision

例如:

人員進入

煙霧異常

設備狀態

物體偵測

一百二十、加入資料圖表

目前 History 顯示 Table。

下一步可以使用:

Chart.js

建立:

Temperature Curve
Humidity Curve
Fan Status Chart

Dashboard 就會變成:

Current Status
+
Historical Trend

一百二十一、加入每日報表

SQLite 可以每天統計:

MIN(temperature)

MAX(temperature)

AVG(temperature)

產生:

Daily Environment Report

例如:

2026-08-18

Min Temperature: 25.8°C

Max Temperature: 31.4°C

Average: 28.2°C

Max Humidity: 78%

Alarm Count: 3

一百二十二、加入多房間

目前 Topic:

home/room1

增加另一台:

home/room2

第三台:

home/room3

中央系統:

Room1 Pi ──┐
           │
Room2 Pi ──┼── MQTT Broker
           │
Room3 Pi ──┘
                │
                ▼
             Dashboard

就能形成:

Multi-Room IoT Monitoring System

一百二十三、加入中央 Server

再進一步:

Raspberry Pi #1
       │
Raspberry Pi #2
       │
Raspberry Pi #3
       │
       ▼
 MQTT Broker
       │
       ▼
Central Server
       │
       ├── Database
       ├── Dashboard
       ├── Alert
       └── Analytics

Raspberry Pi 就從:

Server

逐步轉成:

Edge Node

一百二十四、本章最重要的觀念

學完 Raspberry Pi 之後,最重要的不是記住:

GPIO17

也不是記住:

led.on()

而是知道如何把:

Sensor
Control
Network
Database
Web
Camera
Service

整合成一個可以長期運作的:

System

真正工程實作的價值就在這裡。

一百二十五、本章完成成果

完成本章後,你已經建立:

Raspberry Pi 智慧環境監控中心

包含:

Sensor

DHT11 / DHT22

Local Display

LCD1602 I²C

Alarm

LED
Buzzer

Automatic Control

Relay
Fan

Database

SQLite

Web

Flask
Waitress
HTML
JavaScript

IoT

Mosquitto
MQTT

Vision

Camera
Picamera2

Remote Maintenance

SSH

System Service

systemd

真正完成:

Hardware
+
Software
+
Network
+
Database
+
Web
+
IoT
+
Camera
+
Automation

一百二十六、20 章最終總結

從第一章開始,我們逐步讓 Raspberry Pi 從:

一台 Linux 小電腦

變成:

GPIO Controller

再變成:

Sensor Controller

接著成為:

Web Server

然後進入:

IoT Gateway

最後完成:

Raspberry Pi Smart Environment IoT Edge Server

這也代表你已經不再只是「會操作 Raspberry Pi」。

而是已經掌握一套完整的:

Raspberry Pi IoT 系統設計基礎。

下一步學習

不要停在這一篇,接著把內容做成自己的實作

目前主題:ESP32 / IoT。從完整學習路線、免費資源、會員下載,再接到實作所需材料。

同系列:Raspberry-Pi |  已是會員?直接前往 Getfile
相關資源與會員文章下載
文章可免費閱讀;下載完整 DOCX 或 PDF 需登入免費基本會員。
查看相關資源 會員免費下載 DOCX 會員免費 PDF/列印

← 回文章列表