顯示具有 IoT 標籤的文章。 顯示所有文章
顯示具有 IoT 標籤的文章。 顯示所有文章

2026年10月6日 星期二

MQTT 與 IoT 雙向通訊|Pico 2 W

Pico 2 W × Thonny|第 6 次課程

MQTT 與 IoT 雙向通訊

前一篇已把感測資料整理成 JSON。本篇將資料放進 MQTT 訊息,讓 Pico 2 W 發布感測資料,也訂閱控制主題接收命令。這樣裝置不只會「上傳」,也能從網路接收控制,形成 IoT 的雙向通訊。

一、MQTT 的三個角色

MQTT 使用 Broker(訊息代理伺服器)中轉訊息。Publisher 將訊息發布到某個 Topic;Subscriber 訂閱感興趣的 Topic。Broker 依照訂閱關係轉送訊息,因此 Publisher 通常不需要知道每一個 Subscriber 的網路位置。

Publisher 發布者把訊息送到指定 Topic,例如 Pico 上傳感測資料。
Broker 代理伺服器接收、管理並轉送訊息,是通訊中介。
Subscriber 訂閱者訂閱 Topic 並接收訊息,例如電腦 Dashboard。

同一台 Pico 可以同時扮演 Publisher 與 Subscriber:它發布感測值,也訂閱控制命令。

二、Topic 是訊息的分類路徑

Topic 用來標示訊息的類別或用途。好的命名方式能看出位置、設備與功能,讓多個裝置加入時仍然容易管理。

farm/room01/pico01/telemetry
farm/room01/pico01/status
farm/room01/pico01/control

farm/room02/pico02/telemetry
  • telemetry:感測器或設備定期發布的遙測資料。
  • status:設備目前狀態。
  • control:送往設備的控制命令。

Topic 名稱要在發布端和訂閱端完全一致;斜線分隔的層級也要保持規則一致。

三、Publish:Pico 發布感測資料

發布時要指定 Topic 和 Payload(訊息內容)。Topic 說明這是哪一類訊息,Payload 則放實際資料。本課程沿用上一堂的 JSON 格式,因此接收端可以依照欄位名稱解析資料。

import json

topic_telemetry = b"farm/room01/pico01/telemetry"

# 這些數字只用來示範資料格式
payload = {
    "temperature": 28.3,
    "humidity": 71.2
}

client.publish(
    topic_telemetry,
    json.dumps(payload)
)

範例中的數字只是格式示意,不是實際場域資料。實作時應換成感測器讀值,並確認欄位名稱、型態與單位已事先約定。

四、Subscribe:Pico 訂閱控制命令

Dashboard 或電腦可以把控制命令發布到設備的 control Topic。Pico 訂閱這個 Topic,收到訊息後解析 JSON,再依內容控制 GPIO。

import json
from machine import Pin

led = Pin("LED", Pin.OUT)

def on_message(topic, message):
    print("Topic:", topic)
    print("Message:", message)

    try:
        command = json.loads(message.decode())

        if "relay" in command:
            led.value(1 if command["relay"] else 0)
            print("LED updated:", led.value())

    except (ValueError, KeyError) as error:
        print("Invalid command:", error)

topic_control = b"farm/room01/pico01/control"
client.set_callback(on_message)
client.subscribe(topic_control)

命令可以是 {"relay": true}或 {"relay": false}。實際系統應依設備功能設計命令欄位,並確認收到的內容合法後才執行控制。

五、Pico 連接 Broker 並雙向交換訊息

以下是教學架構範例,假設 Pico 已先連上 Wi-Fi,且韌體已提供相容的 umqtt.simple用戶端。請把 Broker 主機、帳號與密碼替換成教師提供的測試設定。Broker 的網路位置與連線方式依服務而異。

import json
import time
from umqtt.simple import MQTTClient

BROKER = "請填入 MQTT Broker 主機"
CLIENT_ID = b"pico-room01-device01"

TOPIC_TELEMETRY = b"farm/room01/pico01/telemetry"
TOPIC_CONTROL = b"farm/room01/pico01/control"

# 若測試 Broker 不需要帳密,可依用戶端規格省略相關參數
client = MQTTClient(CLIENT_ID, BROKER)

def on_message(topic, message):
    print("Received:", topic, message)
    # 在此解析控制命令並更新 GPIO

client.set_callback(on_message)
client.connect()
client.subscribe(TOPIC_CONTROL)

print("MQTT connected and subscribed")

while True:
    # 示範內容;實作時改成感測器的實際讀值
    telemetry = {
        "temperature": 28.3,
        "humidity": 71.2
    }

    client.publish(
        TOPIC_TELEMETRY,
        json.dumps(telemetry)
    )

    # 檢查是否有訂閱訊息;有訊息時會呼叫 on_message
    client.check_msg()

    time.sleep(5)

set_callback()設定收到訊息時要執行的函式;subscribe()訂閱控制 Topic;publish()發布感測資料;check_msg()檢查是否有新訊息。這個程式每五秒發布一次示範資料,實際量測值需由感測器取得。

套件與 Broker:不同 MicroPython 韌體不一定預先包含 umqtt.simple,需先確認課堂環境的套件安裝方式。此範例使用提示文字作為 Broker 主機,並非可直接連線的服務。

六、MQTT 與 HTTP 如何搭配?

HTTP常見為 Request/Response,適合網頁與 API 的一次性資料交換。
MQTT使用 Publish/Subscribe,適合設備持續發布遙測資料或接收命令。
共同點兩者都在 IP 網路上運作,都必須考慮斷線與錯誤處理。

HTTP 和 MQTT 各有適合的情境,IoT 系統也可以同時使用兩者。例如設備透過 MQTT 傳送即時遙測,而設定頁面或其他服務使用 HTTP API。

七、斷線與安全性

Wi-Fi 或 Broker 可能中斷,設備程式要能顯示連線狀態,並設計清楚的重新連線流程。重新連線後,通常也要重新訂閱控制 Topic。不要在故障時無限快速重試;可採用有限次數與間隔,並讓錯誤訊息容易辨識。

保護 Broker 憑證:不要把真實帳密、Token 或 API Key 放進公開投影片、部落格或 Git 儲存庫。控制 Topic 會影響實體輸出,應使用可信任的 Broker、限制可發布者,並在接收端檢查命令內容。

八、實作任務:完成第一個 MQTT IoT Node

  1. 連上課堂指定 Wi-Fi。
  2. 連接測試 MQTT Broker。
  3. 訂閱設備的 control Topic。
  4. 每五秒發布一筆感測器 JSON 到 telemetry Topic。
  5. 從 Dashboard 或測試用戶端發布控制命令,確認 Pico 收到後能更新 GPIO。
  6. 挑戰:Wi-Fi 或 Broker 斷線後,顯示狀態並完成重新連線與重新訂閱。

測試時,建議同時觀察 Pico 的 Thonny Shell 與 Broker 用戶端畫面,確認每一筆訊息的 Topic、Payload 和方向。

六次課程如何串成完整資料流

  1. 第 1 次:控制硬體,建立 GPIO 基礎。
  2. 第 2 次:連上 Wi-Fi,取得網路資訊。
  3. 第 3 次:建立 Web Server,讓瀏覽器控制 Pico。
  4. 第 4 次:透過 HTTP 與 API 交換資料。
  5. 第 5 次:以 JSON 建立一致的資料格式。
  6. 第 6 次:以 MQTT 發布遙測資料並接收控制命令。

完成這六次課程後,就具備 IoT 專題的基本通訊流程。下一階段可以接續設計 Dashboard 或 IoT 平台,讓資料能被觀察、整理與應用。

Pico 2 W × Thonny|第 6 次課程

MQTT 與 IoT 雙向通訊

前一篇已把感測資料整理成 JSON。本篇將資料放進 MQTT 訊息,讓 Pico 2 W 發布感測資料,也訂閱控制主題接收命令。這樣裝置不只會「上傳」,也能從網路接收控制,形成 IoT 的雙向通訊。

一、MQTT 的三個角色

MQTT 使用 Broker(訊息代理伺服器)中轉訊息。Publisher 將訊息發布到某個 Topic;Subscriber 訂閱感興趣的 Topic。Broker 依照訂閱關係轉送訊息,因此 Publisher 通常不需要知道每一個 Subscriber 的網路位置。

Publisher 發布者把訊息送到指定 Topic,例如 Pico 上傳感測資料。
Broker 代理伺服器接收、管理並轉送訊息,是通訊中介。
Subscriber 訂閱者訂閱 Topic 並接收訊息,例如電腦 Dashboard。

同一台 Pico 可以同時扮演 Publisher 與 Subscriber:它發布感測值,也訂閱控制命令。

二、Topic 是訊息的分類路徑

Topic 用來標示訊息的類別或用途。好的命名方式能看出位置、設備與功能,讓多個裝置加入時仍然容易管理。

farm/room01/pico01/telemetry
farm/room01/pico01/status
farm/room01/pico01/control

farm/room02/pico02/telemetry
  • telemetry:感測器或設備定期發布的遙測資料。
  • status:設備目前狀態。
  • control:送往設備的控制命令。

Topic 名稱要在發布端和訂閱端完全一致;斜線分隔的層級也要保持規則一致。

三、Publish:Pico 發布感測資料

發布時要指定 Topic 和 Payload(訊息內容)。Topic 說明這是哪一類訊息,Payload 則放實際資料。本課程沿用上一堂的 JSON 格式,因此接收端可以依照欄位名稱解析資料。

import json

topic_telemetry = b"farm/room01/pico01/telemetry"

# 這些數字只用來示範資料格式
payload = {
    "temperature": 28.3,
    "humidity": 71.2
}

client.publish(
    topic_telemetry,
    json.dumps(payload)
)

範例中的數字只是格式示意,不是實際場域資料。實作時應換成感測器讀值,並確認欄位名稱、型態與單位已事先約定。

四、Subscribe:Pico 訂閱控制命令

Dashboard 或電腦可以把控制命令發布到設備的 control Topic。Pico 訂閱這個 Topic,收到訊息後解析 JSON,再依內容控制 GPIO。

import json
from machine import Pin

led = Pin("LED", Pin.OUT)

def on_message(topic, message):
    print("Topic:", topic)
    print("Message:", message)

    try:
        command = json.loads(message.decode())

        if "relay" in command:
            led.value(1 if command["relay"] else 0)
            print("LED updated:", led.value())

    except (ValueError, KeyError) as error:
        print("Invalid command:", error)

topic_control = b"farm/room01/pico01/control"
client.set_callback(on_message)
client.subscribe(topic_control)

命令可以是 {"relay": true}或 {"relay": false}。實際系統應依設備功能設計命令欄位,並確認收到的內容合法後才執行控制。

五、Pico 連接 Broker 並雙向交換訊息

以下是教學架構範例,假設 Pico 已先連上 Wi-Fi,且韌體已提供相容的 umqtt.simple用戶端。請把 Broker 主機、帳號與密碼替換成教師提供的測試設定。Broker 的網路位置與連線方式依服務而異。

import json
import time
from umqtt.simple import MQTTClient

BROKER = "請填入 MQTT Broker 主機"
CLIENT_ID = b"pico-room01-device01"

TOPIC_TELEMETRY = b"farm/room01/pico01/telemetry"
TOPIC_CONTROL = b"farm/room01/pico01/control"

# 若測試 Broker 不需要帳密,可依用戶端規格省略相關參數
client = MQTTClient(CLIENT_ID, BROKER)

def on_message(topic, message):
    print("Received:", topic, message)
    # 在此解析控制命令並更新 GPIO

client.set_callback(on_message)
client.connect()
client.subscribe(TOPIC_CONTROL)

print("MQTT connected and subscribed")

while True:
    # 示範內容;實作時改成感測器的實際讀值
    telemetry = {
        "temperature": 28.3,
        "humidity": 71.2
    }

    client.publish(
        TOPIC_TELEMETRY,
        json.dumps(telemetry)
    )

    # 檢查是否有訂閱訊息;有訊息時會呼叫 on_message
    client.check_msg()

    time.sleep(5)

