一个每天都要看的设备,却在断电后丢失时间、遇到夏令时还得手动调钟,这不是复杂问题,而是产品没有照顾真实使用场景。当市售闹钟无法同时满足自动对时、红色七段数码管、无强制 App 等要求时,自制硬件就不只是兴趣项目,也是一种明确的工程选择。
更有意思的是,这类设备可以沿用软件服务的交付方式:代码提交后自动编译、测试并生成固件,再由局域网内的部署节点完成 OTA 更新。床头闹钟由此变成了一台有版本、有日志、可回滚的小型联网设备。
需求先于硬件选型
这个项目的关键并不是往闹钟里塞入尽可能多的功能,而是把边界定清楚:
- 使用红色七段数码管,夜间可读,同时避免高亮白光或蓝光干扰。
- 通过 NTP 自动对时,断电重启后无需重新按键设置。
- 使用时区规则自动处理夏令时,而不是在固件里写死 UTC 偏移。
- 本地完成主要操作,不依赖厂商账号或手机 App。
- 支持无线升级,但不能因为升级失败而让第二天的闹钟失效。
ESP32 很适合这组约束:它自带 Wi-Fi,Arduino 和 ESP-IDF 均支持 NTP、时区配置与 OTA;显示端则可以选用 MAX7219、TM1637 等成熟驱动芯片。红色显示不是软件滤镜,而应直接选择红色 LED 数码管,并通过亮度寄存器或环境光传感器控制夜间亮度。
自动对时不等于写死一个 UTC 偏移
仅仅从 NTP 服务器取得 Unix 时间还不够。真正麻烦的是本地时间规则,例如夏令时开始和结束的日期。可以这样实践:在 ESP32 Arduino 固件中配置 POSIX 时区字符串,让标准库负责转换。
下面的示例连接 Wi-Fi、同步时间,并每秒输出本地时间。运行前需要把 Wi-Fi 信息以及 TZ_RULE 改成设备所在地的配置。
#include <Arduino.h>
#include <WiFi.h>
#include <time.h>
const char* WIFI_SSID = "your-wifi";
const char* WIFI_PASSWORD = "your-password";
// 示例:美国东部时间。中国大陆可使用 "CST-8"。
const char* TZ_RULE = "EST5EDT,M3.2.0/2,M11.1.0/2";
void setup() {
Serial.begin(115200);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(250);
}
configTzTime(TZ_RULE, "pool.ntp.org", "time.cloudflare.com");
}
void loop() {
struct tm localTime;
if (getLocalTime(&localTime, 2000)) {
char value[9];
strftime(value, sizeof(value), "%H:%M:%S", &localTime);
Serial.println(value);
// 将 value 中的时、分写入七段数码管驱动。
} else {
Serial.println("Time sync unavailable");
}
delay(1000);
}
实际设备还应把最近一次可信时间保存在 RTC 或持久化存储中。网络不可用时,闹钟应继续走时和触发,而不是停留在等待 Wi-Fi 的启动界面。NTP 是校准来源,不应该成为报时功能的单点故障。
给闹钟接入 CI/CD
CI/CD 对嵌入式设备的直接价值是可重复构建。开发者不必依赖某台笔记本上碰巧可用的工具链,每次提交都能得到带版本号的固件产物。
可以使用 PlatformIO 管理 ESP32 工程:
; platformio.ini
[env:clock]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
build_flags =
-D FIRMWARE_VERSION=\"${sysenv.GITHUB_SHA}\"
下面是一条可改造的 GitHub Actions 流水线。它先构建固件并保存产物;只有推送到 main 分支时,才通过局域网内的自托管 Runner 执行 OTA。CLOCK_IP 和 OTA_PASSWORD 应配置为仓库 Secret。
name: Clock firmware
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install platformio
- run: pio run -e clock
- uses: actions/upload-artifact@v4
with:
name: clock-firmware
path: .pio/build/clock/firmware.bin
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: build
runs-on: [self-hosted, clock-lan]
environment: bedroom-clock
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install platformio
- run: pio run -e clock
- name: OTA update
env:
CLOCK_IP: ${{ secrets.CLOCK_IP }}
OTA_PASSWORD: ${{ secrets.OTA_PASSWORD }}
run: pio run -e clock -t upload --upload-port "$CLOCK_IP" --upload-flags="--auth=$OTA_PASSWORD"
要让这段配置工作,还需要在 PlatformIO 环境中加入 upload_protocol = espota,并在固件中启用 ArduinoOTA。自托管 Runner 必须能够访问闹钟所在的局域网;云端 Runner 通常无法直接连接家庭内网设备。
床头设备的部署规则要比普通 Demo 更保守
可以远程更新,不代表应该随时自动更新。闹钟属于低复杂度但高信任设备,升级策略至少应满足以下条件:
- 构建和部署分离,拉取请求只能构建,不能触达实体设备。
- 部署任务使用受保护的 Environment,必要时增加人工批准。
- OTA 分区支持回滚,新固件启动失败后恢复上一版本。
- 固件升级不能清除闹钟时间、亮度、时区等用户配置。
- 网络断开时保留本地按键、显示和响铃能力。
- 不把 Wi-Fi 密码、OTA 密码或家庭网络地址提交到仓库。
还应保留一个物理恢复入口,例如长按按键进入配置模式,或者通过 USB 串口重新刷写。无线部署是便利通道,不能成为唯一维护通道。
一个值得复制的产品思路
这类项目真正值得借鉴的不是“给闹钟加上 DevOps”这个噱头,而是从一个长期存在的小摩擦出发,建立完整且克制的工程闭环:硬件满足视觉和操作要求,时间规则交给可靠的系统能力,固件通过自动化流水线交付,同时为断网、断电和升级失败保留退路。
开始实施时,可以先完成红色显示、RTC 和本地闹钟,再接入 NTP;等离线行为稳定后,才加入 OTA 和部署流水线。对于每天负责叫醒人的设备,可靠性永远应该排在功能数量之前。