GC9A01 圓形氣象 LCD (SPIFFS + JPG 圖示)
用 ESP32 + GC9A01 圓形 LCD 打造即時氣象錶盤!天氣圖示存在 LittleFS,換圖不用改程式。串接免費 Open-Meteo API,顯示即時溫度與天氣狀態,每10分鐘自動更新,簡單又實用!
上一篇只介紹到了如何用GC9A01顯示文字,這次用了 SPIFFS 儲存 JPG 圖檔,不只顯示圖片,再配合上網路氣象服務,顯示出對應的天氣圖示!
這一篇會學到什麼?
- SPIFFS:ESP32 內建檔案系統,可以存放 JPG、設定檔等
- JPEGDEC:串流解碼 JPEG,記憶體用量低,NodeMCU-32S 也能跑
- 如何在執行時動態決定顯示哪張圖
如果你還沒看過上一篇入門的話,建議可以先去看看哦!

第一步:準備材料
- NodeMCU-32S 開發板 x1
- GC9A01 圓形 LCD(1.28吋,240×240) x1
- 麵包板 與 杜邦線


第二步:硬體接線
| GC9A01 引腳 | NodeMCU-32S | 說明 |
|---|---|---|
| VCC | 3.3V | 電源(⚠️ 只能接 3.3V) |
| GND | GND | 接地 |
| SCL | GPIO 18 | SPI 時脈 |
| SDA | GPIO 23 | SPI 資料輸出 |
| DC | GPIO 27 | 指令 / 資料切換 |
| RST | GPIO 33 | 重置 |
| BLK | GPIO 22 | 背光 |
| CS | GPIO 5 | 片選 |
至於LCD顯示圖片的方法大致上有三種:
方法一:將照片轉成 C 語言陣列(寫死在程式碼裡)
這是創客界最常拿來測試、也是最不需要更動硬體的方法。做法是透過轉檔軟體(例如 Image2LCD 或是網頁版轉換器),把 JPG 或 PNG 照片直接翻譯成成千上萬個代表顏色的 16 進位色碼(例如 0xFFFF 代表白色),變成一個 .h 標頭檔。
- 優點:
- 最簡單、免接線: 不需要增加任何額外模組,程式碼燒進去照片就出來了。
- 顯示速度極快: 因為資料已經解碼並直接存在晶片的 Flash 記憶體裡,LovyanGFX 只要用一行
display.pushImage()就能瞬間把畫面刷出來。
- 缺點:
- 極度消耗記憶體: 一張 240x320 解析度的照片,大約會吃掉 150KB 的程式空間。ESP32 的空間通常只有 4MB 到 16MB,放沒幾張高畫質照片,程式庫就爆滿了。
- 更新麻煩: 每次想換照片,都要重新轉檔、重新編譯燒錄程式。
方法二:存放在 ESP32 內部檔案系統 (SPIFFS / LittleFS)
ESP32 的 Flash 記憶體其實可以切出一塊空間,當成「虛擬的隨身碟」來用。你可以把真正的 .jpg 或 .png 檔案,透過 Arduino IDE 的專屬外掛工具,直接上傳到 ESP32 的肚子裡。
- 優點:
- 免接外掛模組: 一樣不需要額外買 SD 卡模組,硬體保持最精簡。
- 管理直覺: 你面對的是真正的圖檔,而不是一堆看不懂的十六進位代碼。LovyanGFX 支援直接讀取檔案,例如使用
display.drawJpgFile()。而且jpg圖檔很省空間,240x320 解析度的照片,一張30-50k,ESP32你可以放一堆圖檔了!
- 缺點:
- 需要安裝上傳工具: Arduino IDE 原本不支援直接上傳檔案到 ESP32 內部,你必須先安裝「ESP32 Sketch Data Upload」或 LittleFS 的外掛。
- 空間依然受限: 通常能切出來當檔案系統的空間還是有限制,適合放幾張介面 UI 圖片或圖示,無法做大型電子相框。但ESP32-S3 N16R8就不同了,有了16M的大容量,可以放不少jpg圖囉!
方法三:讀取外部 SD 卡(外接 MicroSD 模組)
這就是真正的「電子相框」做法。你需要另外準備一個 MicroSD 卡模組,接上 ESP32,然後把電腦裡的照片全部複製到 SD 卡裡面。
- 優點:
- 容量幾乎無限: 隨便一張 8GB 或 16GB 的 SD 卡,可以放幾千、幾萬張照片。
- 更新超方便: 想換照片時,只要把 SD 卡拔下來插進電腦複製貼上就好,完全不用動到 ESP32 的程式碼。
- 缺點:
- 硬體變複雜: 你需要額外買 SD 卡模組,而且它也是走 SPI 通訊,代表你又要多接 4 到 6 根線(CS, MOSI, MISO, SCK)。這意味著它必須跟你的螢幕、觸控晶片一起「三方共用」匯流排,硬體除錯難度會再稍微提升。
- 顯示速度稍慢: ESP32 必須先從 SD 卡把 JPG 檔案讀出來,經過 CPU 運算解碼,再丟給螢幕,所以圖片太大的話,畫面載入會有一點點「由上往下刷」的感覺。
總結來說: 「方法一」是網路最多人用的,但每次要轉檔,傑森是蠻不喜歡的啦!從SD卡也是麻煩,還要自己再焊線,而且讀取慢半拍。SPIFFS最方便,但就是要考量到儲存空間的存制。但如果像本次要做的只是6張小圖示,而且是用JPG格式,那NodeMcu-32S也是很夠用的哦!
第三步:認識 SPIFFS
SPIFFS(SPI Flash File System)是 ESP32 Flash 裡的一塊獨立分區,就像一個迷你隨身碟。程式存在一個分區,SPIFFS 存在另一個分區,兩者互不影響。
ESP32 Flash(4MB)
├── 程式分區(約 1.4MB) ← 燒錄 .ino 編譯後的 bin
└── SPIFFS 分區(約 1.5MB) ← 存放 JPG 圖示
SPIFFS 的優點是圖示和程式分開管理。想換圖示主題的時候,不用動程式碼,只需要重新上傳 SPIFFS 就好。
第四步:準備 SPIFFS 圖示
把 6 個 JPG 圖示放進專案資料夾裡的 data 子資料夾:
gc9a01_weather_spiffs/
├── gc9a01_weather_spiffs.ino ← 主程式
└── data/ ← SPIFFS 上傳工具會讀這個資料夾
├── sunny.jpg
├── cloudy.jpg
├── rainy.jpg
├── storm.jpg
├── snow.jpg
└── fog.jpg
圖示尺寸建議 64×64 像素,JPG 格式。本篇附上的圖示已經是正確尺寸,直接用就好。