set_callback()設定收到訊息時要執行的函式;subscribe()訂閱控制 Topic;publish()發布感測資料;check_msg()檢查是否有新訊息。這個程式每五秒發布一次示範資料,實際量測值需由感測器取得。

套件與 Broker:不同 MicroPython 韌體不一定預先包含 umqtt.simple,需先確認課堂環境的套件安裝方式。此範例使用提示文字作為 Broker 主機,並非可直接連線的服務。

六、MQTT 與 HTTP 如何搭配?

HTTP常見為 Request/Response,適合網頁與 API 的一次性資料交換。
MQTT使用 Publish/Subscribe,適合設備持續發布遙測資料或接收命令。
共同點兩者都在 IP 網路上運作,都必須考慮斷線與錯誤處理。

HTTP 和 MQTT 各有適合的情境,IoT 系統也可以同時使用兩者。例如設備透過 MQTT 傳送即時遙測,而設定頁面或其他服務使用 HTTP API。

七、斷線與安全性

Wi-Fi 或 Broker 可能中斷,設備程式要能顯示連線狀態,並設計清楚的重新連線流程。重新連線後,通常也要重新訂閱控制 Topic。不要在故障時無限快速重試;可採用有限次數與間隔,並讓錯誤訊息容易辨識。

保護 Broker 憑證:不要把真實帳密、Token 或 API Key 放進公開投影片、部落格或 Git 儲存庫。控制 Topic 會影響實體輸出,應使用可信任的 Broker、限制可發布者,並在接收端檢查命令內容。

八、實作任務:完成第一個 MQTT IoT Node

  1. 連上課堂指定 Wi-Fi。
  2. 連接測試 MQTT Broker。
  3. 訂閱設備的 control Topic。
  4. 每五秒發布一筆感測器 JSON 到 telemetry Topic。
  5. 從 Dashboard 或測試用戶端發布控制命令,確認 Pico 收到後能更新 GPIO。
  6. 挑戰:Wi-Fi 或 Broker 斷線後,顯示狀態並完成重新連線與重新訂閱。

測試時,建議同時觀察 Pico 的 Thonny Shell 與 Broker 用戶端畫面,確認每一筆訊息的 Topic、Payload 和方向。

六次課程如何串成完整資料流

  1. 第 1 次:控制硬體,建立 GPIO 基礎。
  2. 第 2 次:連上 Wi-Fi,取得網路資訊。
  3. 第 3 次:建立 Web Server,讓瀏覽器控制 Pico。
  4. 第 4 次:透過 HTTP 與 API 交換資料。
  5. 第 5 次:以 JSON 建立一致的資料格式。
  6. 第 6 次:以 MQTT 發布遙測資料並接收控制命令。

完成這六次課程後,就具備 IoT 專題的基本通訊流程。下一階段可以接續設計 Dashboard 或 IoT 平台,讓資料能被觀察、整理與應用。

JSON 與 IoT 資料格式|Pico 2 W

Pico 2 W × Thonny|第 5 次課程

JSON 與 IoT 資料格式

感測器產生資料,Pico 負責整理與傳送;要讓不同設備、程式和伺服器都能理解同一筆資訊,就需要清楚一致的資料格式。本篇介紹 JSON 的基本結構、Python 字典與 JSON 的轉換,以及 IoT 遙測欄位的設計方法。

作者:施朝斌

一、為什麼 IoT 需要 JSON?

如果只傳送 28.3,71.2,350,接收端雖然看到三個數字,卻不知道它們各自代表溫度、濕度還是光照。JSON 在資料旁附上欄位名稱,讓資料較容易閱讀,也讓程式能依欄位取值。

{
  "temperature": 28.3,
  "humidity": 71.2,
  "light": 350
}

以上數字沿用課程投影片作為格式示例,不代表實際場域量測結果。正式資料應由感測器讀取,並清楚標明單位與資料來源。

二、JSON 的基本結構

JSON 物件使用大括號 {},以「欄位名稱:值」的方式組織資料;陣列使用中括號 [],可以放置多筆項目。常見值的型態包括字串、數字、布林值、物件、陣列,以及 null。

{
  "device": "PICO01",
  "temperature": 28.3,
  "humidity": 71.2,
  "relay": true,
  "location": {
    "room": "A101"
  },
  "alerts": []
}
  • 字串:文字需使用雙引號,例如 "PICO01"。
  • 數字:例如 28.3;不要把數字不必要地存成文字。
  • 布林值:使用小寫的 true 或 false。
  • 物件:可再包含一組欄位,例如 location。
  • 陣列:使用中括號,可表示多筆資料或多個項目。
  • null:表示目前沒有可用值;它不同於字串 "null"或數字 0。

三、Python 字典與 JSON 互相轉換

在 Python 程式中,資料常先以字典整理。json.dumps()把 Python 資料轉成 JSON 文字;json.loads()則把 JSON 文字解析回 Python 資料。

import json

data = {
    "temperature": 28.3,
    "humidity": 71.2,
    "relay": True
}

json_text = json.dumps(data)
print(json_text)

decoded_data = json.loads(json_text)
print(decoded_data["temperature"])

Python 的 True轉成 JSON 後會是小寫的 true;JSON 解析回 Python 後則會成為 True。同樣地,Python 的 None會轉成 JSON 的 null。

兩個常見動詞:dumps把資料「序列化」成文字;loads把文字「解析」成資料。HTTP 傳輸時,送出或收到的通常是文字內容。

四、設計 IoT 遙測資料欄位

一筆可交換的遙測資料,除了感測值,也應讓接收端知道資料來自哪個設備、何時產生,以及每個數字代表什麼。欄位名稱與格式最好固定,後續接入 HTTP API、MQTT 或 Dashboard 時才能共用。

{
  "deviceId": "PICO01",
  "timestamp": null,
  "temperature": 28.3,
  "humidity": 71.2,
  "light": 350,
  "battery": 4.05
}

這些感測值是投影片中的示範數字,不是實際量測結果。timestamp目前設為 null,表示範例沒有提供時間;只有在 Pico 已取得可靠時鐘並採用一致的時間格式後,才應填入時間戳記。每個欄位也應訂定單位,例如溫度使用 °C、電壓使用 V,避免不同設備各自解讀。

設計欄位時,還要決定設備識別方式、欄位拼法、數值型態、單位、取樣時間與缺值表示法。這些規則就是資料格式的一部分。

五、用 Python 建立自己的 JSON 遙測資料

以下以示範值展示資料結構。實作時請把數值換成程式實際讀到的感測器資料;若某欄位目前沒有讀值,使用 None轉成 JSON null,不要假裝已取得測量結果。

import json

telemetry = {
    "deviceId": "PICO01",
    "timestamp": None,
    "temperature": 28.3,
    "humidity": 71.2,
    "relay": False
}

payload = json.dumps(telemetry)
print(payload)

接著可以把 payload放進 HTTP POST 的 Body,或在下一堂課將它作為 MQTT 訊息內容。只要欄位規則維持一致,接收端就能用相同方式解析。

六、錯誤資料也要處理

網路上的資料不一定完整、型態正確或仍然新鮮。接收端應先檢查必需欄位,再確認型態與時間資訊,並適當處理伺服器錯誤。

import json

response_text = '{"temperature": 28.3}'

try:
    data = json.loads(response_text)

    temperature = data.get("temperature")
    humidity = data.get("humidity")

    if temperature is None:
        print("Temperature is missing")
    elif not isinstance(temperature, (int, float)):
        print("Temperature has the wrong type")
    else:
        print("Temperature:", temperature)

    if humidity is None:
        print("Humidity is missing")
    else:
        print("Humidity:", humidity)

except ValueError as error:
    print("Invalid JSON:", error)
  • 欄位不存在:使用 get()或明確檢查,避免直接索引而產生 KeyError。
  • 型態錯誤:數字 28.3與文字 "28.3"不是同一種資料。
  • 資料過期:若有可靠時間戳記,可比較資料時間與目前時間;沒有時鐘時不要假裝能判定新舊。
  • 伺服器錯誤:檢查 HTTP 狀態碼,必要時有限次重試並記錄結果。

七、REST API 路徑與資料責任

REST API 常用不同路徑區分資源或功能。例如 /api/telemetry可代表感測資料、/api/status可代表設備狀態、/api/control可代表控制要求、/api/config可代表設定資訊。路徑名稱只是設計示例,實際 API 應依服務規格使用。

把感測資料、設備狀態、控制命令與設定分開,有助於開發者理解每個端點的用途。欄位名稱與格式也要在設備端和伺服器端保持一致。

八、實作任務:定義自己的 Pico 資料格式

  1. 建立一個穩定的 deviceId。
  2. 選擇 2 至 3 個實際存在或課堂指定的感測欄位。
  3. 加入一個設備狀態欄位,例如繼電器開關狀態。
  4. 為每個欄位記錄型態、單位與缺值表示方式。
  5. 使用 json.dumps()轉成 JSON,並在 Shell 檢查輸出。
  6. 依課程提供的測試服務 POST,檢查狀態碼與回應內容。
不要把示範數據當成實測結果:完成實驗紀錄時,註明數值來自範例、模擬器或實際感測器。若來源不同,也要避免混在一起比較或宣稱為場域成效。

本篇重點回顧

  • JSON 以欄位名稱標示資料意義,方便不同設備和程式交換資訊。
  • Python 字典可用 json.dumps()轉為 JSON 文字,用 json.loads()解析回資料。
  • IoT 遙測格式應固定欄位名稱、型態、單位、設備識別與時間規則。
  • 要檢查欄位缺漏、型態不符、資料是否過期及 HTTP 錯誤。
  • 範例數值只示範格式;實際結果應來自感測器或明確標示的測試資料。

下一篇將把一致的 JSON 遙測資料帶入 MQTT,讓 Pico 發布感測訊息,也能訂閱控制命令。

2026年8月15日 星期六

[水井村USR] ESP32 同時當基地台又能上網! 水井三寶智慧互動展覽系統 AP+STA 共存實測

ESP32 同時當基地台又能上網!
水井三寶智慧互動展覽系統 AP+STA 共存實測

ESP32 Arduino SoftAP STA Edge Web IoT USR

在「水井三寶智慧互動展覽系統」的開發過程中,有一個很重要的網路問題: ESP32 能不能一方面提供現場手機連線控制,另一方面又同時連上 Internet?

經過實際測試,答案是:可以。 本次成功讓 ESP32 的 SoftAP 與 STA 同時運作,而且 AP 端的手機控制與 STA 端的 Internet 連線可以共存。

一、為什麼水井三寶故事機需要 AP+STA?

一般 ESP32 IoT 專題常見的做法,是讓 ESP32 連接既有 Wi-Fi,也就是 STA(Station)模式。 但「水井三寶智慧互動展覽系統」的使用情境不同。

展覽現場可能有大量參觀者,而且展場 Internet 並不一定穩定。因此,我們希望故事機即使沒有 Internet, 工作人員仍然可以拿手機直接連上 ESP32,進入內嵌控制網站進行播放、診斷與設備管理。

另一方面,如果展場具有 Internet,ESP32 又應該能將匿名互動事件傳送到遠端 Django 平台, 形成長期的展覽數據。

【參觀者/工作人員手機】
│
│ Wi-Fi
▼
【ESP32 SoftAP】
Shuijing-Treasures
192.168.4.1
│
├──── Edge Web 現場控制
│
└──── ESP32 同時使用 STA
    │
    ▼
【手機熱點/展場 Wi-Fi】
    │
    ▼
   Internet
    │
    ▼
【Django 雲端平台】

二、AP 與 STA 分別做什麼?

模式 用途 水井三寶應用
AP ESP32 自己建立 Wi-Fi 手機直接連入故事機
SoftAP IP ESP32 本地控制網址 192.168.4.1
STA ESP32 連接外部 Wi-Fi 取得 Internet
Internet 連接遠端伺服器 Django 匿名事件平台

三、這次實測成功

本次使用 ESP32 實際測試 AP+STA 共存。 ESP32 一方面建立自己的 Wi-Fi:

SSID:Shuijing-Treasures
Local control:http://192.168.4.1

另一方面,再透過 STA 連接名為 ChiYuan 的外部 Wi-Fi。 實際取得:

