数码管支持时间、气象温度显示10秒轮回、运行稳定、接口获取天气数据;
针脚CLK=IO12,DIO=IO13,3V3/GND
知识点:配网联网、数码管显示、亮度调节、气象温度获取、串口调试与交互、防死机机制


源码固件直接烧录
/*
* t_tm1637.cpp — TM1637 四位数码管 时钟 + 间歇温度显示
*
* 烧录:platformio run -e tm1637 -t upload
*
* 接线(本独立固件未编译 LCD/OLED,故 P1 上的 IO12/IO13 可作纯 GPIO 用):
* TM1637 VCC -> 3.3V
* TM1637 GND -> GND
* TM1637 CLK -> P1-3 IO12 (PIN_TM1637_CLK)
* TM1637 DIO -> P1-5 IO13 (PIN_TM1637_DIO)
*
* ⚠️ 不要选 IO11 作 CLK/DIO:N8R8 模块上 GPIO11 复用为 VDD_SPI(给 Octal PSRAM 供电),
* 不是普通 GPIO,任何固件都驱动不了。
*
* 显示逻辑:
- 常态显示时间 hh:mm,中间冒号按 1Hz 闪烁(充当“秒”指示,因为只显示到分)。
- 每 10 秒,若有温度值,切到温度显示约 3 秒(格式 99.9C,无冒号),然后回到时间。
- 温度暂无传感器:默认不切温度;串口发 "temp 25.3" 注入测试值,发 "notemp" 取消。
*
* 串口命令(与 oled 一致,便于改 WiFi):
* 9IEDITWIFI:SSID,PASSWORD / 9IPROV / 9IRESETCONFIG
* temp XX.X (注入温度,启用间歇温度显示)
* notemp (清除温度,回到纯时间)
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiMulti.h>
#include <WiFiUdp.h>
#include <HTTPClient.h>
#include <stdlib.h>
#include <NTPClient.h>
#include <time.h>
#include "pins.h"
#include "wifi_creds.h"
#include "prov_ap.h"
// ---------------- TM1637 引脚(见 pins.h)----------------
#define TM_CLK PIN_TM1637_CLK
#define TM_DIO PIN_TM1637_DIO
// ---------------- 显示参数 ----------------
#define TM_BRIGHTNESS 0x05 // 初始/默认亮度(0~7,越大越亮)
#define TM_BRIGHT_DAY 0x03 // 白天 08:00-18:00 较低亮度
#define TM_BRIGHT_NIGHT 0x07 // 夜间 18:00-08:00 较高亮度
#define COLON_DIGIT_IDX 1 // 中间冒号=第2位的 DP(0x80);绝大多数带时钟点模组如此
#define SWITCH_MS 10000UL // 每 10 秒切到温度
#define TEMP_SHOW_MS 3000UL // 温度显示持续 3 秒
#define COLON_BLINK_MS 500UL // 冒号 1Hz 闪烁
// ---------------- 7 段编码(bit0=a,1=b,2=c,3=d,4=e,5=f,6=g;bit7=DP)----------------
static const uint8_t DIGIT[10] = {
0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F
};
#define SEG_BLANK 0x00
#define SEG_DASH 0x40 // 仅 g -> '-'
#define SEG_C 0x39 // a,d,e,f -> 'C'
// ---------------- TM1637 位控驱动(bit-bang,零依赖)----------------
class TM1637 {
public:
TM1637(uint8_t clk, uint8_t dio) : m_clk(clk), m_dio(dio), m_bright(TM_BRIGHTNESS) {}
void begin() {
pinMode(m_clk, OUTPUT);
pinMode(m_dio, OUTPUT);
digitalWrite(m_clk, HIGH);
digitalWrite(m_dio, HIGH);
uint8_t z[4] = {0, 0, 0, 0}; // 上电先清屏
display(z, false);
}
void display(const uint8_t seg[4], bool colon) {
start();
writeByte(0x40); // 数据命令:自动地址递增
stop();
start();
writeByte(0xC0); // 起始地址 0
for (int i = 0; i < 4; i++) {
uint8_t s = seg[i];
if (i == COLON_DIGIT_IDX && colon) s |= 0x80; // 中间冒号(第2位 DP)
writeByte(s);
}
stop();
start();
writeByte(0x88 | m_bright); // 显示开 + 亮度
stop();
}
void setBrightness(uint8_t b) {
m_bright = b & 0x07;
start();
writeByte(0x88 | m_bright); // 仅改亮度,不动显示内容
stop();
}
uint8_t brightness() const { return m_bright; }
private:
void start() {
digitalWrite(m_clk, HIGH);
digitalWrite(m_dio, HIGH);
delayMicroseconds(2);
digitalWrite(m_dio, LOW);
delayMicroseconds(2);
digitalWrite(m_clk, LOW);
}
void stop() {
digitalWrite(m_clk, LOW);
digitalWrite(m_dio, LOW);
delayMicroseconds(2);
digitalWrite(m_clk, HIGH);
delayMicroseconds(2);
digitalWrite(m_dio, HIGH);
}
// 返回从机 ACK(拉低=成功)
bool writeByte(uint8_t b) {
for (int i = 0; i < 8; i++) {
digitalWrite(m_clk, LOW);
digitalWrite(m_dio, (b & 0x01) ? HIGH : LOW);
delayMicroseconds(2);
digitalWrite(m_clk, HIGH);
delayMicroseconds(2);
b >>= 1;
}
// ACK:释放 DIO,第 9 个时钟读从机应答
digitalWrite(m_clk, LOW);
pinMode(m_dio, INPUT);
delayMicroseconds(2);
digitalWrite(m_clk, HIGH);
delayMicroseconds(2);
bool ack = (digitalRead(m_dio) == LOW);
digitalWrite(m_clk, LOW);
pinMode(m_dio, OUTPUT);
return ack;
}
uint8_t m_clk, m_dio, m_bright;
};
static TM1637 g_tm(TM_CLK, TM_DIO);
// ---------------- WiFi / NTP ----------------
#define DEV_SEED_CREDS 1
static const char *PRESET_SSID[] = {"huawei", "xiaomi", "iphone"};
static const char *PRESET_PASS[] = {"123456", "123456", "123456"};
static WifiCreds creds;
static WiFiUDP ntpUDP;
static NTPClient timeClient(ntpUDP, "pool.ntp.org", 8 * 3600, 60000); // UTC+8
// ---------------- 温度(暂时无传感器,串口注入测试)----------------
static bool g_tempAvail = false;
static float g_tempC = 0.0f;
// ---------------- 状态机 ----------------
enum { MODE_TIME, MODE_TEMP };
static int g_mode = MODE_TIME;
static unsigned long g_lastSwitchMs = 0;
static unsigned long g_tempEnterMs = 0;
static uint8_t g_curBright = TM_BRIGHTNESS;
// ---------------- 显示缓冲(去重重绘,减少闪烁)----------------
static uint8_t g_lastSeg[4] = {0xFF, 0xFF, 0xFF, 0xFF};
static bool g_lastColon = false;
// ---------------- 构建段码 ----------------
static void buildTime(uint8_t seg[4], int hh, int mm, bool blankLeading) {
int h0 = hh / 10, h1 = hh % 10;
int m0 = mm / 10, m1 = mm % 10;
seg[0] = (blankLeading && h0 == 0) ? SEG_BLANK : DIGIT[h0];
seg[1] = DIGIT[h1];
seg[2] = DIGIT[m0];
seg[3] = DIGIT[m1];
}
static void buildTemp(uint8_t seg[4], float t) {
if (t > 99.9f || t < -9.95f) { // 超范围:显示 "----"
seg[0] = seg[1] = seg[2] = seg[3] = SEG_DASH;
return;
}
bool neg = (t < 0.0f);
float at = neg ? -t : t;
int t10 = (int)(at * 10.0f + 0.5f); // 四舍五入到 0.1C,避免浮点截断少 1
int ip = t10 / 10;
int frac = t10 % 10;
if (neg) {
seg[0] = SEG_DASH; // "-5.3C"
seg[1] = DIGIT[ip] | 0x80;
} else if (ip >= 10) { // "25.3C"
seg[0] = DIGIT[ip / 10];
seg[1] = DIGIT[ip % 10] | 0x80;
} else { // " 5.3C" 整数十位留空
seg[0] = SEG_BLANK;
seg[1] = DIGIT[ip] | 0x80;
}
seg[2] = DIGIT[frac];
seg[3] = SEG_C; // 'C'
}
// ---------------- 取时间(NTP 优先;未校时则板载自走时钟,从 08:00:00 起)----------------
static bool getClock(int &hh, int &mm, int &ss) {
if (timeClient.isTimeSet()) {
time_t t = timeClient.getEpochTime();
struct tm *ti = localtime(&t);
hh = ti->tm_hour; mm = ti->tm_min; ss = ti->tm_sec;
return true;
}
unsigned long secs = 8UL * 3600UL + millis() / 1000UL; // 自走时钟兜底
hh = (secs / 3600) % 24;
mm = (secs / 60) % 60;
ss = secs % 60;
return false;
}
// ---------------- 重绘(仅在内容变化时写屏,减少闪烁)----------------
static void render(const uint8_t seg[4], bool colon) {
bool changed = (colon != g_lastColon);
for (int i = 0; i < 4; i++)
if (seg[i] != g_lastSeg[i]) { changed = true; break; }
if (!changed) return;
g_tm.display(seg, colon);
for (int i = 0; i < 4; i++) g_lastSeg[i] = seg[i];
g_lastColon = colon;
}
// ---------------- 联网温度(中国天气网 d1 实时接口,需 Referer)----------------
// 返回 var dataSK={...,"temp":"17.5",...},约 200B; cityname 为 GBK,固件只取 temp,不受影响
struct CityCode { const char *name; float lat; float lon; };
static const CityCode CITIES[] = {
// 河南省(18 个省辖市)
{"郑州",34.76f,113.65f},{"安阳",36.10f,114.39f},{"新乡",35.30f,113.93f},{"许昌",34.04f,113.85f},
{"平顶山",33.74f,113.30f},{"信阳",32.13f,114.07f},{"南阳",32.99f,112.53f},{"开封",34.80f,114.34f},
{"洛阳",34.62f,112.45f},{"商丘",34.41f,115.65f},{"焦作",35.24f,113.23f},{"鹤壁",35.90f,114.29f},
{"濮阳",35.76f,115.03f},{"周口",33.63f,114.65f},{"漯河",33.58f,114.02f},{"驻马店",33.01f,114.02f},
{"三门峡",34.78f,111.19f},{"济源",35.07f,112.59f},
// 全国主要城市
{"北京",39.90f,116.41f},{"上海",31.23f,121.47f},{"天津",39.13f,117.20f},{"重庆",29.56f,106.55f},
{"石家庄",38.04f,114.51f},{"太原",37.87f,112.55f},{"西安",34.34f,108.94f},{"济南",36.65f,117.12f},
{"青岛",36.07f,120.38f},{"合肥",31.82f,117.23f},{"南京",32.06f,118.80f},{"杭州",30.27f,120.15f},
{"武汉",30.59f,114.31f},{"长沙",28.23f,112.94f},{"成都",30.57f,104.07f},{"广州",23.13f,113.26f},
{"深圳",22.54f,114.06f},
};
#define N_CITIES ((int)(sizeof(CITIES) / sizeof(CITIES[0])))
#define WEATHER_FETCH_MS 600000UL // 每 10 分钟自动刷新
static int g_cityIdx = 0; // 默认郑州
static String g_customCode = ""; // city <9位中国天气网代码> 直发的自定义城市
static unsigned long g_lastFetchMs = 0;
static String g_cityLabel() { return g_customCode.length() ? String("自定义(") + g_customCode + ")" : String(CITIES[g_cityIdx].name); }
static bool fetchWeatherTemp() {
if (WiFi.status() != WL_CONNECTED) return false;
// 指定了中国天气网 9 位代码的城市:走 d1 实时接口(需 Referer)
if (g_customCode.length()) {
Serial.printf("[WTHR] %s -> d1 接口获取中...\n", g_cityLabel().c_str());
HTTPClient http;
String url = String("http://d1.weather.com.cn/sk_2d/") + g_customCode + ".html";
if (!http.begin(url)) { Serial.println("[WTHR] d1 begin 失败(DNS/URL)"); return false; }
http.addHeader("Referer", "http://www.weather.com.cn/"); // d1 接口必须带 Referer
http.setUserAgent("Mozilla/5.0");
int code = http.GET();
bool ok = false;
if (code == 200) {
String body = http.getString();
int p = body.indexOf("\"temp\":\""); // 精确匹配,不会命中 "tempf"
if (p >= 0) {
g_tempC = body.substring(p + 8).toFloat();
g_tempAvail = true; ok = true;
Serial.printf("[WTHR] %s(d1) OK 实时温度 %.1fC\n", g_cityLabel().c_str(), g_tempC);
} else {
Serial.printf("[WTHR] d1 解析失败,body=%s\n", body.substring(0, 120).c_str());
}
}
http.end();
if (!ok && code != 200) Serial.printf("[WTHR] d1 获取失败 HTTP=%d(保留旧值)\n", code);
return ok;
}
// 内置城市:Open-Meteo 主源(HTTP,免 key,干净 JSON)
Serial.printf("[WTHR] %s -> Open-Meteo 获取中...\n", CITIES[g_cityIdx].name);
HTTPClient http;
http.setTimeout(10000);
char url[128];
snprintf(url, sizeof(url),
"http://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f¤t_weather=true",
CITIES[g_cityIdx].lat, CITIES[g_cityIdx].lon);
if (!http.begin(url)) { Serial.println("[WTHR] Open-Meteo begin 失败(DNS/URL)"); return false; }
int code = http.GET();
bool ok = false;
if (code == 200) {
String body = http.getString();
const char *cw = strstr(body.c_str(), "\"current_weather\"");
const char *pt = cw ? strstr(cw, "\"temperature\":") : nullptr;
if (pt) {
pt += 13; // 跳过 "temperature":(14 字符,含引号+冒号)
// 跳过冒号/空格/引号等非数值字符,定位到数字本身
while (*pt && (*pt < '0' || *pt > '9') && *pt != '-' && *pt != '.') pt++;
g_tempC = (float)atof(pt);
g_tempAvail = true; ok = true;
Serial.printf("[WTHR] %s Open-Meteo OK 实时温度 %.1fC\n", CITIES[g_cityIdx].name, g_tempC);
} else {
Serial.printf("[WTHR] Open-Meteo 解析失败,body=%s\n", body.substring(0, 120).c_str());
}
}
http.end();
if (!ok && code != 200) Serial.printf("[WTHR] Open-Meteo 获取失败 HTTP=%d(保留旧值)\n", code);
return ok;
}
// ---------------- 串口命令 ----------------
static bool needRestart = false;
static void parseSerialLine(const String &line) {
if (line.startsWith("9IEDITWIFI:")) {
String body = line.substring(11);
int comma = body.indexOf(',');
if (comma < 0) { Serial.println("[PROV] 格式错:9IEDITWIFI:SSID,PASSWORD"); return; }
String ssid = body.substring(0, comma);
String pass = body.substring(comma + 1);
ssid.trim(); pass.trim();
if (ssid.length() == 0) { Serial.println("[PROV] SSID 空"); return; }
creds.set(ssid.c_str(), pass.c_str());
Serial.printf("[PROV] 已保存 WiFi: %s,即将重连\n", ssid.c_str());
needRestart = true;
} else if (line == "9IPROV") {
Serial.println("[PROV] 收到 9IPROV,重启进入网页配网");
needRestart = true;
} else if (line == "9IRESETCONFIG") {
creds.clearAll();
Serial.println("[PROV] 已清空 WiFi 凭据,重启进入网页配网");
needRestart = true;
} else if (line.startsWith("temp ")) {
float v = line.substring(5).toFloat();
String arg = line.substring(5);
arg.trim();
if (arg.length() == 0 || (v == 0.0f && arg.indexOf('0') < 0)) {
Serial.println("[TEMP] 解析失败,格式:temp 25.3");
} else {
g_tempC = v; g_tempAvail = true;
g_mode = MODE_TEMP; g_tempEnterMs = millis(); // 立即切温度显示,便于串口确认
Serial.printf("[TEMP] 已注入 %.1fC,立即显示 3 秒,之后每 10s 间歇切换\n", g_tempC);
}
} else if (line == "notemp") {
g_tempAvail = false;
Serial.println("[TEMP] 已清除温度,回到纯时间显示");
} else if (line.startsWith("city ")) {
String arg = line.substring(5);
arg.trim();
if (arg.length() == 0) { Serial.println("[WTHR] 格式:city 郑州 或 city 101180101(中国天气网9位代码)"); return; }
bool found = false;
for (int i = 0; i < N_CITIES; i++) {
if (arg == CITIES[i].name) { g_cityIdx = i; g_customCode = ""; found = true; break; }
}
if (!found && arg.length() == 9) { // 表外城市:直接发中国天气网 9 位代码,走 d1 接口
bool digits = true;
for (unsigned int k = 0; k < arg.length(); k++)
if (!isDigit(arg[k])) { digits = false; break; }
if (digits) { g_customCode = arg; found = true; }
}
if (found) {
Serial.printf("[WTHR] 城市切换为 %s,立即获取\n", g_cityLabel().c_str());
if (fetchWeatherTemp()) { g_lastFetchMs = millis(); g_mode = MODE_TEMP; g_tempEnterMs = millis(); }
} else {
Serial.printf("[WTHR] 未收录 \"%s\"。可用城市:\n", arg.c_str());
for (int i = 0; i < N_CITIES; i++) Serial.printf(" %s\n", CITIES[i].name);
Serial.println(" 或发 city <中国天气网9位代码>(如 101180103)走 d1 接口");
}
} else {
Serial.printf("[SERIAL] 未识别命令: \"%s\"\n", line.c_str());
}
}
static void checkSerial() {
static String buf;
while (Serial.available()) {
char c = (char)Serial.read();
if (c == '\n' || c == '\r') {
if (buf.length()) { parseSerialLine(buf); buf = ""; }
} else buf += c;
}
}
static bool attemptConnect(unsigned long timeoutMs) {
WiFiMulti wm;
for (int i = 0; i < creds.count(); i++)
wm.addAP(creds.get(i)->ssid, creds.get(i)->pass);
Serial.print("[WiFi] 连接中");
unsigned long t = millis();
int dots = 0;
while (wm.run() != WL_CONNECTED && millis() - t < timeoutMs) {
if ((dots++) % 10 == 0) Serial.print(".");
delay(100);
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("[WiFi] 联网成功 SSID=%s IP=%s RSSI=%ddBm\n",
WiFi.SSID().c_str(), WiFi.localIP().toString().c_str(), WiFi.RSSI());
return true;
}
Serial.println("[WiFi] 连接失败");
return false;
}
void setup() {
Serial.begin(115200);
// ⚠️ 绝不能设 0:HWCDC(arduino-esp32 2.0.17) 的 write() 在"串口断开+发送缓冲满"时,
// tries=tx_timeout_ms=0 → 递减下溢成 0xFFFFFFFF → while(connected && to_send) 死循环,
// loopTask 被卡死(表现为运行几分钟后显示冻结/死掉,开串口则因缓冲被读空而不复现)。
// 默认 100ms:满时阻塞约 100ms 后放弃退出,tries 正常归零。代价仅为打印偶发 100ms 延迟。
Serial.setTxTimeoutMs(100);
delay(1000);
Serial.println("=== TM1637 Clock ===");
g_tm.begin();
Serial.printf("[TM1637] init CLK=GPIO%d DIO=GPIO%d\n", TM_CLK, TM_DIO);
// ★ 上电 1 秒内先点亮 "----",不等 WiFi/NTP:避免"开机到首次显示"的黑屏窗口
// (此前 setup 要走完 连WiFi(最长20s)+NTP+拉天气 才开始第一次渲染,不开串口时
// 看上去就像"不显示"。关串口监视器会触发 S3 USB-JTAG 复位重启,同样落在该窗口内)
{
uint8_t initSeg[4] = {SEG_DASH, SEG_DASH, SEG_DASH, SEG_DASH};
g_tm.display(initSeg, false);
for (int i = 0; i < 4; i++) g_lastSeg[i] = initSeg[i];
g_lastColon = false;
}
creds.begin();
Serial.printf("[WiFi] NVS 已有 %d 条凭据\n", creds.count());
if (creds.empty()) {
#if DEV_SEED_CREDS
Serial.println("[WiFi] NVS 为空,先用开发预设播种");
for (int i = 0; i < 5; i++) creds.set(PRESET_SSID[i], PRESET_PASS[i]);
#else
Serial.println("[PROV] NVS 为空,进入网页配网");
startApProvisioning(creds, 180000);
ESP.restart();
#endif
}
WiFi.mode(WIFI_STA);
// 连不上也不强制造配网:时钟用板载自走时钟兜底,仍可显示
if (!attemptConnect(20000)) {
Serial.println("[WiFi] 未联网,时钟将使用板载自走时钟(08:00 起);串口发 9IEDITWIFI:SSID,PASS 配网");
}
timeClient.begin();
for (int k = 0; k < 5 && !timeClient.update(); k++) delay(300);
if (timeClient.isTimeSet())
Serial.printf("[NTP] ok: %s\n", timeClient.getFormattedTime().c_str());
else
Serial.println("[NTP] 未校时,使用板载自走时钟(08:00 起)");
g_lastSwitchMs = millis();
// 不在 setup 里同步拉天气(会拖慢首次显示):
// 开机 5 秒后由 loop 触发首次拉取;失败则 15s 后再试
g_lastFetchMs = millis() - WEATHER_FETCH_MS + 5000UL;
}
void loop() {
checkSerial();
if (needRestart) { delay(200); ESP.restart(); }
if (WiFi.status() != WL_CONNECTED) {
Serial.println("[WiFi] reconnecting");
if (!attemptConnect(20000)) Serial.println("[WiFi] reconnect failed");
}
timeClient.update();
// ---- 定时刷新联网温度(每 10 分钟)----
if (WiFi.status() == WL_CONNECTED && millis() - g_lastFetchMs >= WEATHER_FETCH_MS) {
g_lastFetchMs = millis();
fetchWeatherTemp();
}
unsigned long now = millis();
uint8_t seg[4];
// ---- 亮度按昼夜自动调整(08:00-18:00 低,其余高;仅变化时下发)----
int hh, mm, ss;
getClock(hh, mm, ss);
uint8_t targetB = (hh >= 8 && hh < 18) ? TM_BRIGHT_DAY : TM_BRIGHT_NIGHT;
if (targetB != g_curBright) { g_tm.setBrightness(targetB); g_curBright = targetB; }
// ---- 模式切换:每 10s 若有温度则显示 3s ----
if (!g_tempAvail) {
g_mode = MODE_TIME;
g_lastSwitchMs = now; // 温度就绪后从“现在”重新计 10s
} else {
if (g_mode == MODE_TIME && now - g_lastSwitchMs >= SWITCH_MS) {
g_mode = MODE_TEMP; g_tempEnterMs = now;
} else if (g_mode == MODE_TEMP && now - g_tempEnterMs >= TEMP_SHOW_MS) {
g_mode = MODE_TIME; g_lastSwitchMs = now;
}
}
if (g_mode == MODE_TIME) {
buildTime(seg, hh, mm, true);
bool colon = ((now / COLON_BLINK_MS) % 2) == 0; // 1Hz 闪烁 = 秒指示
render(seg, colon);
} else {
buildTemp(seg, g_tempC);
render(seg, false); // 温度:不显示冒号
}
// ---- 板载 WS2812 心跳灯(IO48):每秒闪一下暗绿 = 固件活着 ----
// 用途:不开串口时判断固件是否在跑——灯闪 = 固件正常(显示问题在数码管/供电);
// 灯完全不闪 = 固件没跑(复位/卡死/供电不足)。确认无碍后可删掉这段。
{
static unsigned long lastBlinkMs = 0;
static bool ledOn = false;
if (now - lastBlinkMs >= 1000UL) {
lastBlinkMs = now;
ledOn = !ledOn;
neopixelWrite(48, 0, ledOn ? 40 : 0, 0); // 暗绿,每秒闪一下;确认无碍后可删
}
}
delay(100);
}