2026年8月3日 星期一

從《勤勞小蜜蜂》部落格文章認識 HTML:用正確標籤建立清楚、有意義的網頁

智慧生活科技專業社群|大一新生網頁設計入門

從《勤勞小蜜蜂》部落格文章認識 HTML:用正確標籤建立清楚、有意義的網頁

HTML 不只是把文字放上網頁,更是在描述內容的角色。從一篇真實文章拆解標題、段落、圖片、清單、連結與語意結構。

打開一個網頁時,我們看到的是標題、文章、圖片與按鈕;瀏覽器看到的則是一組具有結構的 HTML 標籤。HTML 是 HyperText Markup Language 的縮寫,中文通常譯為「超文字標記語言」。它不是用來計算或控制流程的程式語言,而是用標籤說明一段內容是什麼。

本文以《勤勞小蜜蜂:爸爸教我的四種力量》部落格文章為例。原文有活動介紹、四種力量清單、Tale-Bot Pro 教學內容、SDGs 說明、圖片與教材資訊,因此能一次看到多種常用 HTML 標籤如何合作。

讀完本文,你將能夠:
  • 辨認開始標籤、結束標籤、內容與屬性。
  • 使用標題、段落、清單與連結組織文章。
  • 以語意標籤表達頁首、主要文章、區段與頁尾。
  • 為圖片加入替代文字,照顧無障礙與載入失敗情況。
  • 理解 classid 與常見連結屬性的用途。

一、一個 HTML 元素由什麼組成?

以段落為例,<p> 是開始標籤,</p> 是結束標籤,中間是使用者會讀到的內容。三者合起來稱為一個 HTML 元素。

段落元素

<p>父愛不只有一種模樣。</p>
<p>開始標籤
父愛……元素內容
</p>結束標籤
完整一組HTML 元素

有些元素沒有成對的結束標籤,例如圖片使用 <img>。圖片內容不是文字,而是透過 src 屬性指定檔案位置。

重要觀念:標籤名稱說明內容的角色,CSS 才負責外觀。不要因為想把文字變大,就隨意使用標題標籤;應先判斷它在文章結構中是不是標題。

二、用語意標籤建立文章骨架

原文最外層使用 <article>,表示這是一篇可以獨立閱讀或分享的文章。文章標題區使用 <header>,內容可再用 <section> 分組,最後的補充資訊則可放在 <footer>

文章的語意結構

<article class="slt-post">
  <header>
    <h1>文章主標題</h1>
  </header>

  <section>
    <h2>第一個主題</h2>
    <p>主題內容</p>
  </section>

  <footer>作者與教材資訊</footer>
</article>
<article>可獨立成立的文章、新聞、貼文或作品。
<header>文章或區段的開頭,可放標題、摘要與分類。
<section>具有共同主題的一組內容,通常應有標題。
<footer>文章或區段的結尾資訊,例如作者、版權與延伸說明。

這些語意標籤通常不會自動帶來華麗外觀,但能幫助搜尋引擎、螢幕閱讀器與維護者理解文件。這也是為什麼「看起來一樣」的兩份網頁,HTML 品質仍可能不同。

三、標題與段落:建立清楚的閱讀層級

HTML 提供 <h1><h6> 六個標題層級。原則上,一篇文章以 <h1> 作為主標題,主要章節使用 <h2>,章節內的子題再使用 <h3>

標題層級與段落

<h1>勤勞小蜜蜂:爸爸教我的四種力量</h1>

<h2>四位爸爸,帶來四種父愛力量</h2>
<p>父愛不只有一種模樣。</p>

<h3>企鵝爸爸:守護</h3>
<p>在冰雪與寒風中穩穩護住蛋。</p>

不要只因為 <h1> 預設字體最大,就用它放大文字;也不要從 <h2> 突然跳到 <h5>。先建立合理層級,再用 CSS 調整大小和顏色。

四、清單標籤:有順序與沒有順序要分清楚

步驟、排名與具有先後關係的內容適合使用有序清單 <ol>;條件、特色與不分先後的項目適合使用無序清單 <ul>。每一個清單項目都要放在 <li> 中。

四種力量使用有序清單

<ol class="power-list">
  <li><b>企鵝爸爸|守護</b></li>
  <li><b>海馬爸爸|愛與生命</b></li>
  <li><b>老鷹爸爸|勇敢成長</b></li>
  <li><b>勇鬃爸爸|責任與勇氣</b></li>
</ol>

即使 CSS 把預設數字隱藏並改成圓形編號,HTML 仍保留 <ol>,因為這四項是依序介紹的內容。這說明 HTML 負責語意,CSS 負責呈現,兩者各司其職。

五、圖片不是只有 img:figure、figcaption 與 alt

文章中的封面圖不只是裝飾,它有自己的說明文字,因此可使用 <figure> 包住圖片,再用 <figcaption> 放置圖說。

具有圖說與替代文字的圖片

<figure>
  <img
    src="cover.jpg"
    alt="勤勞小蜜蜂父親節繪本封面"
    width="1200"
    height="849">
  <figcaption>父親節 Tale-Bot Pro 課堂繪本</figcaption>
</figure>

alt 要怎麼寫?

  • 描述圖片在目前文章中傳達的必要資訊,而不是把檔名重複一遍。
  • 避免使用「這是一張……的圖片」等多餘開頭,螢幕閱讀器通常已會告知它是圖片。
  • 純裝飾圖片可使用空值 alt="",讓輔助工具略過。
  • 圖片若同時也是連結,替代文字應說明點擊後的目的。
實務提醒:alt 是圖片無法載入時的替代資訊,也服務使用螢幕閱讀器的讀者;figcaption 則是所有讀者都看得到的圖說。兩者用途不同。

六、連結標籤與安全屬性

<a> 用來建立超連結,最重要的屬性是 href。原文的 PDF 下載按鈕在語意上仍是一個連結,只是透過 CSS 把它畫成按鈕外觀。

在新分頁開啟 PDF

<a
  href="picture-book.pdf"
  target="_blank"
  rel="noopener">
  下載 A4 橫式繪本 PDF
</a>
  • href:連結目的地,可以是網頁、圖片、PDF 或頁面內的位置。
  • target="_blank":在新分頁或新視窗開啟。
  • rel="noopener":新分頁開啟外部內容時的安全防護。

連結文字應能單獨說明目的。比起「按這裡」,「下載 A4 橫式繪本 PDF」更容易理解,也有利於無障礙操作。

七、引用內容使用 blockquote

原文章把一段重要教學觀點放在 <blockquote> 中。這個標籤表示一段區塊引用;CSS 再替它加上米白背景與左側色條。

區塊引用

<blockquote class="slt-quote">
  孩子走錯路時,正是在學習把問題變成
  「可以再試一次」的步驟。
</blockquote>

如果只是想替普通段落加背景,不應為了外觀濫用 <blockquote>;可使用 <aside><div> 或一般段落,再配合適當類別。

八、資料名稱與內容適合使用 dl、dt、dd

作者、出版單位與計畫名稱是一組「名稱—內容」資料。原文使用描述清單 <dl>,其中 <dt> 是名稱,<dd> 是對應說明。

教材資訊的描述清單

<dl>
  <dt>計畫名稱</dt>
  <dd>115年度官邸兒童館營運推廣案經營計畫</dd>

  <dt>繪本作者</dt>
  <dd>林正敏</dd>