STA CONNECTED
STA SSID : ChiYuan
STA IP   : 192.168.1.119
RSSI     : -21 dBm

==============================
SYSTEM READY
==============================

Local control: http://192.168.4.1
Internet: YES

▲ 圖1 ESP32 AP+STA 共存實測。 ESP32 本地 AP 維持 192.168.4.1,同時透過 STA 取得 192.168.1.119,Internet 狀態為 YES。
這張測試畫面證明了一件很重要的事:

ESP32 不需要在「本地控制」與「Internet」之間二選一。
它可以同時提供 192.168.4.1 Edge Web, 又透過另一個 Wi-Fi 介面連上 Internet。

四、連線過程

程式的核心概念其實很簡單:先建立 SoftAP,再使用 WiFi.begin() 建立 STA 連線。

STEP 1|ESP32 建立 SoftAP

if (!WiFi.softAP(AP_SSID, AP_PASSWORD)) {

    Serial.println("SoftAP FAILED");

    while (1) {
        delay(1000);
    }
}

Serial.println("SoftAP OK");

Serial.print("AP IP : ");
Serial.println(WiFi.softAPIP());

成功後,ESP32 本身就是一個 Wi-Fi 基地台。 手機可以搜尋到:

Shuijing-Treasures

連線後即可進入:

http://192.168.4.1

STEP 2|ESP32 再連接 Internet Wi-Fi

WiFi.begin(STA_SSID, STA_PASSWORD);

這時 ESP32 又以 STA 身分連接外部 AP。 程式最多等待 15 秒:

unsigned long start = millis();

while (
    WiFi.status() != WL_CONNECTED &&
    millis() - start < 15000
) {

    Serial.print(".");
    delay(500);
}

STEP 3|確認兩個 IP 同時存在

這是理解 AP+STA 最重要的地方。 ESP32 此時具有兩個不同用途的 IP。

介面 本次實測 IP 功能
SoftAP 192.168.4.1 手機連 ESP32
STA 192.168.1.119 ESP32 連 Internet

五、還能知道有幾台手機連進 ESP32

程式使用:

WiFi.softAPgetStationNum()

取得目前連接 ESP32 AP 的裝置數。 本次測試畫面持續出現:

AP Clients = 1

代表測試當下確實有一台裝置連著 Shuijing-Treasures。

六、RSSI 也能成為展覽維護資訊

程式每五秒輸出 STA 狀態:

AP Clients = 1 | STA = ONLINE | IP = 192.168.1.119 | RSSI = -28
AP Clients = 1 | STA = ONLINE | IP = 192.168.1.119 | RSSI = -21
AP Clients = 1 | STA = ONLINE | IP = 192.168.1.119 | RSSI = -29

這些資訊未來可以直接整合進水井三寶 Edge Web 的「設備診斷」頁面, 讓工作人員不必接電腦,就可以從手機知道:

  • AP 是否正常
  • 目前有多少裝置連線
  • STA 是否 ONLINE
  • STA IP
  • Wi-Fi RSSI
  • Internet 是否可用

七、完整 Arduino 測試程式

以下是本次 AP+STA 共存實際測試使用的 Arduino 程式。 正式使用時只需要修改 STA 的 Wi-Fi 名稱與密碼。

#include <Arduino.h>
#include <WiFi.h>
#include <NetworkClient.h>
#include <WiFiAP.h>

// ===============================
// ESP32 AP + Internet STA TEST
// ===============================

// ESP32 本地 AP
const char *AP_SSID = "Shuijing-Treasures";
const char *AP_PASSWORD = "12345678";

// 可上網的 Wi-Fi
// 先用手機熱點測試最容易排除展場網路問題
const char *STA_SSID = "您的手機熱點名稱";
const char *STA_PASSWORD = "您的手機熱點密碼";

NetworkServer server(80);

void setup() {

  Serial.begin(115200);
  delay(1500);

  Serial.println();
  Serial.println("==============================");
  Serial.println("SHUIJING AP + STA TEST");
  Serial.println("==============================");

  // --------------------------------
  // STEP 1
  // 建立 SoftAP
  // --------------------------------

  Serial.println();
  Serial.println("[1] Starting SoftAP...");

  if (!WiFi.softAP(AP_SSID, AP_PASSWORD)) {

    Serial.println("SoftAP FAILED");

    while (1) {
      delay(1000);
    }
  }

  Serial.println("SoftAP OK");

  Serial.print("AP SSID : ");
  Serial.println(AP_SSID);

  Serial.print("AP IP   : ");
  Serial.println(WiFi.softAPIP());


  // --------------------------------
  // STEP 2
  // 建立 STA Internet
  // --------------------------------

  Serial.println();
  Serial.println("[2] Starting STA...");

  WiFi.begin(STA_SSID, STA_PASSWORD);

  unsigned long start = millis();

  while (
    WiFi.status() != WL_CONNECTED &&
    millis() - start < 15000
  ) {

    Serial.print(".");
    delay(500);
  }

  Serial.println();


  // --------------------------------
  // STEP 3
  // 檢查 STA
  // --------------------------------

  if (WiFi.status() == WL_CONNECTED) {

    Serial.println("STA CONNECTED");

    Serial.print("STA SSID : ");
    Serial.println(WiFi.SSID());

    Serial.print("STA IP   : ");
    Serial.println(WiFi.localIP());

    Serial.print("RSSI     : ");
    Serial.print(WiFi.RSSI());
    Serial.println(" dBm");

  } else {

    Serial.println("STA NOT CONNECTED");
    Serial.println("Local AP should still work.");
  }


  // --------------------------------
  // STEP 4
  // 啟動本地 Web Server
  // --------------------------------

  server.begin();

  Serial.println();
  Serial.println("==============================");
  Serial.println("SYSTEM READY");
  Serial.println("==============================");

  Serial.print("Local control: http://");
  Serial.println(WiFi.softAPIP());

  if (WiFi.status() == WL_CONNECTED) {

    Serial.println("Internet: YES");

  } else {

    Serial.println("Internet: NO");
  }
}


void loop() {

  // ===============================
  // 本地 AP Web Server
  // ===============================

  NetworkClient client = server.accept();

  if (client) {

    String currentLine = "";

    while (client.connected()) {

      if (client.available()) {

        char c = client.read();

        if (c == '\n') {

          if (currentLine.length() == 0) {

            client.println("HTTP/1.1 200 OK");
            client.println(
              "Content-Type: text/html; charset=utf-8"
            );
            client.println("Connection: close");
            client.println();

            client.println(
              "<!DOCTYPE html>"
              "<html>"
              "<head>"
              "<meta charset='UTF-8'>"
              "<meta name='viewport' "
              "content='width=device-width,initial-scale=1'>"
              "</head>"
              "<body>"
            );

            client.println(
              "<h1>水井三寶 AP + STA 測試</h1>"
            );

            client.print("<p>AP IP:");
            client.print(WiFi.softAPIP());
            client.println("</p>");

            client.print("<p>STA:");

            if (WiFi.status() == WL_CONNECTED) {

              client.println("CONNECTED</p>");

              client.print("<p>Internet IP:");
              client.print(WiFi.localIP());
              client.println("</p>");

              client.print("<p>RSSI:");
              client.print(WiFi.RSSI());
              client.println(" dBm</p>");

            } else {

              client.println("OFFLINE</p>");
            }

            client.println("</body></html>");

            break;

          } else {

            currentLine = "";
          }

        } else if (c != '\r') {

          currentLine += c;
        }
      }
    }

    client.stop();
  }


  // ===============================
  // STA 斷線診斷
  // ===============================

  static unsigned long lastPrint = 0;

  if (millis() - lastPrint > 5000) {

    lastPrint = millis();

    Serial.print("AP Clients = ");
    Serial.print(WiFi.softAPgetStationNum());

    Serial.print(" | STA = ");

    if (WiFi.status() == WL_CONNECTED) {

      Serial.print("ONLINE");

      Serial.print(" | IP = ");
      Serial.print(WiFi.localIP());

      Serial.print(" | RSSI = ");
      Serial.println(WiFi.RSSI());

    } else {

      Serial.println("OFFLINE");
    }
  }
}

八、為什麼這次測試對「水井三寶」很重要?

這不只是一個 ESP32 Wi-Fi 技術測試,而是決定「水井三寶智慧互動展覽系統」 能不能真正進入展覽現場的一個重要基礎。

第二層|智慧互動層
ESP32+ToF+三按鈕+JQ6500+RGB, 負責故事播放與現場互動。
第三層|Edge Web 層
ESP32 提供 192.168.4.1, 即使 Internet 中斷仍可現場控制。
第四層|Django Data 層
ESP32 透過 STA 與 Internet, 將匿名互動事件送往遠端平台。
內容層|水井三寶網站
白馬、烏龜、姻緣花、三寶故事與創作者資訊, 提供更完整的數位延伸閱讀。

九、最重要的展覽設計原則:Internet 不是故事機的開關

展覽系統不應該因為 Internet 斷線,就讓作品停止說故事。

因此,水井三寶智慧互動展覽系統採取的是 Edge First 的思考方式。

ToF、三個實體按鈕、JQ6500 故事播放、RGB 燈光與 192.168.4.1 本地控制都應該在 ESP32 本機完成。

STA 與 Internet 則是「加值能力」,主要負責匿名事件同步與遠端資料分析。

未來即使展場 Wi-Fi 暫時中斷:

JQ6500        → 繼續播放
三按鈕        → 繼續操作
ToF           → 繼續偵測
RGB           → 繼續互動
192.168.4.1   → 繼續控制

Internet      → 暫時離線
Django Event  → 暫存,恢復後補傳

十、下一步:從「會上網」進入「真正的四層系統」

本次測試已經完成一個重要的技術驗證: ESP32 SoftAP 與 Internet STA 可以穩定共存。

下一階段就可以把這項成果正式整合進「水井三寶智慧互動展覽系統」:

水井三寶作品
      │
      ▼
ESP32 智慧互動層
ToF+Button+JQ6500+WS2812
      │
      ▼
Edge Web
192.168.4.1
      │
      ├──────── 現場手機控制
      │
      ▼
STA / Internet
      │
      ▼
Django
匿名事件資料庫
      │
      ▼
展覽 Dashboard

當地方工藝遇上 ESP32、Edge Web 與 Django, 科技不再只是放在作品旁邊的設備, 而是開始成為地方故事與參觀者之間的互動媒介。

白馬行土地、烏龜守清水、姻緣花牽人情;
三寶同行,水井共生。

2026年8月2日 星期日

[水井村USR]從 Atlas Aquaponics Kit 到 Django 雲端監控平台:智慧養殖 IoT 系統整合教學





Atlas Scientific Aquaponics Kit V1.8 × Adafruit HUZZAH32 ESP32 Feather × Django REST API × PythonAnywhere
智慧養殖不只是把感測器接上 ESP32,而是要完成「水質感測、資料處理、網路傳輸、雲端儲存與即時呈現」的完整資料鏈。本教學整理實際測試過程,說明如何將 RTD、pH、DO 與 EC 感測資料,透過 HTTPS POST 傳送到水井村 USR 的 Django REST API,並在 Web Dashboard 查看多家養殖戶的即時狀態。

一、系統整合的重要概念

RTD/pH/DO/EC → ESP32 讀值與補償 → Wi-Fi → HTTPS JSON → Django REST API → 資料庫 → Web Dashboard
請先將「Atlas Aquaponics V1.8 Django REST API 系統整合圖」上傳至 Blogger, 再將 HTML 中的 SYSTEM_IMAGE_URL 換成圖片網址。
圖:水質感測、ESP32、Django REST API 與 Dashboard 的完整資料流程。

系統中,ESP32 並不是單純讀值後顯示在序列監控視窗,而是扮演「邊緣節點」: 負責啟用各 EZO 電路、讀取感測器、執行溫度補償、建立 JSON、取得網路時間,並定時將資料送往雲端。

二、硬體與感測器

感測器 用途 實際 I²C 位址 資料用途
RTD 水溫 0x66(102) 水溫監控與其他感測器的溫度補償
pH 酸鹼值 0x63(99) 判斷水體酸鹼狀況
DO 溶氧 0x61(97) 評估水體含氧與水車運轉需求
EC 導電度 0x69(105) 推估鹽度與水體離子濃度
實作重點:本機 EC 的位址不是常見預設值 0x64,而是 0x69。除錯時不能只相信原廠預設值,應先執行 I²C 掃描,再依實際位址建立 EZO 物件。