準備工作
1. 安裝 LittleFS Data Uploader 工具
這個工具不會內建在 Arduino IDE 中,需要額外安裝。
- Arduino IDE 2.x 版: 請至 GitHub 搜尋
arduino-littlefs-upload下載.vsix檔,並放入磁碟/使用者/(名稱)/.arduinoIDE/plugins資料夾中。安裝後可按Ctrl + Shift + P呼叫指令區使用。 - 如果沒有
plugins資料夾,就自行建立。



- 準備圖片: 在你的 Arduino 專案資料夾下,建立一個名為
data的資料夾。把準備好的圖片(例如image1.jpg、image2.jpg,建議尺寸 240x320)放進去。

接著回到 Arduino IDE:
工具 (Tools)->Partition Scheme-> 選擇 Custom 的選項(IDE 會優先讀取你剛建立的 csv 檔)。- 然後編譯(或是直接上傳)一次,讓設定生效。
- 執行 LittleFS Data Uploader,按住組合鍵 Ctrl+Shift+p,在輸入框內打upload,就能找到LittleFS Data Uploader,點它就能成功把圖片上傳到開發板了!


第六步:安裝函式庫
進入 Arduino IDE → 工具 → 管理程式庫,安裝:
Arduino_GFX_Library(moononournation):GC9A01 驅動ArduinoJson:解析 Open-Meteo JSONJPEGDEC(Larry Bank):JPEG 解碼器
/*
* GC9A01 圓形 LCD 氣象錶盤(LittleFS + JPG 圖示版)
* 氣象資料來源:Open-Meteo(免費、免 API Key)
* 圖示儲存在 LittleFS 內建檔案系統,方便日後直接換圖
* 適用開發板:NodeMCU-32S
*
* 傑森創工 blog.jmaker.com.tw
*/
// ==========================================
// 1. 引入函式庫
// ==========================================
#include <Arduino_GFX_Library.h> // GC9A01 LCD 驅動
#include <WiFi.h> // WiFi 連線
#include <HTTPClient.h> // 發送 HTTP 請求
#include <ArduinoJson.h> // 解析 JSON 格式的氣象資料
#include <LittleFS.h> // ESP32 內建檔案系統
#include <JPEGDEC.h> // JPEG 解碼器
// 顏色定義(RGB565 格式)
#define BLACK 0x0000
#define WHITE 0xFFFF
#define RED 0xF800
#define GREEN 0x07E0
#define BLUE 0x001F
#define YELLOW 0xFFE0
// ==========================================
// 2. 使用者設定區 - 請修改這裡!
// ==========================================
const char* ssid = "WIFIAP"; // <--- 改成你的 WiFi 名稱
const char* password = "密碼"; // <--- 改成你的 WiFi 密碼
// 你的地點座標(以台中為例,可去 Google Maps 查詢)
String LATITUDE = "24.15";
String LONGITUDE = "120.67";
// ==========================================
// 3. 硬體腳位設定
// ==========================================
#define TFT_CS 5
#define TFT_DC 27
#define TFT_RST 33
#define TFT_BL 22
// ==========================================
// 4. 初始化 GC9A01 LCD
// ==========================================
Arduino_DataBus *bus = new Arduino_HWSPI(TFT_DC, TFT_CS);
Arduino_GC9A01 *gfx = new Arduino_GC9A01(bus, TFT_RST, 0, true);
// ==========================================
// 5. JPEGDEC 解碼器
// ==========================================
JPEGDEC jpeg;
// ==========================================
// 6. 全域變數
// ==========================================
float temperature = 0;
int weatherCode = 0;
bool isDataLoaded = false;
unsigned long lastTime = 0;
unsigned long timerDelay = 600000; // 10 分鐘
// ==========================================
// 7. JPEG 解碼 Callback
// ==========================================
// JPEGDEC 每解碼一個 MCU block 就呼叫這個函式
// 把該塊像素直接畫到 LCD,不需要把整張圖展開在 RAM 裡
// 這樣記憶體用量小很多,NodeMCU-32S 也能跑
int jpegDrawCallback(JPEGDRAW *pDraw) {
gfx->draw16bitRGBBitmap(
pDraw->x, pDraw->y,
pDraw->pPixels,
pDraw->iWidth, pDraw->iHeight
);
return 1; // 回傳 1 = 繼續解碼下一塊
}
// ==========================================
// 8. 根據 weather_code 取得圖示檔名
// ==========================================
// WMO weather_code 對應規則:
// 0 = 晴天
// 1, 2, 3 = 多雲
// 45, 48 = 霧
// 51 ~ 82 = 雨
// 71 ~ 77 = 雪
// 95 ~ 99 = 雷雨
String getIconFilename(int code) {
if (code == 0) return "/sunny.jpg";
if (code >= 1 && code <= 3) return "/cloudy.jpg";
if (code == 45 || code == 48) return "/fog.jpg";
if (code >= 51 && code <= 82) return "/rainy.jpg";
if (code >= 71 && code <= 77) return "/snow.jpg";
if (code >= 95) return "/storm.jpg";
return "/cloudy.jpg";
}
// ==========================================
// 9. 根據 weather_code 取得天氣描述
// ==========================================
String getWeatherDesc(int code) {
if (code == 0) return "Sunny";
if (code >= 1 && code <= 3) return "Cloudy";
if (code == 45 || code == 48) return "Foggy";
if (code >= 51 && code <= 67) return "Rainy";
if (code >= 71 && code <= 77) return "Snowy";
if (code >= 80 && code <= 82) return "Shower";
if (code >= 95 && code <= 99) return "Stormy";
return "Unknown";
}
// ==========================================
// 10. 從 LittleFS 讀取並顯示 JPEG 圖示
// ==========================================
void showWeatherIcon(String filename) {
// 確認檔案存在
if (!LittleFS.exists(filename)) {
Serial.print(F("[圖示] 找不到:"));
Serial.println(filename);
return;
}
// 開啟檔案
File f = LittleFS.open(filename, "r");
if (!f) {
Serial.println(F("[圖示] 開啟失敗"));
return;
}
// 讀進記憶體 buffer
// 圖示很小(約 1~3KB),直接讀進來沒問題
size_t fileSize = f.size();
uint8_t *buf = (uint8_t *)malloc(fileSize);
if (!buf) {
Serial.println(F("[圖示] 記憶體不足"));
f.close();
return;
}
f.read(buf, fileSize);
f.close();
// 計算圖示置中位置
// 圖示 64x64,螢幕 240x240
// 水平置中:(240 - 64) / 2 = 88
// 垂直放在上半部:y = 60
int iconX = (240 - 64) / 2;
int iconY = 60;
// 用 JPEGDEC 從記憶體解碼並串流顯示
if (jpeg.openRAM(buf, fileSize, jpegDrawCallback)) {
jpeg.setPixelType(RGB565_LITTLE_ENDIAN);
//jpeg.setPixelType(RGB565_BIG_ENDIAN); //如果圖案顏色花了,可換這行
jpeg.decode(iconX, iconY, 0); // 0 = 不縮放
jpeg.close();
} else {
Serial.println(F("[圖示] JPEG 解碼失敗"));
}
free(buf); // 一定要釋放記憶體!
}
// ==========================================
// 11. 更新螢幕畫面
// ==========================================
void updateDisplay() {
Serial.println(F("[顯示] 更新畫面..."));
gfx->fillScreen(BLACK);
if (isDataLoaded) {
// --- 顯示天氣圖示(從 LittleFS 讀取 JPG)---
String iconFile = getIconFilename(weatherCode);
Serial.print(F("[顯示] 圖示:"));
Serial.println(iconFile);
showWeatherIcon(iconFile);
// --- 顯示溫度 ---
gfx->setTextSize(4);
gfx->setTextColor(WHITE);
int numWidth = (temperature >= 10 || temperature <= -1) ? 48 : 24;
int tempX = (240 - numWidth - 20) / 2;
gfx->setCursor(tempX, 148);
gfx->print((int)temperature);
// 度數符號
gfx->drawCircle(tempX + numWidth + 5, 150, 4, WHITE);
gfx->drawCircle(tempX + numWidth + 5, 150, 3, WHITE);
// 單位 C
gfx->setTextSize(2);
gfx->setCursor(tempX + numWidth + 13, 150);
gfx->print(F("C"));
// --- 天氣描述 ---
String desc = getWeatherDesc(weatherCode);
gfx->setTextSize(2);
gfx->setTextColor(0x7BEF);
int descX = (240 - desc.length() * 12) / 2;
gfx->setCursor(descX, 200);
gfx->print(desc);
// --- 地點 ---
gfx->setTextSize(1);
gfx->setTextColor(0x39E7);
gfx->setCursor(80, 222);
gfx->print(F("Taichung, TW"));
} else {
gfx->setTextSize(2);
gfx->setTextColor(WHITE);
gfx->setCursor(40, 110);
gfx->print(F("Loading..."));
}
Serial.println(F("[顯示] 完成"));
}
// ==========================================
// 12. 從 Open-Meteo 抓取天氣資料
// ==========================================
void getWeather() {
Serial.println(F("\n-----------------------------"));
Serial.println(F("[HTTP] 準備抓取天氣資料..."));
int maxRetries = 3;
bool success = false;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
Serial.print(F("[嘗試] 第 "));
Serial.print(attempt);
Serial.print(F(" 次... "));
WiFiClient client;
HTTPClient http;
String serverPath =
"http://api.open-meteo.com/v1/forecast?latitude=" + LATITUDE +
"&longitude=" + LONGITUDE +
"¤t=temperature_2m,weather_code&timezone=auto";
if (http.begin(client, serverPath)) {
http.setTimeout(5000);
int httpCode = http.GET();
if (httpCode == 200) {
Serial.println(F("成功!"));
String payload = http.getString();
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload);
if (!error) {
temperature = doc["current"]["temperature_2m"];
weatherCode = doc["current"]["weather_code"];
Serial.print(F(" >>> 溫度: ")); Serial.println(temperature);
Serial.print(F(" >>> 天氣代碼: ")); Serial.println(weatherCode);
isDataLoaded = true;
updateDisplay();
success = true;
http.end();
break;
} else {
Serial.print(F("JSON 解析失敗: "));
Serial.println(error.c_str());
}
} else {
Serial.print(F("HTTP 錯誤: "));
Serial.println(httpCode);
}
http.end();
} else {
Serial.println(F("無法建立連線"));
}
if (!success && attempt < maxRetries) {
Serial.println(F(" -> 等待 2 秒後重試..."));
delay(2000);
}
}
if (!success) {
Serial.println(F("[遺憾] 3 次嘗試都失敗。"));
gfx->fillScreen(BLACK);
gfx->setTextSize(2);
gfx->setTextColor(RED);
gfx->setCursor(40, 110);
gfx->print(F("Net Error"));
}
Serial.println(F("-----------------------------\n"));
}
// ==========================================
// 13. Setup
// ==========================================
void setup() {
Serial.begin(115200);
delay(2000);
Serial.println(F("\n================================="));
Serial.println(F(" GC9A01 氣象錶盤(LittleFS版) "));
Serial.println(F("=================================\n"));
// --- A. 背光 ---
pinMode(TFT_BL, OUTPUT);
digitalWrite(TFT_BL, HIGH);
// --- B. LCD ---
Serial.print(F("[初始化] GC9A01... "));
gfx->begin();
gfx->fillScreen(BLACK);
Serial.println(F("成功"));
gfx->setTextSize(2);
gfx->setTextColor(WHITE);
gfx->setCursor(40, 100);
gfx->print(F("JMaker"));
gfx->setCursor(20, 130);
gfx->print(F("Weather!"));
// --- C. LittleFS ---
Serial.print(F("[初始化] LittleFS... "));
if (!LittleFS.begin(true)) {
// true = 若格式有問題自動重新格式化
Serial.println(F("失敗!請確認已上傳圖示至 LittleFS。"));
} else {
Serial.println(F("成功"));
// 列出 LittleFS 裡的所有檔案,方便除錯確認
Serial.println(F("[LittleFS] 檔案清單:"));
File root = LittleFS.open("/");
File file = root.openNextFile();
while (file) {
Serial.print(F(" - "));
Serial.print(file.name());
Serial.print(F(" ("));
Serial.print(file.size());
Serial.println(F(" bytes)"));
file = root.openNextFile();
}
}
// --- D. WiFi ---
Serial.println(F("[初始化] WiFi..."));
WiFi.mode(WIFI_STA);
WiFi.setTxPower(WIFI_POWER_8_5dBm);
WiFi.setSleep(false);
IPAddress dns(8, 8, 8, 8);
WiFi.config(INADDR_NONE, INADDR_NONE, dns);
Serial.print(F("[WiFi] 連線至: "));
Serial.println(ssid);
WiFi.begin(ssid, password);
int limit = 0;
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(F("."));
limit++;
if (limit > 60) {
Serial.println(F("\n[錯誤] 超時,重開機..."));
ESP.restart();
}
}
Serial.println(F("\n[WiFi] 連線成功!"));
Serial.print(F(" - IP:")); Serial.println(WiFi.localIP());
// --- E. 第一次抓天氣 ---
getWeather();
}
// ==========================================
// 14. Loop
// ==========================================
void loop() {
if ((millis() - lastTime) > timerDelay) {
Serial.println(F("[計時器] 更新天氣!"));
if (WiFi.status() == WL_CONNECTED) {
getWeather();
} else {
Serial.println(F("[錯誤] WiFi 斷線,重連中..."));
WiFi.reconnect();
}
lastTime = millis();
}
}
第八步:上傳程式碼
SPIFFS 上傳完成後,再上傳主程式。
修改 3 個地方:
ssid:你的 WiFi 名稱password:你的 WiFi 密碼LATITUDE/LONGITUDE:你的經緯度
👇 重點解析:
- SPIFFS 要先上傳,程式才找得到圖示。如果順序反了,螢幕只會顯示溫度,沒有圖示。
- JPEGDEC 串流解碼:程式用 callback 函式一塊一塊解碼並畫到螢幕,不需要一次把整張圖展開在 RAM,NodeMCU-32S 記憶體足夠。
- Serial Monitor 開機時會列出 SPIFFS 裡的所有檔案,方便確認圖示有沒有上傳成功。
- Open-Meteo 免費、免 Key,WiFi 防當機三招延用氣象站那篇。
- 預設每 10 分鐘更新一次。
常見問題
Q:有溫度但沒有圖示? A:SPIFFS 裡找不到對應的 JPG。打開 Serial Monitor 確認開機時有沒有列出圖示檔案,沒有的話重新執行SPIFFS 上傳。
Q:圖示顯示花掉或顏色怪? 可以在以下兩行換一行再試試。
jpeg.setPixelType(RGB565_LITTLE_ENDIAN);
jpeg.setPixelType(RGB565_BIG_ENDIAN);
Q:想換一套新的圖示主題? A:把新的 JPG 放進 data/ 資料夾(檔名不變),重新執行上傳就好,不需要重新燒錄程式。這就是 SPIFFS 版最大的優點!
Q:WiFi 連不上? A:確認是 2.4GHz 的 WiFi。
希望大家做出來都很順利!有問題歡迎到我們粉絲團留言討論 😄
#傑森創工 #ESP32 #GC9A01 #SPIFFS #Arduino #maker