</dl>

描述清單也適合術語解釋、問答配對、產品規格與聯絡資料。它比一串用冒號分隔的普通段落更能表達資料關係。

九、div、span、class 與 id 各自負責什麼?

<div> 是沒有特定語意的區塊容器,<span> 則是行內容器。當沒有更合適的語意標籤,又需要分組或套用樣式時才使用它們。

名稱用途範例注意事項
<div>分組一整塊內容。活動資訊卡、按鈕列。有合適的 article、section、figure 時,優先使用語意標籤。
<span>標記句子中的一小段。替關鍵字或程式碼片段加色。不會自動換行,不適合包住完整區段。
class替一個或多個元素建立分類名稱。class="info-card"同一個 class 可以重複使用。
id識別頁面中唯一的元素,也可作為頁內連結目標。id="activity"同一頁不要出現兩個相同 id。

同時使用 class 與 id

<section id="activity" class="info-section">
  <h2>活動資訊</h2>
</section>

<a href="#activity">前往活動資訊</a>

十、註解與特殊字元

HTML 註解不會顯示在一般網頁畫面中,適合留下編輯提醒。原文章以註解提示張貼者把圖片與 PDF 的示意網址換成正式網址。

編輯註解

<!-- 上傳封面後,請把 COVER_IMAGE_URL 換成實際網址。 -->

如果要在網頁正文中直接顯示標籤文字,不能直接輸入 <h1>,否則瀏覽器會把它當成真正標籤。應將小於符號寫成 &lt;,大於符號寫成 &gt;。本文所有深色程式碼區塊都使用了這種字元實體。

十一、常用標籤快速整理

標籤主要用途原文章中的例子
<article>可獨立閱讀的完整內容包住整篇部落格文章
<header>文章或區段的開頭主標題與導言
<h1>~<h3>建立標題層級文章標題、章節與子題
<p>一般文字段落活動理念與教學說明
<ol>、<ul>、<li>有序或無序的項目集合四種力量與 SDGs 項目
<figure>具有獨立意義的圖片或圖表繪本封面
<img>嵌入圖片封面圖與活動海報
<figcaption>圖片或圖表的可見說明封面圖說
<a>建立超連結PDF 下載連結
<blockquote>較長的區塊引用程式思考教學觀點
<dl>、<dt>、<dd>名稱與說明的配對資料計畫、作者與單位資訊
<strong>、<b>重要內容或視覺粗體卡片名稱與提示文字

十二、新生實作:寫一篇自己的活動介紹

  1. <article> 包住整篇內容,並在 <header> 中加入一個 <h1> 和一段導言。
  2. 建立「活動資訊」與「學習內容」兩個區段,每個區段使用一個 <h2>
  3. <ul> 列出三項學習內容;若內容有先後步驟,改用 <ol>
  4. 加入一張圖片,撰寫能傳達圖片意義的 alt,並用 <figcaption> 加入圖說。
  5. 新增一個描述清單,放入日期、地點與主辦單位。
  6. 最後加入一個文字清楚的報名或下載連結,並使用鍵盤測試能否順利操作。

思考題

如果把文章裡所有標籤都換成 <div>,再用 CSS 做出相同外觀,肉眼可能看不出差異;但搜尋引擎、螢幕閱讀器與下一位維護者會失去哪些資訊?請從「結構、無障礙、維護」三個角度各寫一句說明。

十三、交作業前的 HTML 自我檢查

  • 文章只有一個清楚的主標題,標題層級沒有任意跳號。
  • 每一段完整文字都放在適當的段落或內容標籤中。
  • 有順序與無順序的內容分別使用 <ol><ul>
  • 圖片都有符合用途的 alt,純裝飾圖使用空的替代文字。
  • 連結文字能直接說明目的,不只寫「這裡」或「更多」。
  • 新分頁連結搭配適當的 rel 安全屬性。
  • 同一頁沒有重複的 id
  • 開始與結束標籤正確配對,巢狀順序沒有交錯。
  • 先使用有意義的語意標籤,再考慮以 <div> 分組。

學習 HTML 的核心不是背誦標籤表,而是看到內容時能問:「這一段在文件中扮演什麼角色?」當標籤選得正確,CSS 更容易套用,網頁也更容易被閱讀、搜尋與維護。接著再搭配 CSS 調整色彩、間距與版面,就能從清楚的內容骨架建立完整的網頁作品。

課堂延伸建議:將本文、CSS 入門文章與《勤勞小蜜蜂:爸爸教我的四種力量》原始部落格文章並排開啟,使用瀏覽器開發者工具選取畫面元素,觀察每一個標籤的名稱、巢狀關係與 class。

文章定位:大一新生 HTML 入門教材|智慧生活科技專業社群

從《勤勞小蜜蜂》部落格文章學 CSS:把內容變成清楚、好讀又能適應手機的網頁

智慧生活科技專業社群|大一新生網頁設計入門

從《勤勞小蜜蜂》部落格文章學 CSS:把內容變成清楚、好讀又能適應手機的網頁

不用先背完所有語法。從一篇真實文章拆解顏色、卡片、格線與響應式設計,理解 CSS 如何把 HTML 的內容組織成完整的視覺介面。

你可能已經知道 HTML 負責標題、段落、圖片與連結,卻不確定 CSS 到底做了什麼。簡單地說,HTML 像是文章的骨架,CSS 則決定骨架穿什麼衣服:字有多大、內容有多寬、卡片如何排列,以及手機畫面太窄時要怎麼調整。

本文以《勤勞小蜜蜂:爸爸教我的四種力量》部落格文章為例。那篇文章同時包含活動資訊、故事卡片、教學重點與 SDGs 說明,很適合觀察 CSS 如何處理一篇內容較長、元件較多的文章。

讀完本文,你將能夠:
  • 看懂選擇器、屬性和值的基本結構。
  • 使用 CSS 變數管理整篇文章的色彩。
  • 利用盒模型、Grid 與偽元素製作卡片和編號。
  • 用媒體查詢讓同一份內容適應電腦與手機。
  • 把樣式限制在文章內,降低與部落格主題互相干擾的機會。

一、先看懂 CSS 的一句話

一條 CSS 規則通常由「選擇器」和一組宣告組成。選擇器回答「要改誰」,屬性回答「要改什麼」,值則回答「要改成什麼」。

基本語法

.css-lead {
  color: #0f4c81;
  font-size: 19px;
  font-weight: 600;
}

.css-lead 是類別選擇器,會找出所有寫著 class="css-lead" 的 HTML 元素。大括號裡的三行宣告,分別設定文字顏色、大小和粗細。每一行最後的分號很重要,漏掉時可能讓後面的宣告無法正確解析。

初學技巧:先從「改一個值、重新整理、觀察結果」開始。一次只改一項,比同時改十項更容易建立屬性與畫面之間的連結。

二、先限制作用範圍,避免改壞整個網站

在部落格文章中直接寫 h1 { color: orange; },可能讓網站裡所有一級標題都變成橘色,包括導覽列或其他文章。範例文章先用一個外層類別包住全文,再把規則寫成 .slt-post h1,意思是「只選取這篇文章裡的 h1」。

將樣式限定在文章內

/* HTML:先替全文加一個專屬容器 */
<article class="slt-post">...</article>

/* CSS:只有容器裡的 h1 會套用 */
.slt-post h1 {
  color: #dc5a12;
}