三、正確設定 Aquaponics V1.8 腳位

本系統採用 Adafruit HUZZAH32 ESP32 Feather。實際測試後,I²C 使用 GPIO23 與 GPIO22, 各感測器也有獨立的 Enable 腳位。

程式碼 1:I²C、Enable 與 EZO 位址
constexpr uint8_t I2C_SDA = 23;
constexpr uint8_t I2C_SCL = 22;

constexpr uint8_t EN_PH  = 13;
constexpr uint8_t EN_DO  = 12;
constexpr uint8_t EN_EC  = 27;
constexpr uint8_t EN_RTD = 33;
constexpr uint8_t EN_CO2 = 15;
constexpr uint8_t EN_HUM = 32;

constexpr uint8_t ADDR_DO  = 97;   // 0x61
constexpr uint8_t ADDR_PH  = 99;   // 0x63
constexpr uint8_t ADDR_RTD = 102;  // 0x66
constexpr uint8_t ADDR_EC  = 105;  // 0x69,本機 EC 已改址

Ezo_board PH  = Ezo_board(ADDR_PH,  "PH");
Ezo_board DO  = Ezo_board(ADDR_DO,  "DO");
Ezo_board EC  = Ezo_board(ADDR_EC,  "EC");
Ezo_board RTD = Ezo_board(ADDR_RTD, "RTD");

constexpr unsigned long READ_DELAY_MS = 1100;
constexpr unsigned long SENSOR_INTERVAL_MS = 5000;
constexpr unsigned long UPLOAD_INTERVAL_MS = 15000;
constexpr unsigned long WIFI_RETRY_INTERVAL_MS = 10000;

// EC 換算鹽度暫用係數;正式部署前應以標準液或鹽度計校正。
constexpr float EC_TO_SALINITY_FACTOR = 0.00064f;

const long GMT_OFFSET_SEC = 8L * 60L * 60L;
const int DAYLIGHT_OFFSET_SEC = 0;
const char* NTP_SERVER_1 = "pool.ntp.org";
const char* NTP_SERVER_2 = "time.google.com";

struct SensorData {
  float waterTemperature;
  float ph;
  float dissolvedOxygen;
  float conductivity;
  float salinity;

  bool temperatureValid;
  bool phValid;
  bool dissolvedOxygenValid;
  bool conductivityValid;
};

SensorData sensorData = {
  NAN, NAN, NAN, NAN, NAN,
  false, false, false, false
};

unsigned long lastSensorTime = 0;
unsigned long lastUploadTime = 0;
unsigned long lastWiFiRetryTime = 0;

void enableCircuits() {
  pinMode(EN_PH, OUTPUT);
  pinMode(EN_DO, OUTPUT);
  pinMode(EN_EC, OUTPUT);
  pinMode(EN_RTD, OUTPUT);
  pinMode(EN_CO2, OUTPUT);
  pinMode(EN_HUM, OUTPUT);

  digitalWrite(EN_PH, LOW);
  digitalWrite(EN_DO, LOW);
  digitalWrite(EN_EC, LOW);

  digitalWrite(EN_RTD, HIGH);
  digitalWrite(EN_CO2, HIGH);
  digitalWrite(EN_HUM, HIGH);

  delay(2000);
}

void connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  Serial.println();
  Serial.print("[WiFi] 正在連線:");
  Serial.println(WIFI_SSID);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  unsigned long startedAt = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - startedAt < 15000) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("[WiFi] 連線成功");
    Serial.print("[WiFi] IP:");
    Serial.println(WiFi.localIP());
    Serial.print("[WiFi] RSSI:");
    Serial.print(WiFi.RSSI());
    Serial.println(" dBm");
  } else {
    Serial.println("[WiFi] 本次連線失敗,稍後重試");
  }
}

void maintainWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  if (millis() - lastWiFiRetryTime >= WIFI_RETRY_INTERVAL_MS) {
    lastWiFiRetryTime = millis();
    connectWiFi();
  }
}

void initializeTime() {
  configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER_1, NTP_SERVER_2);

  Serial.print("[NTP] 同步時間");
  struct tm timeInfo;

  for (int i = 0; i < 20; i++) {
    if (getLocalTime(&timeInfo, 1000)) {
      Serial.println();
      Serial.println("[NTP] 同步成功");
      return;
    }
    Serial.print(".");
  }

  Serial.println();
  Serial.println("[NTP] 尚未取得時間");
}

String getRecordedAt() {
  struct tm timeInfo;
  if (!getLocalTime(&timeInfo, 3000)) return "";

  char buffer[25];
  strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%S", &timeInfo);
  return String(buffer) + "+08:00";
}

bool receiveValue(Ezo_board& sensor, float& value) {
  receive_and_print_reading(sensor);

  if (sensor.get_error() != Ezo_board::SUCCESS) return false;

  value = sensor.get_last_received_reading();
  return !isnan(value);
}

void readSensors() {
  Serial.println();
  Serial.println("========== 感測器讀值 ==========");

  RTD.send_read_cmd();
  delay(READ_DELAY_MS);

  float temperature = NAN;
  sensorData.temperatureValid = receiveValue(RTD, temperature);

  if (sensorData.temperatureValid && temperature > -1000.0f) {
    sensorData.waterTemperature = temperature;
    Serial.printf("水溫 RTD:%.2f C\n", sensorData.waterTemperature);

    PH.send_cmd_with_num("T,", temperature);
    DO.send_cmd_with_num("T,", temperature);
    EC.send_cmd_with_num("T,", temperature);
  } else {
    sensorData.temperatureValid = false;
    Serial.println("水溫 RTD:無有效資料");

    PH.send_cmd_with_num("T,", 25.0);
    DO.send_cmd_with_num("T,", 25.0);
    EC.send_cmd_with_num("T,", 25.0);
  }

  delay(350);

  PH.send_read_cmd();
  DO.send_read_cmd();
  EC.send_read_cmd();

  delay(READ_DELAY_MS);

  float phValue = NAN;
  float doValue = NAN;
  float ecValue = NAN;

  sensorData.phValid = receiveValue(PH, phValue);
  sensorData.dissolvedOxygenValid = receiveValue(DO, doValue);
  sensorData.conductivityValid = receiveValue(EC, ecValue);

  if (sensorData.phValid) sensorData.ph = phValue;
  if (sensorData.dissolvedOxygenValid) sensorData.dissolvedOxygen = doValue;

  if (sensorData.conductivityValid) {
    sensorData.conductivity = ecValue;
    sensorData.salinity = ecValue * EC_TO_SALINITY_FACTOR;
  }

  Serial.println();
  Serial.println("---------- 結果 ----------");

  if (sensorData.phValid) Serial.printf("pH:%.2f\n", sensorData.ph);
  else Serial.println("pH:無有效資料");

  if (sensorData.dissolvedOxygenValid) {
    Serial.printf("溶氧 DO:%.2f mg/L\n", sensorData.dissolvedOxygen);
  } else {
    Serial.println("溶氧 DO:無有效資料");
  }

  if (sensorData.conductivityValid) {
    Serial.printf("導電度 EC:%.0f uS/cm\n", sensorData.conductivity);
    Serial.printf("換算鹽度:%.2f ppt\n", sensorData.salinity);
  } else {
    Serial.println("導電度 EC:無有效資料");
  }

  Serial.println("==========================");
}

String jsonNumberOrNull(float value, bool valid, unsigned int decimals) {
  if (!valid || isnan(value)) return "null";
  return String((double)value, decimals);
}

String escapeJsonString(const String& input) {
  String output;
  output.reserve(input.length() + 8);

  for (size_t i = 0; i < input.length(); i++) {
    char c = input.charAt(i);
    switch (c) {
      case '"': output += "\\\""; break;
      case '\\': output += "\\\\"; break;
      case '\n': output += "\\n"; break;
      case '\r': output += "\\r"; break;
      case '\t': output += "\\t"; break;
      default: output += c; break;
    }
  }

  return output;
}

String buildPayload(const String& recordedAt) {
  String payload;
  payload.reserve(420);

  payload += "{";
  payload += "\"token\":\"" + escapeJsonString(API_TOKEN) + "\",";
  payload += "\"farm_name\":\"" + escapeJsonString(FARM_NAME) + "\",";
  payload += "\"pond_code\":\"" + escapeJsonString(POND_CODE) + "\",";

  payload += "\"water_temperature\":";
  payload += jsonNumberOrNull(sensorData.waterTemperature, sensorData.temperatureValid, 2);
  payload += ",";

  payload += "\"salinity\":";
  payload += jsonNumberOrNull(sensorData.salinity, sensorData.conductivityValid, 2);
  payload += ",";

  payload += "\"ph\":";
  payload += jsonNumberOrNull(sensorData.ph, sensorData.phValid, 2);
  payload += ",";

  payload += "\"dissolved_oxygen\":";
  payload += jsonNumberOrNull(sensorData.dissolvedOxygen, sensorData.dissolvedOxygenValid, 2);
  payload += ",";

  payload += "\"water_source\":\"" + escapeJsonString(WATER_SOURCE) + "\",";
  payload += "\"recorded_at\":\"" + recordedAt + "\"";
  payload += "}";

  return payload;
}

void uploadToDjango() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[API] Wi-Fi 未連線,本次不上傳");
    return;
  }

  String recordedAt = getRecordedAt();
  if (recordedAt.length() == 0) {
    Serial.println("[API] 尚未取得 NTP 時間,本次不上傳");
    return;
  }

  String payload = buildPayload(recordedAt);

  NetworkClientSecure secureClient;
  secureClient.setInsecure(); // 測試階段;正式部署建議改用 CA 憑證

  HTTPClient http;
  http.setTimeout(15000);

  Serial.println();
  Serial.println("========== Django API 上傳 ==========");
  Serial.print("[API] URL:");
  Serial.println(API_URL);
  Serial.print("[API] Payload:");
  Serial.println(payload);

  if (!http.begin(secureClient, API_URL)) {
    Serial.println("[API] HTTPS 初始化失敗");
    return;
  }

  http.addHeader("Content-Type", "application/json");
  http.addHeader("Accept", "application/json");

  int httpCode = http.POST(payload);
  Serial.print("[API] HTTP 狀態碼:");
  Serial.println(httpCode);

  if (httpCode > 0) {
    String response = http.getString();
    Serial.print("[API] 回應:");
    Serial.println(response);

    if (httpCode >= 200 && httpCode < 300) {
      Serial.println("[API] 上傳成功");
    } else {
      Serial.println("[API] 上傳失敗,請檢查 token、欄位或資料格式");
    }
  } else {
    Serial.print("[API] 連線錯誤:");
    Serial.println(http.errorToString(httpCode));
  }

  http.end();
  Serial.println("====================================");
}

void setup() {
  Serial.begin(115200);
  delay(1500);

  Serial.println();
  Serial.println("Atlas Aquaponics V1.8 + Django API 啟動");
  Serial.printf("SDA=GPIO%u, SCL=GPIO%u\n", I2C_SDA, I2C_SCL);
  Serial.printf("EC 位址=%u (0x%02X)\n", ADDR_EC, ADDR_EC);

  enableCircuits();

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000);
  Wire.setTimeOut(1000);

  connectWiFi();
  if (WiFi.status() == WL_CONNECTED) initializeTime();

  readSensors();

  lastSensorTime = millis();
  lastUploadTime = millis();
}

void loop() {
  maintainWiFi();

  unsigned long now = millis();

  if (now - lastSensorTime >= SENSOR_INTERVAL_MS) {
    lastSensorTime = now;
    readSensors();
  }

  if (now - lastUploadTime >= UPLOAD_INTERVAL_MS) {
    lastUploadTime = now;
    uploadToDjango();
  }

  delay(10);
}

constexpr uint8_t ADDR_DO  = 97;   // 0x61
constexpr uint8_t ADDR_PH  = 99;   // 0x63
constexpr uint8_t ADDR_RTD = 102;  // 0x66
constexpr uint8_t ADDR_EC  = 105;  // 0x69,本機 EC 已改址

