GC9A01 圓形氣象 LCD (SPIFFS + JPG 圖示)

用 ESP32 + GC9A01 圓形 LCD 打造即時氣象錶盤!天氣圖示存在 LittleFS,換圖不用改程式。串接免費 Open-Meteo API,顯示即時溫度與天氣狀態,每10分鐘自動更新,簡單又實用!

GC9A01 圓形氣象 LCD (SPIFFS + JPG 圖示)

上一篇只介紹到了如何用GC9A01顯示文字,這次用了 SPIFFS 儲存 JPG 圖檔,不只顯示圖片,再配合上網路氣象服務,顯示出對應的天氣圖示!


這一篇會學到什麼?

  • SPIFFS:ESP32 內建檔案系統,可以存放 JPG、設定檔等
  • JPEGDEC:串流解碼 JPEG,記憶體用量低,NodeMCU-32S 也能跑
  • 如何在執行時動態決定顯示哪張圖

如果你還沒看過上一篇入門的話,建議可以先去看看哦!

[ESP32]GC9A01圓形LCD,入門篇
GC9A01這款LCD很特別,是圓形的!看外型就覺得很適合拿來做手錶、時鐘,或其它測量儀器。1.28吋,全彩螢幕,真的蠻漂亮的。

第一步:準備材料

  1. NodeMCU-32S 開發板 x1
  2. GC9A01 圓形 LCD(1.28吋,240×240) x1
  3. 麵包板杜邦線
NodeMCU-32S 相容版本 ESP32開發板 WiFi 藍牙 可用Arduino IDE
全腳位引出,還保持迷你的身型,插上麵包後還能插杜邦線,真的是太棒了! 和NodeMCU V2幾乎一樣尺寸! 有5V供電輸出,非常方便! 有了ESP32開發板,真的可以忘記原來的那些Arduino板子了! 可以用Arduino IDE開發,但效能更強大,還內建WiFi 傑森實測記錄,大家可以到 F 粉 絲 團 B 看貼文哦! ESP32-D0WDQ6 內置兩個低功耗 Xtensa® 32-bit LX6 MCU。片上存儲包括: • 448 KB 的 ROM,用於程序啟動和內核功能
GC9A01圓形 1.28吋 TFT LCD顯示器
圓形切割的LCD,非常特別。 建議使用ESP32或ESP8266配合 已焊好針,不附杜邦線,開發板另購 尺寸:1.28吋 解析度:240x240 驅動晶片:GC9A01 介面:SPI 面板尺寸:44x26mm 工作電壓:3.3V

第二步:硬體接線

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資料夾,就自行建立。
Releases · earlephilhower/arduino-littlefs-upload
Build and uploads LittleFS filesystems for the Arduino-Pico RP2040, RP2350, ESP8266, and ESP32 cores under Arduino IDE 2.2.1 or higher - earlephilhower/arduino-littlefs-upload
  • 準備圖片: 在你的 Arduino 專案資料夾下,建立一個名為 data 的資料夾。把準備好的圖片(例如 image1.jpgimage2.jpg,建議尺寸 240x320)放進去。

接著回到 Arduino IDE:

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

第六步:安裝函式庫

進入 Arduino IDE → 工具 → 管理程式庫,安裝:

  • Arduino_GFX_Library(moononournation):GC9A01 驅動
  • ArduinoJson:解析 Open-Meteo JSON
  • JPEGDEC(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 +
      "&current=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 個地方:

  1. ssid:你的 WiFi 名稱
  2. password:你的 WiFi 密碼
  3. LATITUDE / LONGITUDE:你的經緯度

👇 重點解析:

  1. SPIFFS 要先上傳,程式才找得到圖示。如果順序反了,螢幕只會顯示溫度,沒有圖示。
  2. JPEGDEC 串流解碼:程式用 callback 函式一塊一塊解碼並畫到螢幕,不需要一次把整張圖展開在 RAM,NodeMCU-32S 記憶體足夠。
  3. Serial Monitor 開機時會列出 SPIFFS 裡的所有檔案,方便確認圖示有沒有上傳成功。
  4. Open-Meteo 免費、免 Key,WiFi 防當機三招延用氣象站那篇。
  5. 預設每 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