這種寫法稱為「設定樣式作用域」。它不是特殊語法,而是善用後代選擇器建立邊界。多人共同維護網站時,清楚的作用域能減少樣式衝突。

三、用 CSS 變數建立一致的配色系統

原文章反覆使用深藍、橘色、綠色與米白色。若每個地方都直接輸入色碼,日後換色會非常辛苦。CSS 自訂屬性可以先替顏色命名,再透過 var() 取用。

變數的宣告與使用

.slt-post {
  --slt-navy: #0f4c81;
  --slt-orange: #dc5a12;
  --slt-cream: #fff8e9;
}

.slt-post h2 {
  color: var(--slt-navy);
  border-left: 7px solid var(--slt-orange);
}

變數名稱可以表達用途,比單純記住 #0f4c81 更容易閱讀。若要把文章主色改成紫色,只需修改變數宣告,不必逐行搜尋整份 CSS。

命名顏色--slt-orange 表示品牌橘色,適合已確定的視覺識別。
命名用途--color-accent 表示強調色,日後更換品牌時較有彈性。

四、理解盒模型:空間感來自四個層次

瀏覽器會把多數元素視為一個矩形盒子。由內而外可以想成:內容(content)、內距(padding)、邊框(border)與外距(margin)。範例文章的標題區之所以像一張完整卡片,就是這幾個屬性共同作用的結果。

卡片樣式

.slt-hero {
  background: linear-gradient(135deg, #fff7df, #eaf7ff);
  border: 2px solid #f2cf7b;
  border-radius: 22px;
  margin-bottom: 28px;
  padding: 34px 38px;
}
這是一張具有背景、邊框、圓角與內距的示範卡片。

padding: 34px 38px 代表上下內距為 34px、左右內距為 38px。適當的內距能讓文字不貼著邊框;外距則用來拉開卡片和下一段內容。

為什麼要使用 box-sizing?

比較容易預測的尺寸計算

.slt-post * {
  box-sizing: border-box;
}

預設情況下,元素的寬度不包含 padding 和 border,容易出現「設定 100% 卻超出容器」的問題。改用 border-box 後,指定寬度會把內距與邊框一起算進去,版面較容易控制。

五、用 Grid 排出會自動調整的資訊卡

活動日期、時間、地點與對象在電腦上適合排成兩欄。CSS Grid 能把容器切成規則欄位,gap 則負責卡片之間的間隔。

兩欄卡片格線

.slt-info-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 14px;
}

repeat(2, ...) 代表建立兩欄;1fr 表示平均分配剩餘空間;minmax(0, 1fr) 可降低長文字把欄位撐破的機會。這是實務上很常用的安全寫法。

六、用偽元素建立自動編號

四種力量卡片左側的圓形數字不必逐一寫進 HTML。CSS 計數器與 ::before 偽元素可以在每個清單項目前自動產生編號。

CSS 自動編號

.power-list {
  counter-reset: power;
  list-style: none;
}

.power-list li::before {
  content: counter(power);
  counter-increment: power;
  border-radius: 50%;
}

::before 會建立一個位於元素內容之前的裝飾性區塊。這種做法適合視覺編號;但若數字本身具有重要語意,仍應優先使用 HTML 的 <ol>,讓螢幕閱讀器與搜尋引擎理解它是有順序的清單。

七、用媒體查詢照顧手機閱讀

同一篇文章不應只在電腦上好看。當螢幕寬度小於 680px,原文章會縮小標題、減少卡片內距,並把兩欄改成一欄。

手機版規則

@media (max-width: 680px) {
  .slt-post h1 {
    font-size: 28px;
  }

  .slt-info-grid,
  .slt-power-list {
    grid-template-columns: 1fr;
  }
}

媒體查詢可以理解成「條件成立時才使用的 CSS」。設計響應式版面時,先問內容在窄螢幕上是否仍然清楚,再決定斷點,而不是只記住某一個固定數字。

八、從範例整理出的實用屬性

屬性在文章中的用途初學者常見問題
max-width限制文章最大寬度,避免一行文字太長。只設 width,導致小螢幕產生水平捲軸。
line-height增加中文字行距,提升長文閱讀性。設得太小,段落看起來擁擠。
border-radius製作卡片、按鈕與圖片的圓角。以為它會增加內距;圓角與空間是不同概念。
box-shadow讓圖片與按鈕產生輕微層次。陰影過黑或範圍太大,使畫面顯得混濁。
overflow-x: auto讓過寬的程式碼或表格可以橫向捲動。直接隱藏內容,造成手機使用者看不到完整資訊。
:hover:focus提示滑鼠或鍵盤使用者目前選到的連結。只設 hover,忽略鍵盤操作的 focus 狀態。

九、新生練習:從改色開始,逐步完成自己的卡片

  1. 建立 HTML:新增一個 <div class="my-card">,裡面放標題與一小段自我介紹。
  2. 加入空間:設定 paddingmarginmax-width,觀察三者差異。
  3. 建立視覺:加入背景色、邊框與圓角,完成第一張資訊卡。
  4. 加入變數:把主色存成 --my-color,並在標題與邊框重複使用。
  5. 測試手機:縮窄瀏覽器視窗,確認文字沒有被截斷,也沒有不必要的水平捲軸。

挑戰題

把原本兩欄的資訊卡改成:寬螢幕三欄、平板兩欄、手機一欄。你需要兩個媒體查詢。完成後再想一想:斷點應該根據特定手機型號,還是根據內容開始擁擠的位置來決定?

十、交作業前的 CSS 自我檢查

  • 類別名稱能表達用途,不只叫做 .red.big
  • 文章樣式有外層作用域,不會任意改動整個網站。
  • 重複使用的顏色已整理成 CSS 變數。
  • 文字大小、行距與顏色對比足以閱讀。
  • 圖片使用 max-width: 100%,不會超出容器。
  • 電腦與手機版都實際測試過。
  • 連結除了顏色之外,還有 hover 或 focus 等辨識方式。
  • 刪除某段裝飾後,HTML 內容仍保有正確語意與順序。

CSS 的學習重點不是記住所有屬性,而是建立「選到正確元素—修改一項規則—觀察版面變化—在不同螢幕測試」的工作流程。當你能說明每一條規則為什麼存在,就已經從套用樣式向真正的介面設計跨出重要一步。

課堂延伸建議:可將本文與《勤勞小蜜蜂:爸爸教我的四種力量》部落格文章並排開啟,一邊尋找實際元件,一邊使用瀏覽器開發者工具修改數值。練習時請保留原始檔,以便隨時比較與還原。

文章定位:大一新生 CSS 入門教材|智慧生活科技專業社群

《勤勞小蜜蜂:爸爸教我的四種力量》用故事與程式設計陪孩子看見父愛

父親節親子共學 × Tale-Bot Pro × SDGs

從企鵝、海馬、老鷹與草原獅子爸爸的故事出發,讓孩子在操作 Tale-Bot Pro 的過程中,學會守護、照顧、勇敢與負責。


《勤勞小蜜蜂:爸爸教我的四種力量》父親節 Tale-Bot Pro 課堂繪本

父愛不只有一種模樣。有的爸爸在風雪中安靜守護,有的爸爸用身體孕育新生命,有的爸爸陪孩子一次次練習飛翔,也有爸爸願意承認錯誤、照顧環境,並為自己的選擇負責。