Ezo_board PH  = Ezo_board(ADDR_PH,  "PH");
Ezo_board DO  = Ezo_board(ADDR_DO,  "DO");
Ezo_board EC  = Ezo_board(ADDR_EC,  "EC");
Ezo_board RTD = Ezo_board(ADDR_RTD, "RTD");

constexpr unsigned long READ_DELAY_MS = 1100;
constexpr unsigned long SENSOR_INTERVAL_MS = 5000;
constexpr unsigned long UPLOAD_INTERVAL_MS = 15000;
constexpr unsigned long WIFI_RETRY_INTERVAL_MS = 10000;

// EC 換算鹽度暫用係數;正式部署前應以標準液或鹽度計校正。
constexpr float EC_TO_SALINITY_FACTOR = 0.00064f;

const long GMT_OFFSET_SEC = 8L * 60L * 60L;
const int DAYLIGHT_OFFSET_SEC = 0;
const char* NTP_SERVER_1 = "pool.ntp.org";
const char* NTP_SERVER_2 = "time.google.com";

struct SensorData {
  float waterTemperature;
  float ph;
  float dissolvedOxygen;
  float conductivity;
  float salinity;

  bool temperatureValid;
  bool phValid;
  bool dissolvedOxygenValid;
  bool conductivityValid;
};

SensorData sensorData = {
  NAN, NAN, NAN, NAN, NAN,
  false, false, false, false
};

unsigned long lastSensorTime = 0;
unsigned long lastUploadTime = 0;
unsigned long lastWiFiRetryTime = 0;

void enableCircuits() {
  pinMode(EN_PH, OUTPUT);
  pinMode(EN_DO, OUTPUT);
  pinMode(EN_EC, OUTPUT);
  pinMode(EN_RTD, OUTPUT);
  pinMode(EN_CO2, OUTPUT);
  pinMode(EN_HUM, OUTPUT);

  digitalWrite(EN_PH, LOW);
  digitalWrite(EN_DO, LOW);
  digitalWrite(EN_EC, LOW);

  digitalWrite(EN_RTD, HIGH);
  digitalWrite(EN_CO2, HIGH);
  digitalWrite(EN_HUM, HIGH);

  delay(2000);
}

void connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  Serial.println();
  Serial.print("[WiFi] 正在連線:");
  Serial.println(WIFI_SSID);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  unsigned long startedAt = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - startedAt < 15000) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("[WiFi] 連線成功");
    Serial.print("[WiFi] IP:");
    Serial.println(WiFi.localIP());
    Serial.print("[WiFi] RSSI:");
    Serial.print(WiFi.RSSI());
    Serial.println(" dBm");
  } else {
    Serial.println("[WiFi] 本次連線失敗,稍後重試");
  }
}

void maintainWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  if (millis() - lastWiFiRetryTime >= WIFI_RETRY_INTERVAL_MS) {
    lastWiFiRetryTime = millis();
    connectWiFi();
  }
}

void initializeTime() {
  configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER_1, NTP_SERVER_2);

  Serial.print("[NTP] 同步時間");
  struct tm timeInfo;

  for (int i = 0; i < 20; i++) {
    if (getLocalTime(&timeInfo, 1000)) {
      Serial.println();
      Serial.println("[NTP] 同步成功");
      return;
    }
    Serial.print(".");
  }

  Serial.println();
  Serial.println("[NTP] 尚未取得時間");
}

String getRecordedAt() {
  struct tm timeInfo;
  if (!getLocalTime(&timeInfo, 3000)) return "";

  char buffer[25];
  strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%S", &timeInfo);
  return String(buffer) + "+08:00";
}

bool receiveValue(Ezo_board& sensor, float& value) {
  receive_and_print_reading(sensor);

  if (sensor.get_error() != Ezo_board::SUCCESS) return false;

  value = sensor.get_last_received_reading();
  return !isnan(value);
}

void readSensors() {
  Serial.println();
  Serial.println("========== 感測器讀值 ==========");

  RTD.send_read_cmd();
  delay(READ_DELAY_MS);

  float temperature = NAN;
  sensorData.temperatureValid = receiveValue(RTD, temperature);

  if (sensorData.temperatureValid && temperature > -1000.0f) {
    sensorData.waterTemperature = temperature;
    Serial.printf("水溫 RTD:%.2f C\n", sensorData.waterTemperature);

    PH.send_cmd_with_num("T,", temperature);
    DO.send_cmd_with_num("T,", temperature);
    EC.send_cmd_with_num("T,", temperature);
  } else {
    sensorData.temperatureValid = false;
    Serial.println("水溫 RTD:無有效資料");

    PH.send_cmd_with_num("T,", 25.0);
    DO.send_cmd_with_num("T,", 25.0);
    EC.send_cmd_with_num("T,", 25.0);
  }

  delay(350);

  PH.send_read_cmd();
  DO.send_read_cmd();
  EC.send_read_cmd();

  delay(READ_DELAY_MS);

  float phValue = NAN;
  float doValue = NAN;
  float ecValue = NAN;

  sensorData.phValid = receiveValue(PH, phValue);
  sensorData.dissolvedOxygenValid = receiveValue(DO, doValue);
  sensorData.conductivityValid = receiveValue(EC, ecValue);

  if (sensorData.phValid) sensorData.ph = phValue;
  if (sensorData.dissolvedOxygenValid) sensorData.dissolvedOxygen = doValue;

  if (sensorData.conductivityValid) {
    sensorData.conductivity = ecValue;
    sensorData.salinity = ecValue * EC_TO_SALINITY_FACTOR;
  }

  Serial.println();
  Serial.println("---------- 結果 ----------");

  if (sensorData.phValid) Serial.printf("pH:%.2f\n", sensorData.ph);
  else Serial.println("pH:無有效資料");

  if (sensorData.dissolvedOxygenValid) {
    Serial.printf("溶氧 DO:%.2f mg/L\n", sensorData.dissolvedOxygen);
  } else {
    Serial.println("溶氧 DO:無有效資料");
  }

  if (sensorData.conductivityValid) {
    Serial.printf("導電度 EC:%.0f uS/cm\n", sensorData.conductivity);
    Serial.printf("換算鹽度:%.2f ppt\n", sensorData.salinity);
  } else {
    Serial.println("導電度 EC:無有效資料");
  }

  Serial.println("==========================");
}

String jsonNumberOrNull(float value, bool valid, unsigned int decimals) {
  if (!valid || isnan(value)) return "null";
  return String((double)value, decimals);
}

String escapeJsonString(const String& input) {
  String output;
  output.reserve(input.length() + 8);

  for (size_t i = 0; i < input.length(); i++) {
    char c = input.charAt(i);
    switch (c) {
      case '"': output += "\\\""; break;
      case '\\': output += "\\\\"; break;
      case '\n': output += "\\n"; break;
      case '\r': output += "\\r"; break;
      case '\t': output += "\\t"; break;
      default: output += c; break;
    }
  }

  return output;
}

String buildPayload(const String& recordedAt) {
  String payload;
  payload.reserve(420);

  payload += "{";
  payload += "\"token\":\"" + escapeJsonString(API_TOKEN) + "\",";
  payload += "\"farm_name\":\"" + escapeJsonString(FARM_NAME) + "\",";
  payload += "\"pond_code\":\"" + escapeJsonString(POND_CODE) + "\",";

  payload += "\"water_temperature\":";
  payload += jsonNumberOrNull(sensorData.waterTemperature, sensorData.temperatureValid, 2);
  payload += ",";

  payload += "\"salinity\":";
  payload += jsonNumberOrNull(sensorData.salinity, sensorData.conductivityValid, 2);
  payload += ",";

  payload += "\"ph\":";
  payload += jsonNumberOrNull(sensorData.ph, sensorData.phValid, 2);
  payload += ",";

  payload += "\"dissolved_oxygen\":";
  payload += jsonNumberOrNull(sensorData.dissolvedOxygen, sensorData.dissolvedOxygenValid, 2);
  payload += ",";

  payload += "\"water_source\":\"" + escapeJsonString(WATER_SOURCE) + "\",";
  payload += "\"recorded_at\":\"" + recordedAt + "\"";
  payload += "}";

  return payload;
}

void uploadToDjango() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[API] Wi-Fi 未連線,本次不上傳");
    return;
  }

  String recordedAt = getRecordedAt();
  if (recordedAt.length() == 0) {
    Serial.println("[API] 尚未取得 NTP 時間,本次不上傳");
    return;
  }

  String payload = buildPayload(recordedAt);

  NetworkClientSecure secureClient;
  secureClient.setInsecure(); // 測試階段;正式部署建議改用 CA 憑證

  HTTPClient http;
  http.setTimeout(15000);

  Serial.println();
  Serial.println("========== Django API 上傳 ==========");
  Serial.print("[API] URL:");
  Serial.println(API_URL);
  Serial.print("[API] Payload:");
  Serial.println(payload);

  if (!http.begin(secureClient, API_URL)) {
    Serial.println("[API] HTTPS 初始化失敗");
    return;
  }

  http.addHeader("Content-Type", "application/json");
  http.addHeader("Accept", "application/json");

  int httpCode = http.POST(payload);
  Serial.print("[API] HTTP 狀態碼:");
  Serial.println(httpCode);

  if (httpCode > 0) {
    String response = http.getString();
    Serial.print("[API] 回應:");
    Serial.println(response);

    if (httpCode >= 200 && httpCode < 300) {
      Serial.println("[API] 上傳成功");
    } else {
      Serial.println("[API] 上傳失敗,請檢查 token、欄位或資料格式");
    }
  } else {
    Serial.print("[API] 連線錯誤:");
    Serial.println(http.errorToString(httpCode));
  }

  http.end();
  Serial.println("====================================");
}

void setup() {
  Serial.begin(115200);
  delay(1500);

  Serial.println();
  Serial.println("Atlas Aquaponics V1.8 + Django API 啟動");
  Serial.printf("SDA=GPIO%u, SCL=GPIO%u\n", I2C_SDA, I2C_SCL);
  Serial.printf("EC 位址=%u (0x%02X)\n", ADDR_EC, ADDR_EC);

  enableCircuits();

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000);
  Wire.setTimeOut(1000);

  connectWiFi();
  if (WiFi.status() == WL_CONNECTED) initializeTime();

  readSensors();

  lastSensorTime = millis();
  lastUploadTime = millis();
}

void loop() {
  maintainWiFi();

  unsigned long now = millis();

  if (now - lastSensorTime >= SENSOR_INTERVAL_MS) {
    lastSensorTime = now;
    readSensors();
  }

  if (now - lastUploadTime >= UPLOAD_INTERVAL_MS) {
    lastUploadTime = now;
    uploadToDjango();
  }

  delay(10);
}

Wire.begin(23, 22) 明確指定 SDA 與 SCL,避免使用其他 ESP32 開發板的預設值。 EC 物件則必須使用已確認的位址 105,否則會出現 No Data 或 I²C 傳輸失敗。

四、為什麼先讀 RTD?

pH、DO 與 EC 都會受到水溫影響。因此每輪量測先讀取 RTD,再把溫度傳給其他三個模組, 能讓讀值更符合當下水體狀況。

程式碼 2:溫度補償流程
RTD.send_read_cmd();
delay(READ_DELAY_MS);

float temperature = NAN;
sensorData.temperatureValid =
  receiveValue(RTD, temperature);

if (sensorData.temperatureValid &&
    temperature > -1000.0f) {
  sensorData.waterTemperature = temperature;

  PH.send_cmd_with_num("T,", temperature);
  DO.send_cmd_with_num("T,", temperature);
  EC.send_cmd_with_num("T,", temperature);
} else {
  PH.send_cmd_with_num("T,", 25.0);
  DO.send_cmd_with_num("T,", 25.0);
  EC.send_cmd_with_num("T,", 25.0);
}

若 RTD 暫時失效,程式改用 25°C 作為備援值,避免 pH、DO 與 EC 全部停止工作。 這是一種「容錯設計」,但正式系統仍應把 RTD 失效狀況記錄或發出警示。