雲林官邸兒童館以父親節為主題,規劃《勤勞小蜜蜂:爸爸教我的四種力量》親子共學教材,把原創繪本、動物生態、Tale-Bot Pro 程式啟蒙與永續教育放進同一段學習旅程。孩子不只是聽故事,也要先觀察、說出計畫、安排指令、預測結果,並在走錯路時找到可以修改的步驟。

孩子走錯路時,正是在學習把問題變成「可以再試一次」的步驟。比起直接得到答案,能夠觀察、修正與重新出發,更是程式思考的重要能力。

活動資訊

日期2026 年 8 月 8 日(星期六)
時間13:30-15:30
地點雲林官邸兒童館
參加對象國小學生與家長,限收 8 組親子


四位爸爸,帶來四種父愛力量

  1. 企鵝爸爸|守護
    在冰雪與寒風中穩穩護住蛋,讓孩子理解陪伴、耐心與承諾。
  2. 海馬爸爸|愛與生命
    用身體孕育小海馬,帶孩子認識照顧可以由不同家庭成員共同承擔。
  3. 老鷹爸爸|勇敢成長
    陪小鷹重複練習、接受失敗,再一次展開翅膀。
  4. 勇鬃爸爸|責任與勇氣
    原創草原獅子爸爸以行動示範:承認錯誤、照顧環境、為自己的選擇負責,也是一種勇敢。

Tale-Bot Pro:讓故事變成可以行動的程式

課堂使用 Tale-Bot Pro 作為操作教具,孩子依照繪本情境,在原創地圖上安排前進、轉彎與重複指令,陪小蜜蜂完成採蜜任務,也把四位爸爸的力量卡送到正確位置。

孩子將在活動中練習

  • 依序思考:先說出路線,再輸入指令。
  • 預測結果:想一想機器人會停在哪一格。
  • 除錯修正:走錯時找出需要替換的那一步。
  • 重複模式:把相同動作整理成更短、更清楚的程序。
  • 合作表達:親子共同討論,輪流說明自己的方法。

從父愛故事連結 SDGs 永續教育

本教材沒有直接使用聯合國官方 SDGs 圖示,而是以文字與生活行動融入永續教育:

  • SDG 4 優質教育:透過故事、遊戲與程式操作,建立主動探索及解決問題的能力。
  • SDG 5 性別平等:從海馬爸爸與家庭分工理解,照顧責任不受性別限制。
  • SDG 13 氣候行動:認識極地環境、生物棲地與氣候變遷的關聯。
  • SDG 14 保育海洋生態:關心海馬與海洋生物的生活環境,減少垃圾進入海洋。
  • SDG 15 保育陸域生態:從老鷹與草原動物的故事學習尊重棲地、友善自然。

帶回家的親子延伸

共讀結束後,家長可以和孩子一起玩「口令機器人」:由一人擔任機器人,另一人用前進、後退、左轉與右轉下指令。出發前先請孩子說出完整計畫;如果走錯,不急著公布答案,而是問:「你想先換掉哪一步?」

也可以把學習延伸到家庭生活:今天想和爸爸共同完成哪一件事?整理玩具、節約用水、關掉不用的電燈、照顧植物或完成垃圾分類,都能成為孩子實踐責任與永續行動的起點。


AI 使用聲明:本書之故事主題、地方文化資料、文字內容、角色設定、畫面構圖及編輯決策,均由創作團隊規劃與審訂;生成式人工智慧僅作為文字整理、創意發想及圖像製作之輔助工具。

商標說明:Tale-Bot Pro 名稱僅用於識別課堂實際使用之教具,不表示原廠對本教材之審訂、授權或背書。

2026年8月2日 星期日

[水井村USR]ESP32 Wi-Fi 設定流程實作:連線失敗自動進入 AP 模式,手機設定後寫入 NVS 並重新連線


Atlas Aquaponics V1.8 × Adafruit HUZZAH32 ESP32 Feather × Wi-Fi Config Portal × Preferences(NVS)× Django REST API
在智慧養殖 IoT 設備中,若 Wi-Fi 帳密直接寫死在程式裡,每次更換路由器或部署到不同養殖場,都必須重新修改程式並燒錄。本文記錄如何讓 ESP32 在開機時先讀取已儲存的 Wi-Fi 設定;若連線失敗,便自動建立 AP 熱點與手機設定頁,讓使用者輸入 SSID 與密碼,寫入 ESP32 內建 Flash 的 NVS,再自動重新啟動並使用新帳密連線。
ESP32 開機 從 NVS 讀取 SSID/密碼 嘗試連線 失敗啟動 AP 手機輸入設定 寫入 Flash ESP32 重啟 使用新帳密連線

圖:ESP32 連線失敗後進入 AP 模式、手機設定、NVS 儲存與重新連線流程。

一、為什麼不能只把 SSID 與密碼寫在程式裡?

最初程式採用固定設定:

固定帳密寫法
const char* WIFI_SSID = "Home_WiFi";
const char* WIFI_PASSWORD = "12345678";

這種方法雖然簡單,但會帶來三個限制:

  1. 設備移到新場域時,必須重新燒錄程式。
  2. Wi-Fi 密碼變更後,現場人員無法自行設定。
  3. 同一份程式很難快速部署到不同魚塭或養殖戶。

因此,較適合現場部署的方式是:

開機時先讀取已儲存的 SSID 與密碼;連不上時自動建立設定熱點,讓手機完成設定。

二、Wi-Fi 設定儲存在哪裡?

ESP32 的 SSID 與密碼不是存入傳統 EEPROM,而是使用 Preferences 寫入內建 Flash 的 NVS(Non-Volatile Storage)

項目 說明
儲存位置 ESP32 內建 Flash 的 NVS 分區
斷電後是否保留 會保留
重新啟動後是否保留 會保留
適合資料 SSID、密碼、場域編號、裝置設定等少量 Key/Value 資料

三、需要使用的函式庫

程式碼 1:AP、Web Server、DNS 與 NVS
#include <Wire.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <NetworkClientSecure.h>
#include <Preferences.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <time.h>

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

// Wi-Fi 帳密由手機設定頁寫入 Preferences,不再固定寫死於程式。
String wifiSsid;
String wifiPassword;

// 首次安裝時若尚未儲存帳密,也可在此提供預設備援值;
// 不需要預設值時請維持空字串。
const char* DEFAULT_WIFI_SSID = "";
const char* DEFAULT_WIFI_PASSWORD = "";

// AP 設定熱點
const char* CONFIG_AP_PREFIX = "Shuijing-Setup";
const char* CONFIG_AP_PASSWORD = "shuijing123";

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;

Preferences preferences;
WebServer configServer(80);
DNSServer dnsServer;

bool configPortalActive = false;
bool restartScheduled = false;
unsigned long restartAt = 0;

// 儲存成功後,先導向 /done,再由完成頁安排重新啟動。
String pendingSavedSsid;

constexpr uint16_t DNS_PORT = 53;
constexpr unsigned long WIFI_CONNECT_TIMEOUT_MS = 20000;

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);
}

String htmlEscape(const String& value) {
  String result;
  result.reserve(value.length() + 8);

  for (size_t i = 0; i < value.length(); i++) {
    char c = value.charAt(i);

    switch (c) {
      case '&': result += "&amp;"; break;
      case '<': result += "&lt;"; break;
      case '>': result += "&gt;"; break;
      case '"': result += "&quot;"; break;
      case '\'': result += "&#39;"; break;
      default: result += c; break;
    }
  }

  return result;
}