五、Wi-Fi 與 Django REST API 設定

程式碼 3:Wi-Fi、API 與場域識別
const char* WIFI_SSID = "請填入WiFi名稱";
const char* WIFI_PASSWORD = "請填入WiFi密碼";

const char* API_URL =
  "https://shuijingusr.pythonanywhere.com/api/pond/sensor-reading/";

const char* API_TOKEN = "abc123";
const char* FARM_NAME = "湖虎戰隊";
const char* POND_CODE = "1";
const char* WATER_SOURCE = "直流變頻水車運轉中";

constexpr uint8_t I2C_SDA = 23;
constexpr uint8_t I2C_SCL = 22;

constexpr uint8_t EN_PH  = 13;
constexpr uint8_t EN_DO  = 12;
constexpr uint8_t EN_EC  = 27;
constexpr uint8_t EN_RTD = 33;
constexpr uint8_t EN_CO2 = 15;
constexpr uint8_t EN_HUM = 32;

constexpr uint8_t ADDR_DO  = 97;   // 0x61
constexpr uint8_t ADDR_PH  = 99;   // 0x63
constexpr uint8_t ADDR_RTD = 102;  // 0x66
constexpr uint8_t ADDR_EC  = 105;  // 0x69,本機 EC 已改址

Ezo_board PH  = Ezo_board(ADDR_PH,  "PH");
Ezo_board DO  = Ezo_board(ADDR_DO,  "DO");
Ezo_board EC  = Ezo_board(ADDR_EC,  "EC");
Ezo_board RTD = Ezo_board(ADDR_RTD, "RTD");

constexpr unsigned long READ_DELAY_MS = 1100;
constexpr unsigned long SENSOR_INTERVAL_MS = 5000;
constexpr unsigned long UPLOAD_INTERVAL_MS = 15000;
constexpr unsigned long WIFI_RETRY_INTERVAL_MS = 10000;

// EC 換算鹽度暫用係數;正式部署前應以標準液或鹽度計校正。
constexpr float EC_TO_SALINITY_FACTOR = 0.00064f;

const long GMT_OFFSET_SEC = 8L * 60L * 60L;
const int DAYLIGHT_OFFSET_SEC = 0;
const char* NTP_SERVER_1 = "pool.ntp.org";
const char* NTP_SERVER_2 = "time.google.com";

struct SensorData {
  float waterTemperature;
  float ph;
  float dissolvedOxygen;
  float conductivity;
  float salinity;

  bool temperatureValid;
  bool phValid;
  bool dissolvedOxygenValid;
  bool conductivityValid;
};

SensorData sensorData = {
  NAN, NAN, NAN, NAN, NAN,
  false, false, false, false
};

unsigned long lastSensorTime = 0;
unsigned long lastUploadTime = 0;
unsigned long lastWiFiRetryTime = 0;

void enableCircuits() {
  pinMode(EN_PH, OUTPUT);
  pinMode(EN_DO, OUTPUT);
  pinMode(EN_EC, OUTPUT);
  pinMode(EN_RTD, OUTPUT);
  pinMode(EN_CO2, OUTPUT);
  pinMode(EN_HUM, OUTPUT);

  digitalWrite(EN_PH, LOW);
  digitalWrite(EN_DO, LOW);
  digitalWrite(EN_EC, LOW);

  digitalWrite(EN_RTD, HIGH);
  digitalWrite(EN_CO2, HIGH);
  digitalWrite(EN_HUM, HIGH);

  delay(2000);
}

void connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  Serial.println();
  Serial.print("[WiFi] 正在連線:");
  Serial.println(WIFI_SSID);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  unsigned long startedAt = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - startedAt < 15000) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("[WiFi] 連線成功");
    Serial.print("[WiFi] IP:");
    Serial.println(WiFi.localIP());
    Serial.print("[WiFi] RSSI:");
    Serial.print(WiFi.RSSI());
    Serial.println(" dBm");
  } else {
    Serial.println("[WiFi] 本次連線失敗,稍後重試");
  }
}

void maintainWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  if (millis() - lastWiFiRetryTime >= WIFI_RETRY_INTERVAL_MS) {
    lastWiFiRetryTime = millis();
    connectWiFi();
  }
}

void initializeTime() {
  configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER_1, NTP_SERVER_2);

  Serial.print("[NTP] 同步時間");
  struct tm timeInfo;

  for (int i = 0; i < 20; i++) {
    if (getLocalTime(&timeInfo, 1000)) {
      Serial.println();
      Serial.println("[NTP] 同步成功");
      return;
    }
    Serial.print(".");
  }

  Serial.println();
  Serial.println("[NTP] 尚未取得時間");
}

String getRecordedAt() {
  struct tm timeInfo;
  if (!getLocalTime(&timeInfo, 3000)) return "";

  char buffer[25];
  strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%S", &timeInfo);
  return String(buffer) + "+08:00";
}

bool receiveValue(Ezo_board& sensor, float& value) {
  receive_and_print_reading(sensor);

  if (sensor.get_error() != Ezo_board::SUCCESS) return false;

  value = sensor.get_last_received_reading();
  return !isnan(value);
}

void readSensors() {
  Serial.println();
  Serial.println("========== 感測器讀值 ==========");

  RTD.send_read_cmd();
  delay(READ_DELAY_MS);

  float temperature = NAN;
  sensorData.temperatureValid = receiveValue(RTD, temperature);

  if (sensorData.temperatureValid && temperature > -1000.0f) {
    sensorData.waterTemperature = temperature;
    Serial.printf("水溫 RTD:%.2f C\n", sensorData.waterTemperature);

    PH.send_cmd_with_num("T,", temperature);
    DO.send_cmd_with_num("T,", temperature);
    EC.send_cmd_with_num("T,", temperature);
  } else {
    sensorData.temperatureValid = false;
    Serial.println("水溫 RTD:無有效資料");

    PH.send_cmd_with_num("T,", 25.0);
    DO.send_cmd_with_num("T,", 25.0);
    EC.send_cmd_with_num("T,", 25.0);
  }

  delay(350);

  PH.send_read_cmd();
  DO.send_read_cmd();
  EC.send_read_cmd();

  delay(READ_DELAY_MS);

  float phValue = NAN;
  float doValue = NAN;
  float ecValue = NAN;

  sensorData.phValid = receiveValue(PH, phValue);
  sensorData.dissolvedOxygenValid = receiveValue(DO, doValue);
  sensorData.conductivityValid = receiveValue(EC, ecValue);

  if (sensorData.phValid) sensorData.ph = phValue;
  if (sensorData.dissolvedOxygenValid) sensorData.dissolvedOxygen = doValue;

  if (sensorData.conductivityValid) {
    sensorData.conductivity = ecValue;
    sensorData.salinity = ecValue * EC_TO_SALINITY_FACTOR;
  }

  Serial.println();
  Serial.println("---------- 結果 ----------");

  if (sensorData.phValid) Serial.printf("pH:%.2f\n", sensorData.ph);
  else Serial.println("pH:無有效資料");

  if (sensorData.dissolvedOxygenValid) {
    Serial.printf("溶氧 DO:%.2f mg/L\n", sensorData.dissolvedOxygen);
  } else {
    Serial.println("溶氧 DO:無有效資料");
  }

  if (sensorData.conductivityValid) {
    Serial.printf("導電度 EC:%.0f uS/cm\n", sensorData.conductivity);
    Serial.printf("換算鹽度:%.2f ppt\n", sensorData.salinity);
  } else {
    Serial.println("導電度 EC:無有效資料");
  }

  Serial.println("==========================");
}

String jsonNumberOrNull(float value, bool valid, unsigned int decimals) {
  if (!valid || isnan(value)) return "null";
  return String((double)value, decimals);
}

String escapeJsonString(const String& input) {
  String output;
  output.reserve(input.length() + 8);

  for (size_t i = 0; i < input.length(); i++) {
    char c = input.charAt(i);
    switch (c) {
      case '"': output += "\\\""; break;
      case '\\': output += "\\\\"; break;
      case '\n': output += "\\n"; break;
      case '\r': output += "\\r"; break;
      case '\t': output += "\\t"; break;
      default: output += c; break;
    }
  }

  return output;
}

String buildPayload(const String& recordedAt) {
  String payload;
  payload.reserve(420);

  payload += "{";
  payload += "\"token\":\"" + escapeJsonString(API_TOKEN) + "\",";
  payload += "\"farm_name\":\"" + escapeJsonString(FARM_NAME) + "\",";
  payload += "\"pond_code\":\"" + escapeJsonString(POND_CODE) + "\",";

  payload += "\"water_temperature\":";
  payload += jsonNumberOrNull(sensorData.waterTemperature, sensorData.temperatureValid, 2);
  payload += ",";

  payload += "\"salinity\":";
  payload += jsonNumberOrNull(sensorData.salinity, sensorData.conductivityValid, 2);
  payload += ",";

  payload += "\"ph\":";
  payload += jsonNumberOrNull(sensorData.ph, sensorData.phValid, 2);
  payload += ",";

  payload += "\"dissolved_oxygen\":";
  payload += jsonNumberOrNull(sensorData.dissolvedOxygen, sensorData.dissolvedOxygenValid, 2);
  payload += ",";

  payload += "\"water_source\":\"" + escapeJsonString(WATER_SOURCE) + "\",";
  payload += "\"recorded_at\":\"" + recordedAt + "\"";
  payload += "}";

  return payload;
}

void uploadToDjango() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[API] Wi-Fi 未連線,本次不上傳");
    return;
  }

  String recordedAt = getRecordedAt();
  if (recordedAt.length() == 0) {
    Serial.println("[API] 尚未取得 NTP 時間,本次不上傳");
    return;
  }

  String payload = buildPayload(recordedAt);

  NetworkClientSecure secureClient;
  secureClient.setInsecure(); // 測試階段;正式部署建議改用 CA 憑證

  HTTPClient http;
  http.setTimeout(15000);

  Serial.println();
  Serial.println("========== Django API 上傳 ==========");
  Serial.print("[API] URL:");
  Serial.println(API_URL);
  Serial.print("[API] Payload:");
  Serial.println(payload);

  if (!http.begin(secureClient, API_URL)) {
    Serial.println("[API] HTTPS 初始化失敗");
    return;
  }

  http.addHeader("Content-Type", "application/json");
  http.addHeader("Accept", "application/json");

  int httpCode = http.POST(payload);
  Serial.print("[API] HTTP 狀態碼:");
  Serial.println(httpCode);

  if (httpCode > 0) {
    String response = http.getString();
    Serial.print("[API] 回應:");
    Serial.println(response);

    if (httpCode >= 200 && httpCode < 300) {
      Serial.println("[API] 上傳成功");
    } else {
      Serial.println("[API] 上傳失敗,請檢查 token、欄位或資料格式");
    }
  } else {
    Serial.print("[API] 連線錯誤:");
    Serial.println(http.errorToString(httpCode));
  }

  http.end();
  Serial.println("====================================");
}

void setup() {
  Serial.begin(115200);
  delay(1500);

  Serial.println();
  Serial.println("Atlas Aquaponics V1.8 + Django API 啟動");
  Serial.printf("SDA=GPIO%u, SCL=GPIO%u\n", I2C_SDA, I2C_SCL);
  Serial.printf("EC 位址=%u (0x%02X)\n", ADDR_EC, ADDR_EC);

  enableCircuits();

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000);
  Wire.setTimeOut(1000);

  connectWiFi();
  if (WiFi.status() == WL_CONNECTED) initializeTime();

  readSensors();

  lastSensorTime = millis();
  lastUploadTime = millis();
}

void loop() {
  maintainWiFi();

  unsigned long now = millis();

  if (now - lastSensorTime >= SENSOR_INTERVAL_MS) {
    lastSensorTime = now;
    readSensors();
  }

  if (now - lastUploadTime >= UPLOAD_INTERVAL_MS) {
    lastUploadTime = now;
    uploadToDjango();
  }

  delay(10);
}

farm_name 與 pond_code 用來識別資料屬於哪一個養殖戶及魚塭;同一套程式部署到其他魚塭時,只需修改這些參數。

API 接收端點:
https://shuijingusr.pythonanywhere.com/api/pond/sensor-reading/

六、使用 NTP 建立可信的時間戳記

感測資料若沒有時間,就無法製作趨勢圖、比較不同時段或判斷異常。因此 ESP32 連上 Wi-Fi 後, 透過 NTP 取得台灣時間,並轉換成 Django API 可接受的 ISO 8601 格式。

程式碼 4:時間格式
configTime(
  8L * 60L * 60L,
  0,
  "pool.ntp.org",
  "time.google.com"
);

// 輸出範例
2026-08-02T17:05:00+08:00

七、建立符合 API 規格的 JSON

Django REST API 要求固定欄位名稱,因此 ESP32 需把感測資料組成指定 JSON。 無效感測值會輸出為 null,避免把舊資料誤當成本次讀值。

程式碼 5:建立 JSON Payload
String buildPayload(const String& recordedAt) {
  String payload;
  payload.reserve(420);

  payload += "{";
  payload += "\"token\":\"" + escapeJsonString(API_TOKEN) + "\",";
  payload += "\"farm_name\":\"" + escapeJsonString(FARM_NAME) + "\",";
  payload += "\"pond_code\":\"" + escapeJsonString(POND_CODE) + "\",";

  payload += "\"water_temperature\":";
  payload += jsonNumberOrNull(sensorData.waterTemperature, sensorData.temperatureValid, 2);
  payload += ",";

  payload += "\"salinity\":";
  payload += jsonNumberOrNull(sensorData.salinity, sensorData.conductivityValid, 2);
  payload += ",";

  payload += "\"ph\":";
  payload += jsonNumberOrNull(sensorData.ph, sensorData.phValid, 2);
  payload += ",";

  payload += "\"dissolved_oxygen\":";
  payload += jsonNumberOrNull(sensorData.dissolvedOxygen, sensorData.dissolvedOxygenValid, 2);
  payload += ",";

  payload += "\"water_source\":\"" + escapeJsonString(WATER_SOURCE) + "\",";
  payload += "\"recorded_at\":\"" + recordedAt + "\"";
  payload += "}";

  return payload;
}

void uploadToDjango() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[API] Wi-Fi 未連線,本次不上傳");
    return;
  }

  String recordedAt = getRecordedAt();
  if (recordedAt.length() == 0) {
    Serial.println("[API] 尚未取得 NTP 時間,本次不上傳");
    return;
  }

  String payload = buildPayload(recordedAt);

  NetworkClientSecure secureClient;
  secureClient.setInsecure(); // 測試階段;正式部署建議改用 CA 憑證

  HTTPClient http;
  http.setTimeout(15000);

  Serial.println();
  Serial.println("========== Django API 上傳 ==========");
  Serial.print("[API] URL:");
  Serial.println(API_URL);
  Serial.print("[API] Payload:");
  Serial.println(payload);

  if (!http.begin(secureClient, API_URL)) {
    Serial.println("[API] HTTPS 初始化失敗");
    return;
  }

  http.addHeader("Content-Type", "application/json");
  http.addHeader("Accept", "application/json");

  int httpCode = http.POST(payload);
  Serial.print("[API] HTTP 狀態碼:");
  Serial.println(httpCode);

  if (httpCode > 0) {
    String response = http.getString();
    Serial.print("[API] 回應:");
    Serial.println(response);

    if (httpCode >= 200 && httpCode < 300) {
      Serial.println("[API] 上傳成功");
    } else {
      Serial.println("[API] 上傳失敗,請檢查 token、欄位或資料格式");
    }
  } else {
    Serial.print("[API] 連線錯誤:");
    Serial.println(http.errorToString(httpCode));
  }

  http.end();
  Serial.println("====================================");
}

void setup() {
  Serial.begin(115200);
  delay(1500);

  Serial.println();
  Serial.println("Atlas Aquaponics V1.8 + Django API 啟動");
  Serial.printf("SDA=GPIO%u, SCL=GPIO%u\n", I2C_SDA, I2C_SCL);
  Serial.printf("EC 位址=%u (0x%02X)\n", ADDR_EC, ADDR_EC);

  enableCircuits();

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000);
  Wire.setTimeOut(1000);

  connectWiFi();
  if (WiFi.status() == WL_CONNECTED) initializeTime();

  readSensors();

  lastSensorTime = millis();
  lastUploadTime = millis();
}

void loop() {
  maintainWiFi();

  unsigned long now = millis();

  if (now - lastSensorTime >= SENSOR_INTERVAL_MS) {
    lastSensorTime = now;
    readSensors();
  }

  if (now - lastUploadTime >= UPLOAD_INTERVAL_MS) {
    lastUploadTime = now;
    uploadToDjango();
  }

  delay(10);
}

實際送出的資料格式如下:

JSON 範例
{
  "token": "abc123",
  "farm_name": "湖虎戰隊",
  "pond_code": "1",
  "water_temperature": 30.01,
  "salinity": 0.28,
  "ph": 6.80,
  "dissolved_oxygen": 31.92,
  "water_source": "直流變頻水車運轉中",
  "recorded_at": "2026-08-02T17:05:00+08:00"
}
鹽度換算提醒:目前以 EC × 0.00064 進行近似換算。 正式部署前應使用標準鹽度液或可靠鹽度計校正。若 EZO-EC 已直接啟用 Salinity 輸出,則應直接解析模組回傳的鹽度欄位,不宜再以固定係數換算。

八、使用 HTTPS POST 上傳 Django

程式碼 6:HTTPS POST 核心流程
void uploadToDjango() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[API] Wi-Fi 未連線,本次不上傳");
    return;
  }

  String recordedAt = getRecordedAt();
  if (recordedAt.length() == 0) {
    Serial.println("[API] 尚未取得 NTP 時間,本次不上傳");
    return;
  }

  String payload = buildPayload(recordedAt);

  NetworkClientSecure secureClient;
  secureClient.setInsecure(); // 測試階段;正式部署建議改用 CA 憑證

  HTTPClient http;
  http.setTimeout(15000);

  Serial.println();
  Serial.println("========== Django API 上傳 ==========");
  Serial.print("[API] URL:");
  Serial.println(API_URL);
  Serial.print("[API] Payload:");
  Serial.println(payload);

  if (!http.begin(secureClient, API_URL)) {
    Serial.println("[API] HTTPS 初始化失敗");
    return;
  }

  http.addHeader("Content-Type", "application/json");
  http.addHeader("Accept", "application/json");

  int httpCode = http.POST(payload);
  Serial.print("[API] HTTP 狀態碼:");
  Serial.println(httpCode);

  if (httpCode > 0) {
    String response = http.getString();
    Serial.print("[API] 回應:");
    Serial.println(response);

    if (httpCode >= 200 && httpCode < 300) {
      Serial.println("[API] 上傳成功");
    } else {
      Serial.println("[API] 上傳失敗,請檢查 token、欄位或資料格式");
    }
  } else {
    Serial.print("[API] 連線錯誤:");
    Serial.println(http.errorToString(httpCode));
  }

  http.end();
  Serial.println("====================================");
}

void setup() {
  Serial.begin(115200);
  delay(1500);

  Serial.println();
  Serial.println("Atlas Aquaponics V1.8 + Django API 啟動");
  Serial.printf("SDA=GPIO%u, SCL=GPIO%u\n", I2C_SDA, I2C_SCL);
  Serial.printf("EC 位址=%u (0x%02X)\n", ADDR_EC, ADDR_EC);

  enableCircuits();

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000);
  Wire.setTimeOut(1000);

  connectWiFi();
  if (WiFi.status() == WL_CONNECTED) initializeTime();

  readSensors();

  lastSensorTime = millis();
  lastUploadTime = millis();
}

void loop() {
  maintainWiFi();

  unsigned long now = millis();

  if (now - lastSensorTime >= SENSOR_INTERVAL_MS) {
    lastSensorTime = now;
    readSensors();
  }

  if (now - lastUploadTime >= UPLOAD_INTERVAL_MS) {
    lastUploadTime = now;
    uploadToDjango();
  }

  delay(10);
}

上傳時設定 Content-Type: application/json, Django 才能將 Request Body 解析為 JSON。若回傳 200~299,表示伺服器已接受資料; 常見的成功狀態是 201 Created。

HTTP 狀態 意義 建議檢查
201 資料建立成功 可到 Dashboard 查看最新資料
400 JSON 或欄位格式錯誤 檢查欄位名稱、資料型態與必填值
401/403 驗證失敗 檢查 token 或後端權限
500 後端程式錯誤 檢查 Django Log、Model 與 Serializer
負值 ESP32 網路或 TLS 連線錯誤 檢查 Wi-Fi、DNS、網址與憑證

九、讀取與上傳週期

5 秒更新感測值
15 秒上傳雲端
4 項水質感測資料
HTTPSJSON 傳輸

感測器可以頻繁更新,但不必每次讀值都立刻寫入資料庫。採用「5 秒讀取、15 秒上傳」, 可以兼顧即時性、網路負擔與資料庫容量。

十、從資料到 Dashboard

Django 後端收到資料後,依序完成:

  1. 驗證 token。
  2. 確認 farm_name 與 pond_code。
  3. 檢查水溫、鹽度、pH、溶氧與時間格式。
  4. 將資料寫入資料庫。
  5. 回傳 JSON 與 HTTP 狀態。
  6. Dashboard 查詢各魚塭最新一筆資料並呈現。
實際整合後,Dashboard 已可顯示「湖虎戰隊」的水溫、鹽度、pH 與溶氧資料, 並與其他養殖場域並列,形成多養殖戶即時監控介面。

前往水井村 USR 智慧養殖監控平台

十一、實作中最重要的除錯經驗

  1. 先確認開發板:本案為 Adafruit HUZZAH32 ESP32 Feather,PSRAM 應設為 Disabled。
  2. 不要套用別款板子的腳位:實際 SDA/SCL 是 GPIO23/22。
  3. 先掃描 I²C:EC 實際位址為 0x69,而不是假設中的 0x64。
  4. 分層測試:先驗證硬體讀值,再加入 Wi-Fi,最後才加入 API。
  5. 保留伺服器回應:序列監控要印出 HTTP 狀態與 Response Body,才能快速定位錯誤。
  6. 感測值需校正:通訊成功不代表量測一定正確,pH、DO 與 EC 仍應依原廠程序校正。

十二、後續發展

完成 ESP32 與 Django REST API 整合後,可以進一步加入:

  • 水質異常門檻與 Line/Email 通知。
  • 歷史趨勢圖與日、週、月報表。
  • 多魚塭 LoRa 節點與集中式 Gateway。
  • 低溶氧時自動啟動直流變頻水車。
  • Raspberry Pi、LLM、RAG 與 AI Agent 養殖決策建議。
  • 把感測、控制、節能與減碳成果納入水井村 USR 智慧養殖示範。

結語

這套系統的價值,不只是把四個數值顯示在網頁上,而是建立一條可追蹤、可分析、可擴充的智慧養殖資料鏈。 Atlas Scientific 提供穩定的水質感測,ESP32 完成邊緣運算與網路傳輸,Django REST API 負責資料治理, Dashboard 則讓養殖戶、研究團隊與場域管理者可以在同一個介面掌握即時狀況。

AI 協作聲明: 本文由作者主導內容規劃,並使用生成式 AI 協助文字整理、程式碼說明、版面設計與內容潤飾; 文章所述腳位、I²C 位址、感測流程與 Django REST API 整合,均依實際硬體測試結果整理。

附錄:程式碼

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
/*
  水井 USR 智慧養殖
  Atlas Scientific Aquaponics V1.8 + Django REST API 整合版

  硬體:
  - Adafruit HUZZAH32 ESP32 Feather
  - Atlas Aquaponics V1.8
  - RTD / pH / DO / EC

  已驗證腳位:
  pH EN   -> GPIO13
  DO EN   -> GPIO12
  EC EN   -> GPIO27
  RTD EN  -> GPIO33
  CO2 EN  -> GPIO15
  HUM EN  -> GPIO32

  I2C:
  SDA -> GPIO23
  SCL -> GPIO22

  已確認 I2C 位址:
  DO  -> 0x61
  pH  -> 0x63
  RTD -> 0x66
  EC  -> 0x69(本機已改址)

  Arduino IDE:
  Board: Adafruit ESP32 Feather
  PSRAM: Disabled
  Serial Monitor: 115200
*/