String buildApName() {
  uint64_t chipId = ESP.getEfuseMac();
  char suffix[7];

  snprintf(
    suffix,
    sizeof(suffix),
    "%06llX",
    static_cast<unsigned long long>(chipId & 0xFFFFFFULL)
  );

  return String(CONFIG_AP_PREFIX) + "-" + suffix;
}

void loadWiFiCredentials() {
  bool opened = preferences.begin(
    "wifi-config",
    true
  );

  if (!opened) {
    Serial.println("[NVS] 無法開啟 wifi-config namespace");
    wifiSsid = "";
    wifiPassword = "";
    return;
  }

  bool hasSsid = preferences.isKey("ssid");
  bool hasPassword = preferences.isKey("password");

  wifiSsid = preferences.getString(
    "ssid",
    DEFAULT_WIFI_SSID
  );

  wifiPassword = preferences.getString(
    "password",
    DEFAULT_WIFI_PASSWORD
  );

  size_t ssidLength =
    preferences.getStringLength("ssid");

  size_t passwordLength =
    preferences.getStringLength("password");

  preferences.end();

  Serial.println();
  Serial.println("========== NVS Wi-Fi 設定 ==========");
  Serial.print("[NVS] SSID key:");
  Serial.println(hasSsid ? "存在" : "不存在");

  Serial.print("[NVS] Password key:");
  Serial.println(hasPassword ? "存在" : "不存在");

  Serial.print("[NVS] SSID 儲存長度:");
  Serial.println(ssidLength);

  Serial.print("[NVS] 密碼儲存長度:");
  Serial.println(passwordLength);

  Serial.print("[WiFi] 已儲存 SSID:");
  Serial.println(
    wifiSsid.length() > 0
      ? wifiSsid
      : "尚未設定"
  );

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

bool saveWiFiCredentials(
  const String& ssid,
  const String& password
) {
  if (ssid.length() == 0 || ssid.length() > 32) {
    Serial.println("[NVS] SSID 長度不合法");
    return false;
  }

  if (password.length() > 63) {
    Serial.println("[NVS] 密碼長度不合法");
    return false;
  }

  bool opened = preferences.begin(
    "wifi-config",
    false
  );

  if (!opened) {
    Serial.println("[NVS] 無法以讀寫模式開啟 namespace");
    return false;
  }

  size_t ssidResult =
    preferences.putString("ssid", ssid);

  size_t passwordResult =
    preferences.putString("password", password);

  // 寫入後立即從同一 namespace 回讀確認
  String verifySsid =
    preferences.getString("ssid", "");

  String verifyPassword =
    preferences.getString("password", "");

  preferences.end();

  bool ssidOK =
    ssidResult > 0 &&
    verifySsid == ssid;

  bool passwordOK =
    password.length() == 0
      ? verifyPassword.length() == 0
      : (
          passwordResult > 0 &&
          verifyPassword == password
        );

  Serial.println();
  Serial.println("========== NVS 寫入結果 ==========");
  Serial.print("[NVS] SSID 寫入 bytes:");
  Serial.println(ssidResult);

  Serial.print("[NVS] 密碼寫入 bytes:");
  Serial.println(passwordResult);

  Serial.print("[NVS] SSID 回讀:");
  Serial.println(
    ssidOK ? "成功" : "失敗"
  );

  Serial.print("[NVS] 密碼回讀:");
  Serial.println(
    passwordOK ? "成功" : "失敗"
  );

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

  return ssidOK && passwordOK;
}

void clearWiFiCredentials() {
  preferences.begin("wifi-config", false);
  preferences.clear();
  preferences.end();

  wifiSsid = "";
  wifiPassword = "";
}

String buildConfigPage(
  const String& message = "",
  bool success = false
) {
  int networkCount = WiFi.scanNetworks();

  String page;
  page.reserve(7000);

  page += F(
    "<!DOCTYPE html><html lang='zh-Hant'><head>"
    "<meta charset='utf-8'>"
    "<meta name='viewport' content='width=device-width,initial-scale=1'>"
    "<title>水井 USR Wi-Fi 設定</title>"
    "<style>"
    "body{margin:0;background:#eef5f3;color:#263238;"
    "font-family:Arial,'Microsoft JhengHei',sans-serif}"
    ".box{max-width:560px;margin:24px auto;padding:24px;"
    "background:#fff;border-radius:16px;"
    "box-shadow:0 8px 28px #0002}"
    "h1{margin-top:0;color:#176b5b;font-size:1.6rem}"
    "label{display:block;margin-top:16px;font-weight:700}"
    "input,select{width:100%;padding:12px;margin-top:6px;"
    "border:1px solid #bccbc7;border-radius:8px;"
    "font-size:16px;box-sizing:border-box}"
    "button{width:100%;margin-top:20px;padding:13px;"
    "border:0;border-radius:8px;background:#176b5b;"
    "color:#fff;font-size:16px;font-weight:700}"
    ".msg{padding:12px;border-radius:8px;margin-bottom:16px;"
  );

  page += success
    ? "background:#e8f7ef;color:#176b5b}"
    : "background:#fff3e4;color:#9a5a00}";

  page += F(
    ".small{color:#60706c;font-size:.9rem;line-height:1.65}"
    ".danger{background:#a33;margin-top:10px}"
    "</style></head><body><main class='box'>"
    "<h1>水井 USR 智慧養殖 Wi-Fi 設定</h1>"
  );

  if (message.length() > 0) {
    page += "<div class='msg'>";
    page += htmlEscape(message);
    page += "</div>";
  }

  page += F(
    "<p class='small'>請選擇現場的 2.4 GHz Wi-Fi,"
    "輸入密碼後儲存。ESP32 會重新啟動並嘗試連線。</p>"
    "<form method='GET' action='http://192.168.4.1/save'>"
    "<label for='ssid'>Wi-Fi SSID</label>"
    "<select id='ssidSelect' onchange="
    "\"document.getElementById('ssid').value=this.value\">"
    "<option value=''>請選擇掃描到的網路</option>"
  );

  if (networkCount > 0) {
    for (int i = 0; i < networkCount; i++) {
      String foundSsid = WiFi.SSID(i);

      page += "<option value='";
      page += htmlEscape(foundSsid);
      page += "'>";
      page += htmlEscape(foundSsid);
      page += "(";
      page += String(WiFi.RSSI(i));
      page += " dBm)</option>";
    }
  }

  page += F(
    "</select>"
    "<input id='ssid' name='ssid' maxlength='32' "
    "placeholder='也可手動輸入 SSID' required>"
    "<label for='password'>Wi-Fi 密碼</label>"
    "<input id='password' name='password' type='password' "
    "maxlength='63' placeholder='開放式 Wi-Fi 可留空'>"
    "<button type='submit'>儲存並重新連線</button>"
    "<p class='small'>按下後會直接送到 "
    "<b>http://192.168.4.1/save</b>。"
    "<br>若內建設定視窗無反應,請改用 Chrome/Safari "
    "開啟 192.168.4.1。</p>"
    "</form>"
    "<form method='GET' action='http://192.168.4.1/clear'>"
    "<button class='danger' type='submit'>清除已儲存設定</button>"
    "</form>"
    "<p class='small'>設定頁位址:192.168.4.1<br>"
    "AP 密碼:shuijing123</p>"
    "</main></body></html>"
  );

  WiFi.scanDelete();
  return page;
}


String buildSaveSuccessPage(const String& ssid) {
  String page;
  page.reserve(3500);

  page += F(
    "<!DOCTYPE html><html lang='zh-Hant'><head>"
    "<meta charset='utf-8'>"
    "<meta name='viewport' content='width=device-width,initial-scale=1'>"
    "<title>Wi-Fi 設定完成</title>"
    "<style>"
    "body{margin:0;background:#eef5f3;color:#263238;"
    "font-family:Arial,'Microsoft JhengHei',sans-serif}"
    ".box{max-width:520px;margin:28px auto;padding:28px;"
    "background:#fff;border-radius:18px;text-align:center;"
    "box-shadow:0 8px 28px #0002}"
    ".ok{width:72px;height:72px;margin:0 auto 18px;"
    "border-radius:50%;background:#e5f6ed;color:#1d7a50;"
    "display:flex;align-items:center;justify-content:center;"
    "font-size:42px;font-weight:700}"
    "h1{margin:0 0 14px;color:#176b5b;font-size:1.65rem}"
    ".ssid{display:inline-block;padding:6px 12px;"
    "border-radius:8px;background:#edf5f2;font-weight:700}"
    ".count{font-size:2rem;color:#176b5b;font-weight:700}"
    ".small{color:#60706c;line-height:1.7}"
    "</style>"
    "<script>"
    "let n=10;"
    "function tick(){"
    "document.getElementById('count').textContent=n;"
    "if(n>0){n--;setTimeout(tick,1000);}"
    "else{document.getElementById('status').textContent="
    "'ESP32 已重新啟動,請將手機連回原本的 Wi-Fi。';}"
    "}"
    "window.addEventListener('load',tick);"
    "</script>"
    "</head><body><main class='box'>"
    "<div class='ok'>✓</div>"
    "<h1>Wi-Fi 設定已完成</h1>"
    "<p>已儲存 Wi-Fi:</p><p class='ssid'>"
  );

  page += htmlEscape(ssid);

  page += F(
    "</p>"
    "<p id='status'>ESP32 將在 "
    "<span id='count' class='count'>10</span> 秒後自動重新啟動,"
    "並使用新帳密連線。</p>"
    "<p class='small'>重新啟動後,手機可能會自動離開設定熱點。"
    "請稍候約 10~30 秒,再確認智慧養殖設備是否已連線。"
    "<br>此頁不需要再按任何按鈕。"
    "<br><br>若手機沒有自動跳轉,可手動開啟:"
    "<a href='/done'>http://192.168.4.1/done</a></p>"
    "</main></body></html>"
  );

  return page;
}


void handleConfigTest() {
  Serial.println("[AP] 收到 /test 測試請求");

  configServer.send(
    200,
    "text/plain; charset=utf-8",
    "ESP32 WebServer 正常"
  );
}

void handleConfigRoot() {
  configServer.send(
    200,
    "text/html; charset=utf-8",
    buildConfigPage()
  );
}

void handleConfigSave() {
  Serial.println();
  Serial.println("[AP] 收到 /save 設定請求");
  Serial.print("[AP] HTTP method:");
  Serial.println(
    configServer.method() == HTTP_GET ? "GET" : "OTHER"
  );
  Serial.print("[AP] 參數數量:");
  Serial.println(configServer.args());

  String newSsid = configServer.arg("ssid");
  String newPassword = configServer.arg("password");

  newSsid.trim();

  Serial.print("[AP] 收到 SSID:");
  Serial.println(newSsid);
  Serial.print("[AP] 密碼長度:");
  Serial.println(newPassword.length());

  if (!saveWiFiCredentials(newSsid, newPassword)) {
    configServer.send(
      400,
      "text/html; charset=utf-8",
      buildConfigPage(
        "儲存失敗,請確認 SSID 與密碼長度。",
        false
      )
    );
    return;
  }

  wifiSsid = newSsid;
  wifiPassword = newPassword;
  pendingSavedSsid = newSsid;

  Serial.print("[AP] Wi-Fi 設定已儲存:");
  Serial.println(newSsid);
  
  String responsePage =
    "<!DOCTYPE html><html lang='zh-Hant'><head>"
    "<meta charset='utf-8'>"
    "<meta name='viewport' content='width=device-width,initial-scale=1'>"
    "<title>設定完成</title></head>"
    "<body style='font-family:Arial,sans-serif;text-align:center;"
    "padding:40px;background:#eef5f3'>"
    "<h1 style='color:#176b5b'>Wi-Fi 設定已完成</h1>"
    "<p>ESP32 將在 5 秒後重新啟動。</p>"
    "<p>請稍後將手機連回原本的 Wi-Fi。</p>"
    "</body></html>";

  configServer.send(
    200,
    "text/html; charset=utf-8",
    responsePage
  );

  restartScheduled = true;
  restartAt = millis() + 5000;

  Serial.println("[AP] 設定已寫入,5 秒後重新啟動");
}

void handleConfigDone() {
  String displaySsid =
    pendingSavedSsid.length() > 0
      ? pendingSavedSsid
      : wifiSsid;

  configServer.sendHeader(
    "Cache-Control",
    "no-store, no-cache, must-revalidate"
  );

  configServer.send(
    200,
    "text/html; charset=utf-8",
    buildSaveSuccessPage(displaySsid)
  );
}

void handleConfigClear() {
  clearWiFiCredentials();

  configServer.send(
    200,
    "text/html; charset=utf-8",
    buildConfigPage(
      "已清除 Wi-Fi 設定,ESP32 將重新啟動。",
      true
    )
  );

  restartScheduled = true;
  restartAt = millis() + 6000;
}

void handleCaptivePortalRedirect() {
  configServer.sendHeader(
    "Location",
    String("http://") +
      WiFi.softAPIP().toString() +
      "/",
    true
  );

  configServer.send(
    302,
    "text/plain",
    ""
  );
}

void startConfigPortal() {
  if (configPortalActive) {
    return;
  }

  String apName = buildApName();

  WiFi.disconnect(true, false);
  delay(200);
  WiFi.mode(WIFI_AP_STA);

  bool apStarted = WiFi.softAP(
    apName.c_str(),
    CONFIG_AP_PASSWORD
  );

  if (!apStarted) {
    Serial.println("[AP] 啟動失敗");
    return;
  }

  delay(300);

  IPAddress apIp = WiFi.softAPIP();

  dnsServer.start(
    DNS_PORT,
    "*",
    apIp
  );

  configServer.on(
    "/",
    HTTP_GET,
    handleConfigRoot
  );

  configServer.on(
    "/test",
    HTTP_ANY,
    handleConfigTest
  );

  configServer.on(
    "/save",
    HTTP_ANY,
    handleConfigSave
  );

  configServer.on(
    "/done",
    HTTP_GET,
    handleConfigDone
  );

  configServer.on(
    "/clear",
    HTTP_ANY,
    handleConfigClear
  );

  // 常見 captive portal 偵測路徑
  configServer.on(
    "/generate_204",
    HTTP_ANY,
    handleCaptivePortalRedirect
  );

  configServer.on(
    "/hotspot-detect.html",
    HTTP_ANY,
    handleCaptivePortalRedirect
  );

  configServer.on(
    "/connecttest.txt",
    HTTP_ANY,
    handleCaptivePortalRedirect
  );

  configServer.onNotFound(
    handleCaptivePortalRedirect
  );

  configServer.begin();
  configPortalActive = true;

  Serial.println();
  Serial.println("========== Wi-Fi AP 設定模式 ==========");
  Serial.print("[AP] 熱點名稱:");
  Serial.println(apName);
  Serial.print("[AP] 熱點密碼:");
  Serial.println(CONFIG_AP_PASSWORD);
  Serial.print("[AP] 設定網址:http://");
  Serial.println(apIp);
  Serial.println("請用手機連上熱點後開啟設定頁。");
  Serial.println("======================================");
}

void stopConfigPortal() {
  if (!configPortalActive) {
    return;
  }

  configServer.stop();
  dnsServer.stop();
  WiFi.softAPdisconnect(true);

  configPortalActive = false;
}

bool connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) {
    return true;
  }

  if (wifiSsid.length() == 0) {
    Serial.println("[WiFi] 尚未儲存 SSID");
    return false;
  }

  stopConfigPortal();

  Serial.println();
  Serial.print("[WiFi] 嘗試連線:");
  Serial.println(wifiSsid);

  WiFi.mode(WIFI_STA);
  WiFi.setAutoReconnect(true);
  WiFi.persistent(false);

  WiFi.begin(
    wifiSsid.c_str(),
    wifiPassword.c_str()
  );

  unsigned long startedAt = millis();

  while (
    WiFi.status() != WL_CONNECTED &&
    millis() - startedAt < WIFI_CONNECT_TIMEOUT_MS
  ) {
    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");
    return true;
  }

  Serial.println("[WiFi] 連線失敗");
  WiFi.disconnect(true, false);
  return false;
}

void maintainWiFi() {
  if (configPortalActive) {
    dnsServer.processNextRequest();
    configServer.handleClient();

    return;
  }

  if (WiFi.status() == WL_CONNECTED) {
    return;
  }

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

    if (!connectWiFi()) {
      startConfigPortal();
    }
  }
}

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);

  // 重新啟動後先從 NVS Flash 讀回 SSID 與密碼
  loadWiFiCredentials();

  if (connectWiFi()) {
    initializeTime();
  } else {
    startConfigPortal();
  }

  readSensors();

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

void loop() {
  if (
    restartScheduled &&
    static_cast<long>(millis() - restartAt) >= 0
  ) {
    Serial.println("[SYSTEM] Wi-Fi 設定完成,ESP32 重新啟動");

    if (configPortalActive) {
      configServer.stop();
      dnsServer.stop();
      WiFi.softAPdisconnect(true);
    }

    delay(500);
    ESP.restart();
  }

  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);
}

其中:

  • Preferences.h:將 Wi-Fi 設定寫入 NVS。
  • WebServer.h:建立手機可開啟的設定頁。
  • DNSServer.h:將手機的網址請求導向 ESP32 設定頁。
  • WiFi.h:控制 Station 與 Soft-AP 模式。

四、開機後的完整流程

20 秒嘗試連線時間
192.168.4.1AP 設定頁
NVS帳密永久儲存
自動重啟套用新設定
  1. ESP32 開機,先從 NVS 讀取 SSID 與密碼。
  2. 若有帳密,使用 Station 模式嘗試連線。
  3. 20 秒內連線成功,開始 NTP 校時、感測器讀值與 Django API 上傳。
  4. 若連線失敗,切換成 AP+Station 模式。
  5. 建立熱點 Shuijing-Setup-XXXXXX
  6. 手機連線後開啟 http://192.168.4.1
  7. 輸入 SSID 與密碼,寫入 NVS。
  8. ESP32 自動重新啟動,重新讀取 NVS 並連線。