#include <Wire.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <NetworkClientSecure.h>
#include <time.h>

#include <Ezo_i2c.h>
#include <Ezo_i2c_util.h>

const char* WIFI_SSID = "請填入WiFi名稱";
const char* WIFI_PASSWORD = "請填入WiFi密碼";

const char* API_URL =
  "https://shuijingusr.pythonanywhere.com/api/pond/sensor-reading/";

const char* API_TOKEN = "abc123";
const char* FARM_NAME = "湖虎戰隊";
const char* POND_CODE = "1";
const char* WATER_SOURCE = "直流變頻水車運轉中";

constexpr uint8_t I2C_SDA = 23;
constexpr uint8_t I2C_SCL = 22;

constexpr uint8_t EN_PH  = 13;
constexpr uint8_t EN_DO  = 12;
constexpr uint8_t EN_EC  = 27;
constexpr uint8_t EN_RTD = 33;
constexpr uint8_t EN_CO2 = 15;
constexpr uint8_t EN_HUM = 32;

constexpr uint8_t ADDR_DO  = 97;   // 0x61
constexpr uint8_t ADDR_PH  = 99;   // 0x63
constexpr uint8_t ADDR_RTD = 102;  // 0x66
constexpr uint8_t ADDR_EC  = 105;  // 0x69,本機 EC 已改址

Ezo_board PH  = Ezo_board(ADDR_PH,  "PH");
Ezo_board DO  = Ezo_board(ADDR_DO,  "DO");
Ezo_board EC  = Ezo_board(ADDR_EC,  "EC");
Ezo_board RTD = Ezo_board(ADDR_RTD, "RTD");

constexpr unsigned long READ_DELAY_MS = 1100;
constexpr unsigned long SENSOR_INTERVAL_MS = 5000;
constexpr unsigned long UPLOAD_INTERVAL_MS = 15000;
constexpr unsigned long WIFI_RETRY_INTERVAL_MS = 10000;

// EC 換算鹽度暫用係數;正式部署前應以標準液或鹽度計校正。
constexpr float EC_TO_SALINITY_FACTOR = 0.00064f;

const long GMT_OFFSET_SEC = 8L * 60L * 60L;
const int DAYLIGHT_OFFSET_SEC = 0;
const char* NTP_SERVER_1 = "pool.ntp.org";
const char* NTP_SERVER_2 = "time.google.com";

struct SensorData {
  float waterTemperature;
  float ph;
  float dissolvedOxygen;
  float conductivity;
  float salinity;

  bool temperatureValid;
  bool phValid;
  bool dissolvedOxygenValid;
  bool conductivityValid;
};

SensorData sensorData = {
  NAN, NAN, NAN, NAN, NAN,
  false, false, false, false
};

unsigned long lastSensorTime = 0;
unsigned long lastUploadTime = 0;
unsigned long lastWiFiRetryTime = 0;

void enableCircuits() {
  pinMode(EN_PH, OUTPUT);
  pinMode(EN_DO, OUTPUT);
  pinMode(EN_EC, OUTPUT);
  pinMode(EN_RTD, OUTPUT);
  pinMode(EN_CO2, OUTPUT);
  pinMode(EN_HUM, OUTPUT);

  digitalWrite(EN_PH, LOW);
  digitalWrite(EN_DO, LOW);
  digitalWrite(EN_EC, LOW);

  digitalWrite(EN_RTD, HIGH);
  digitalWrite(EN_CO2, HIGH);
  digitalWrite(EN_HUM, HIGH);

  delay(2000);
}

void connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  Serial.println();
  Serial.print("[WiFi] 正在連線:");
  Serial.println(WIFI_SSID);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  unsigned long startedAt = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - startedAt < 15000) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("[WiFi] 連線成功");
    Serial.print("[WiFi] IP:");
    Serial.println(WiFi.localIP());
    Serial.print("[WiFi] RSSI:");
    Serial.print(WiFi.RSSI());
    Serial.println(" dBm");
  } else {
    Serial.println("[WiFi] 本次連線失敗,稍後重試");
  }
}

void maintainWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  if (millis() - lastWiFiRetryTime >= WIFI_RETRY_INTERVAL_MS) {
    lastWiFiRetryTime = millis();
    connectWiFi();
  }
}

void initializeTime() {
  configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER_1, NTP_SERVER_2);

  Serial.print("[NTP] 同步時間");
  struct tm timeInfo;

  for (int i = 0; i < 20; i++) {
    if (getLocalTime(&timeInfo, 1000)) {
      Serial.println();
      Serial.println("[NTP] 同步成功");
      return;
    }
    Serial.print(".");
  }

  Serial.println();
  Serial.println("[NTP] 尚未取得時間");
}

String getRecordedAt() {
  struct tm timeInfo;
  if (!getLocalTime(&timeInfo, 3000)) return "";

  char buffer[25];
  strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%S", &timeInfo);
  return String(buffer) + "+08:00";
}

bool receiveValue(Ezo_board& sensor, float& value) {
  receive_and_print_reading(sensor);

  if (sensor.get_error() != Ezo_board::SUCCESS) return false;

  value = sensor.get_last_received_reading();
  return !isnan(value);
}

void readSensors() {
  Serial.println();
  Serial.println("========== 感測器讀值 ==========");

  RTD.send_read_cmd();
  delay(READ_DELAY_MS);

  float temperature = NAN;
  sensorData.temperatureValid = receiveValue(RTD, temperature);

  if (sensorData.temperatureValid && temperature > -1000.0f) {
    sensorData.waterTemperature = temperature;
    Serial.printf("水溫 RTD:%.2f C\n", sensorData.waterTemperature);

    PH.send_cmd_with_num("T,", temperature);
    DO.send_cmd_with_num("T,", temperature);
    EC.send_cmd_with_num("T,", temperature);
  } else {
    sensorData.temperatureValid = false;
    Serial.println("水溫 RTD:無有效資料");

    PH.send_cmd_with_num("T,", 25.0);
    DO.send_cmd_with_num("T,", 25.0);
    EC.send_cmd_with_num("T,", 25.0);
  }

  delay(350);

  PH.send_read_cmd();
  DO.send_read_cmd();
  EC.send_read_cmd();

  delay(READ_DELAY_MS);

  float phValue = NAN;
  float doValue = NAN;
  float ecValue = NAN;

  sensorData.phValid = receiveValue(PH, phValue);
  sensorData.dissolvedOxygenValid = receiveValue(DO, doValue);
  sensorData.conductivityValid = receiveValue(EC, ecValue);

  if (sensorData.phValid) sensorData.ph = phValue;
  if (sensorData.dissolvedOxygenValid) sensorData.dissolvedOxygen = doValue;

  if (sensorData.conductivityValid) {
    sensorData.conductivity = ecValue;
    sensorData.salinity = ecValue * EC_TO_SALINITY_FACTOR;
  }

  Serial.println();
  Serial.println("---------- 結果 ----------");

  if (sensorData.phValid) Serial.printf("pH:%.2f\n", sensorData.ph);
  else Serial.println("pH:無有效資料");

  if (sensorData.dissolvedOxygenValid) {
    Serial.printf("溶氧 DO:%.2f mg/L\n", sensorData.dissolvedOxygen);
  } else {
    Serial.println("溶氧 DO:無有效資料");
  }

  if (sensorData.conductivityValid) {
    Serial.printf("導電度 EC:%.0f uS/cm\n", sensorData.conductivity);
    Serial.printf("換算鹽度:%.2f ppt\n", sensorData.salinity);
  } else {
    Serial.println("導電度 EC:無有效資料");
  }

  Serial.println("==========================");
}

String jsonNumberOrNull(float value, bool valid, unsigned int decimals) {
  if (!valid || isnan(value)) return "null";
  return String((double)value, decimals);
}

String escapeJsonString(const String& input) {
  String output;
  output.reserve(input.length() + 8);

  for (size_t i = 0; i < input.length(); i++) {
    char c = input.charAt(i);
    switch (c) {
      case '"': output += "\\\""; break;
      case '\\': output += "\\\\"; break;
      case '\n': output += "\\n"; break;
      case '\r': output += "\\r"; break;
      case '\t': output += "\\t"; break;
      default: output += c; break;
    }
  }

  return output;
}

String buildPayload(const String& recordedAt) {
  String payload;
  payload.reserve(420);

  payload += "{";
  payload += "\"token\":\"" + escapeJsonString(API_TOKEN) + "\",";
  payload += "\"farm_name\":\"" + escapeJsonString(FARM_NAME) + "\",";
  payload += "\"pond_code\":\"" + escapeJsonString(POND_CODE) + "\",";

  payload += "\"water_temperature\":";
  payload += jsonNumberOrNull(sensorData.waterTemperature, sensorData.temperatureValid, 2);
  payload += ",";

  payload += "\"salinity\":";
  payload += jsonNumberOrNull(sensorData.salinity, sensorData.conductivityValid, 2);
  payload += ",";

  payload += "\"ph\":";
  payload += jsonNumberOrNull(sensorData.ph, sensorData.phValid, 2);
  payload += ",";

  payload += "\"dissolved_oxygen\":";
  payload += jsonNumberOrNull(sensorData.dissolvedOxygen, sensorData.dissolvedOxygenValid, 2);
  payload += ",";

  payload += "\"water_source\":\"" + escapeJsonString(WATER_SOURCE) + "\",";
  payload += "\"recorded_at\":\"" + recordedAt + "\"";
  payload += "}";

  return payload;
}

void uploadToDjango() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[API] Wi-Fi 未連線,本次不上傳");
    return;
  }

  String recordedAt = getRecordedAt();
  if (recordedAt.length() == 0) {
    Serial.println("[API] 尚未取得 NTP 時間,本次不上傳");
    return;
  }

  String payload = buildPayload(recordedAt);

  NetworkClientSecure secureClient;
  secureClient.setInsecure(); // 測試階段;正式部署建議改用 CA 憑證

  HTTPClient http;
  http.setTimeout(15000);

  Serial.println();
  Serial.println("========== Django API 上傳 ==========");
  Serial.print("[API] URL:");
  Serial.println(API_URL);
  Serial.print("[API] Payload:");
  Serial.println(payload);

  if (!http.begin(secureClient, API_URL)) {
    Serial.println("[API] HTTPS 初始化失敗");
    return;
  }

  http.addHeader("Content-Type", "application/json");
  http.addHeader("Accept", "application/json");

  int httpCode = http.POST(payload);
  Serial.print("[API] HTTP 狀態碼:");
  Serial.println(httpCode);

  if (httpCode > 0) {
    String response = http.getString();
    Serial.print("[API] 回應:");
    Serial.println(response);

    if (httpCode >= 200 && httpCode < 300) {
      Serial.println("[API] 上傳成功");
    } else {
      Serial.println("[API] 上傳失敗,請檢查 token、欄位或資料格式");
    }
  } else {
    Serial.print("[API] 連線錯誤:");
    Serial.println(http.errorToString(httpCode));
  }

  http.end();
  Serial.println("====================================");
}

void setup() {
  Serial.begin(115200);
  delay(1500);

  Serial.println();
  Serial.println("Atlas Aquaponics V1.8 + Django API 啟動");
  Serial.printf("SDA=GPIO%u, SCL=GPIO%u\n", I2C_SDA, I2C_SCL);
  Serial.printf("EC 位址=%u (0x%02X)\n", ADDR_EC, ADDR_EC);

  enableCircuits();

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000);
  Wire.setTimeOut(1000);

  connectWiFi();
  if (WiFi.status() == WL_CONNECTED) initializeTime();

  readSensors();

  lastSensorTime = millis();
  lastUploadTime = millis();
}

void loop() {
  maintainWiFi();

  unsigned long now = millis();

  if (now - lastSensorTime >= SENSOR_INTERVAL_MS) {
    lastSensorTime = now;
    readSensors();
  }

  if (now - lastUploadTime >= UPLOAD_INTERVAL_MS) {
    lastUploadTime = now;
    uploadToDjango();
  }

  delay(10);
}