五、從 NVS 讀取與寫入 Wi-Fi 設定

程式碼 2:讀取與寫入 NVS
void loadWiFiCredentials() {
  bool opened = preferences.begin(
    "wifi-config",
    true
  );

  if (!opened) {
    Serial.println("[NVS] 無法開啟 wifi-config namespace");
    wifiSsid = "";
    wifiPassword = "";
    return;
  }

  bool hasSsid = preferences.isKey("ssid");
  bool hasPassword = preferences.isKey("password");

  wifiSsid = preferences.getString(
    "ssid",
    DEFAULT_WIFI_SSID
  );

  wifiPassword = preferences.getString(
    "password",
    DEFAULT_WIFI_PASSWORD
  );

  size_t ssidLength =
    preferences.getStringLength("ssid");

  size_t passwordLength =
    preferences.getStringLength("password");

  preferences.end();

  Serial.println();
  Serial.println("========== NVS Wi-Fi 設定 ==========");
  Serial.print("[NVS] SSID key:");
  Serial.println(hasSsid ? "存在" : "不存在");

  Serial.print("[NVS] Password key:");
  Serial.println(hasPassword ? "存在" : "不存在");

  Serial.print("[NVS] SSID 儲存長度:");
  Serial.println(ssidLength);

  Serial.print("[NVS] 密碼儲存長度:");
  Serial.println(passwordLength);

  Serial.print("[WiFi] 已儲存 SSID:");
  Serial.println(
    wifiSsid.length() > 0
      ? wifiSsid
      : "尚未設定"
  );

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

bool saveWiFiCredentials(
  const String& ssid,
  const String& password
) {
  if (ssid.length() == 0 || ssid.length() > 32) {
    Serial.println("[NVS] SSID 長度不合法");
    return false;
  }

  if (password.length() > 63) {
    Serial.println("[NVS] 密碼長度不合法");
    return false;
  }

  bool opened = preferences.begin(
    "wifi-config",
    false
  );

  if (!opened) {
    Serial.println("[NVS] 無法以讀寫模式開啟 namespace");
    return false;
  }

  size_t ssidResult =
    preferences.putString("ssid", ssid);

  size_t passwordResult =
    preferences.putString("password", password);

  // 寫入後立即從同一 namespace 回讀確認
  String verifySsid =
    preferences.getString("ssid", "");

  String verifyPassword =
    preferences.getString("password", "");

  preferences.end();

  bool ssidOK =
    ssidResult > 0 &&
    verifySsid == ssid;

  bool passwordOK =
    password.length() == 0
      ? verifyPassword.length() == 0
      : (
          passwordResult > 0 &&
          verifyPassword == password
        );

  Serial.println();
  Serial.println("========== NVS 寫入結果 ==========");
  Serial.print("[NVS] SSID 寫入 bytes:");
  Serial.println(ssidResult);

  Serial.print("[NVS] 密碼寫入 bytes:");
  Serial.println(passwordResult);

  Serial.print("[NVS] SSID 回讀:");
  Serial.println(
    ssidOK ? "成功" : "失敗"
  );

  Serial.print("[NVS] 密碼回讀:");
  Serial.println(
    passwordOK ? "成功" : "失敗"
  );

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

  return ssidOK && passwordOK;
}

這一版加入「寫入後立即回讀驗證」。若 SSID 或密碼沒有真正寫入 NVS, 序列監控會顯示失敗,避免重新啟動後才發現資料不存在。

重新啟動後一定要先呼叫 loadWiFiCredentials(), 否則 Flash 中雖然有資料,RAM 裡的 wifiSsid 仍會是空字串。

六、建立 AP 設定熱點與手機頁面

程式碼 3:啟動 AP Config Portal
void startConfigPortal() {
  if (configPortalActive) {
    return;
  }

  String apName = buildApName();

  WiFi.disconnect(true, false);
  delay(200);
  WiFi.mode(WIFI_AP_STA);

  bool apStarted = WiFi.softAP(
    apName.c_str(),
    CONFIG_AP_PASSWORD
  );

  if (!apStarted) {
    Serial.println("[AP] 啟動失敗");
    return;
  }

  delay(300);

  IPAddress apIp = WiFi.softAPIP();

  dnsServer.start(
    DNS_PORT,
    "*",
    apIp
  );

  configServer.on(
    "/",
    HTTP_GET,
    handleConfigRoot
  );

  configServer.on(
    "/test",
    HTTP_ANY,
    handleConfigTest
  );

  configServer.on(
    "/save",
    HTTP_ANY,
    handleConfigSave
  );

  configServer.on(
    "/done",
    HTTP_GET,
    handleConfigDone
  );

  configServer.on(
    "/clear",
    HTTP_ANY,
    handleConfigClear
  );

  // 常見 captive portal 偵測路徑
  configServer.on(
    "/generate_204",
    HTTP_ANY,
    handleCaptivePortalRedirect
  );

  configServer.on(
    "/hotspot-detect.html",
    HTTP_ANY,
    handleCaptivePortalRedirect
  );

  configServer.on(
    "/connecttest.txt",
    HTTP_ANY,
    handleCaptivePortalRedirect
  );

  configServer.onNotFound(
    handleCaptivePortalRedirect
  );

  configServer.begin();
  configPortalActive = true;

  Serial.println();
  Serial.println("========== Wi-Fi AP 設定模式 ==========");
  Serial.print("[AP] 熱點名稱:");
  Serial.println(apName);
  Serial.print("[AP] 熱點密碼:");
  Serial.println(CONFIG_AP_PASSWORD);
  Serial.print("[AP] 設定網址:http://");
  Serial.println(apIp);
  Serial.println("請用手機連上熱點後開啟設定頁。");
  Serial.println("======================================");
}

AP 模式的重要設定包括:

  • 熱點名稱:Shuijing-Setup-裝置碼
  • 預設 AP 密碼:shuijing123
  • 設定頁:http://192.168.4.1
  • 手機若沒有自動跳出頁面,可手動用 Chrome 或 Safari 開啟。

七、手機設定步驟

  1. ESP32 無法連上原有 Wi-Fi,會自動啟動 AP 模式。
  2. 手機 Wi-Fi 選擇 Shuijing-Setup-XXXXXX
  3. 輸入 AP 密碼 shuijing123
  4. 開啟瀏覽器並輸入 http://192.168.4.1
  5. 選擇現場 Wi-Fi SSID,輸入密碼。
  6. 按下「儲存並重新連線」。
  7. 手機顯示設定完成,ESP32 自動重新啟動。
  8. 重新啟動後從 NVS 讀回帳密並連線。

八、Wi-Fi 連線函式

程式碼 4:讀取帳密後嘗試連線
bool connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) {
    return true;
  }

  if (wifiSsid.length() == 0) {
    Serial.println("[WiFi] 尚未儲存 SSID");
    return false;
  }

  stopConfigPortal();

  Serial.println();
  Serial.print("[WiFi] 嘗試連線:");
  Serial.println(wifiSsid);

  WiFi.mode(WIFI_STA);
  WiFi.setAutoReconnect(true);
  WiFi.persistent(false);

  WiFi.begin(
    wifiSsid.c_str(),
    wifiPassword.c_str()
  );

  unsigned long startedAt = millis();

  while (
    WiFi.status() != WL_CONNECTED &&
    millis() - startedAt < WIFI_CONNECT_TIMEOUT_MS
  ) {
    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");
    return true;
  }

  Serial.println("[WiFi] 連線失敗");
  WiFi.disconnect(true, false);
  return false;
}

若 NVS 沒有 SSID,程式不會一直卡在連線迴圈,而是直接進入 AP 模式。 若有 SSID 但 20 秒內仍失敗,也會啟動設定頁。

九、重新啟動後一定要重新讀取 NVS

程式碼 5:setup() 的正確順序
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);

  // 重新啟動後先從 NVS Flash 讀回 SSID 與密碼
  loadWiFiCredentials();

  if (connectWiFi()) {
    initializeTime();
  } else {
    startConfigPortal();
  }

  readSensors();

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

正確順序是:

初始化感測器 初始化 I²C loadWiFiCredentials() connectWiFi() 失敗則 startConfigPortal()

十、實作中遇到的問題與修正

問題 原因 修正方式
按下儲存後手機沒有反應 Captive Portal 對 POST 回應相容性不佳 改用 GET,並指定完整網址 http://192.168.4.1/save
ESP32 沒有重新啟動 重啟排程綁定完成頁是否成功開啟 改成儲存成功後直接排程重啟
重新啟動後顯示尚未儲存 SSID setup() 沒有呼叫 loadWiFiCredentials() 開機時先從 NVS 讀回設定
SSID 寫入後不確定是否成功 只寫入、未回讀驗證 putString() 後立即 getString() 比對
手機無法自動跳出設定頁 手機系統或瀏覽器不支援 Captive Portal 自動彈出 直接使用 Chrome/Safari 開啟 192.168.4.1

十一、序列監控應看到的訊息

儲存時
========== NVS 寫入結果 ==========
[NVS] SSID 寫入 bytes:...
[NVS] 密碼寫入 bytes:...
[NVS] SSID 回讀:成功
[NVS] 密碼回讀:成功
=================================
[AP] 設定已寫入,5 秒後重新啟動
重新啟動後
========== NVS Wi-Fi 設定 ==========
[NVS] SSID key:存在
[NVS] Password key:存在
[WiFi] 已儲存 SSID:Home_WiFi
===================================
[WiFi] 嘗試連線:Home_WiFi
[WiFi] 連線成功

十二、整合智慧養殖系統後的價值

完成 Wi-Fi Config Portal 後,Atlas Aquaponics Kit 就不再綁定單一網路。 同一套 ESP32 程式可以快速部署到不同養殖戶與魚塭,只要手機重新設定 Wi-Fi, 即可恢復 Django REST API 上傳。

這個設計讓系統從「實驗室原型」進一步提升為「可由現場人員自行設定的 IoT 設備」。

後續還可延伸:

  • 在設定頁加入 farm_name、pond_code 與 API token。
  • 加入長按按鍵清除 NVS 設定。
  • 加入 LED 顯示 AP、連線中、已連線與上傳成功狀態。
  • 加入 OTA 遠端更新。
  • 加入多組 Wi-Fi 帳密備援。

結語

透過 Soft-AP、WebServer、DNSServer 與 Preferences,ESP32 可以在沒有螢幕與鍵盤的情況下, 由手機完成 Wi-Fi 設定。NVS 則確保 SSID 與密碼在重新啟動與斷電後仍能保留。 這套流程特別適合智慧養殖、智慧農業、社區 IoT 與其他需要現場部署的設備。

AI 協作聲明: 本文由作者主導內容規劃,並使用生成式 AI 協助文字整理、程式碼說明、版面設計與步驟圖生成; 所述流程均依 Atlas Aquaponics V1.8、Adafruit HUZZAH32 ESP32 Feather 與實際 Wi-Fi AP 設定測試結果整理。

[水井村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_namepond_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);
}