1. 项目概述与设计思路几年前我还在用那种老式的数码管时钟走时不准不说功能也单一。后来接触到Adafruit的RGB矩阵和CircuitPython一个想法就冒出来了能不能自己做一个既好看又好玩的智能时钟它得能自动联网对时摆脱手动调校的麻烦要有闹钟但闹铃不能太刺耳最好能自定义显示效果要酷能换颜色操作还得简单直观拧一拧、按一按就能搞定所有设置。这个基于CircuitPython的智能RGB矩阵时钟项目就是这些想法的落地。它本质上是一个集成了网络时间同步、可编程闹钟、全彩视觉反馈和物理交互的嵌入式系统非常适合作为学习嵌入式开发、状态机设计和人机交互的练手项目成品也是一个极具个性的桌面摆件。整个系统的核心设计思路是“分层解耦”和“事件驱动”。硬件层负责最底层的输入输出两个RGB矩阵负责显示旋转编码器负责输入WiFi模块负责联网I2S音频模块负责发声。软件层则用一个主循环while True来轮询所有事件——按钮是否被按下、编码器是否被旋转、定时器是否到期。最关键的是State类它像一个中央指挥部集中管理所有状态当前时间、闹钟时间、显示模式、颜色、各种定时器的倒计时等等。这种设计让代码逻辑非常清晰添加新功能比如增加一个温度显示也不会牵一发而动全身只需要在状态类里加个变量在主循环里加个判断就行。下面我们就从硬件选型开始一步步拆解这个项目的实现。2. 核心硬件选型与电路解析做硬件项目选对核心部件就成功了一半。这个项目的主控我选择了Adafruit的QT Py RP2040。为什么是它首先RP2040芯片性能足够双核ARM Cortex-M0运行CircuitPython绰绰有余还有充足的GPIO和硬件I2C、I2S接口。其次QT Py板型小巧自带STEMMA QT连接器通过防反插的JST SH电缆就能连接各种传感器和屏幕省去了焊接杜邦线的麻烦特别适合快速原型开发。显示部分是两个11x7的RGB LED矩阵屏Adafruit RGB Matrix QT。这种屏幕每个像素都是一个可独立寻址的RGB LED能显示丰富的色彩和简单的图形。我用了两块屏拼接就能得到一个22x7的显示区域足够显示时间“HH:MM”和做一些动画。这里有个关键点I2C地址冲突。两块一模一样的屏幕默认I2C地址都是0x30直接连上总线肯定会冲突。解决方法就在屏幕背面的一个小焊盘上。你需要用美工刀小心地割断标有“0x30”的焊盘连接然后用焊锡桥接旁边的“0x31”焊盘这样第二块屏幕的地址就改成了0x31。这个操作需要一点耐心和稳手算是硬件组装里的第一个小挑战。交互的核心是一个集成了旋转编码器和按键的STEMMA QT模块。旋转编码器用来无级调节比如循环切换颜色、设置时间按键则用于模式切换和确认。编码器通过I2C与一颗seesaw协处理器芯片通信这芯片帮我们处理了编码器脉冲去抖和按键检测的底层细节让主控代码只需关心“位置变化了多少”和“按了多久”这些高级事件大大简化了编程。音频播放用的是Adafruit的I2S Amplifier BFF贴片式扩展板。它直接插在QT Py背面通过I2S总线接收数字音频数据驱动一个8欧姆的小喇叭。我试过几种音频格式最后发现16-bit, 22.05 kHz的单声道WAV文件兼容性最好文件体积也适中。你可以把自己喜欢的铃声比如一段轻音乐或自然音效转换成这个格式重命名为.wav后直接拷贝到CircuitPython设备的根目录代码启动时会自动扫描加载。最后是“骨架”和“皮肤”——3D打印的外壳。设计上要考虑几点一是给屏幕留出透光孔我用了网格状的盖板来柔化LED的点状光让显示效果更均匀二是要预留喇叭的出音孔三是要把旋转编码器的旋钮和USB-C电源接口露出来。装配时用M2.5和M3的螺丝将各块板子固定到外壳内部的支柱上整个过程像拼乐高一样非常有成就感。硬件连接的总线拓扑很简单QT Py作为I2C主机通过一条STEMMA QT线连接第一块矩阵屏和编码器模块第一块矩阵屏再用另一条线级联到第二块屏。I2S音频板则直接插在QT Py背面。3. 软件架构与核心代码实现硬件搭好了接下来就是让它们“活”起来的软件部分。整个项目的代码结构围绕一个主事件循环展开但在这之前有大量的初始化工作和核心类需要构建。3.1 网络时间同步让时钟自己“对表”一个时钟准是第一位。我们利用NTP网络时间协议通过WiFi获取精确时间。在CircuitPython中这变得异常简单。首先你需要将WiFi的SSID和密码以环境变量的形式存储在settings.toml文件里这样代码可以安全地读取而无需硬编码敏感信息。# settings.toml 文件内容 CIRCUITPY_WIFI_SSID “你的WiFi名称” CIRCUITPY_WIFI_PASSWORD “你的WiFi密码”在代码中连接和同步过程如下import wifi import socketpool import ssl import adafruit_ntp import os # 连接到WiFi wifi.radio.connect(os.getenv(“CIRCUITPY_WIFI_SSID”), os.getenv(“CIRCUITPY_WIFI_PASSWORD”)) print(f“已连接到 {os.getenv(‘CIRCUITPY_WIFI_SSID’)}”) # 创建NTP客户端 pool socketpool.SocketPool(wifi.radio) ntp adafruit_ntp.NTP(pool, tz_offset8, cache_seconds3600) # 东八区缓存1小时 def sync_time(): try: # 从NTP服务器获取时间 current_time ntp.datetime # 这里将current_time分解为年、月、日、时、分、秒并设置到RTC或状态变量中 # ... return True except Exception as e: print(“时间同步失败:”, e) return False这里有个关键细节cache_seconds参数。它告诉NTP客户端在成功获取一次时间后多久之内不再向服务器发起请求。我设置为3600秒1小时既保证了长期准确性每天最多误差几秒可通过定期同步修正又避免了对NTP服务器造成不必要的频繁请求。同步失败的处理也很重要代码里加了try...except一旦失败会等待10秒后重启单片机这是一种简单粗暴但有效的恢复策略。3.2 状态机设计系统的“大脑”这是整个项目的软件核心。与其让一堆全局变量散落在代码各处不如用一个State类把它们管起来。这个类记录了系统在任何时刻的样子。class State: def __init__(self): # 显示与交互状态 self.color_value 0 # 颜色盘角度0-255 self.color colorwheel(0) # 当前RGB颜色 self.set_alarm 0 # 0:正常1:设置小时2:设置分钟 self.active_alarm False # 闹铃是否正在响 self.showing_status False # 是否正在显示“ON/OFF”状态 # 时间相关 self.am_pm_hour 0 # 24小时制的小时数 self.mins 0 self.seconds 0 self.time_str “00:00” self.is_pm False # 当前是否为下午12小时制下 # 闹钟相关 self.alarm_str f“{alarm_hour:02}:{alarm_min:02}” # 闹钟时间字符串 self.alarm_is_pm False # 闹钟时间是否为下午 self.alarm_start_time 0 # 闹铃开始响的时刻毫秒时间戳 # 显示效果控制 self.scroll_offset 0 # 滚动文字的偏移量 self.blink_state True # 设置时间时数字的闪烁状态True为显示 self.current_brightness BRIGHTNESS_DAY # 当前亮度 # 定时器非阻塞式延迟的关键 self.refresh_timer Timer(3600000) # 1小时同步一次时间 self.clock_timer Timer(1000) # 1秒更新一次时间 self.wink_timer Timer(30000) # 30秒眨眼一次 self.blink_timer Timer(500) # 500毫秒用于设置模式下的数字闪烁 self.scroll_timer Timer(80) # 80毫秒控制滚动速度 self.alarm_status_timer Timer(100) # 100毫秒控制状态文字滚动这个Timer类是我自己实现的一个简易非阻塞定时器。在while True循环里你不能用time.sleep()那会卡住整个系统。Timer的原理是检查自上次触发后是否已经过了设定的时间间隔。class Timer: def __init__(self, interval_ms): self.interval interval_ms self.last_check ticks_ms() # CircuitPython的毫秒计时器 def check(self): now ticks_ms() if ticks_diff(now, self.last_check) self.interval: self.last_check now return True return False def reset(self): self.last_check ticks_ms()有了状态机和定时器主循环的逻辑就非常清晰了检查各个定时器是否到期检查按钮和编码器是否有动作然后根据当前状态state.set_alarm,state.active_alarm等执行相应的更新。这种状态驱动的编程模式是编写复杂嵌入式系统交互逻辑的利器。3.3 显示驱动与动画效果在5x7的像素点上显示数字和简单动画需要自己定义字模。我定义了一个简单的5x7字体字典以及睁眼、闭眼两套图案。# 自定义5x7字体只包含数字和冒号 FONT { ‘0’: [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110], ‘1’: [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], # ... 其他数字和冒号定义 ‘:’: [0b00000, 0b00100, 0b00000, 0b00000, 0b00000, 0b00100, 0b00000] } EYE_OPEN [0b10101, 0b01110, 0b10001, 0b10101, 0b10001, 0b01110, 0b00000] EYE_CLOSED [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000]Display类封装了所有和屏幕打交道的操作。它的核心方法是draw_time负责将时间字符串如“12:34”绘制到两个矩阵屏上。计算每个字符的起始X坐标然后遍历字模的每一行Y方向根据比特位是1还是0来设置对应像素的颜色。为了做出“眨眼”动画我实现了一个wink_animation方法先画睁眼延迟一小会儿再画闭眼再延迟最后恢复睁眼并重画时间。所有的动画都依靠state.wink_timer.check()这类定时器来触发保证主循环不被阻塞。滚动显示“WAKE UP”或“ON/OFF”的原理也类似。state.scroll_offset变量从0开始逐渐增加每次绘制时根据这个偏移量来决定显示字符串的哪一部分。当偏移量超过字符串总像素宽度加上屏幕宽度时就重置为0完成一次滚动循环。3.4 交互逻辑按钮与编码器交互设计追求直觉。我定义了三种主要的用户操作长按按钮约1秒在正常模式下进入闹钟设置先设置小时在闹铃响起时关闭闹铃。短按按钮一次在设置模式下循环切换“设置小时” - “设置分钟” - “退出设置”。快速短按三次开关闹钟功能屏幕上会滚动显示“ON”或“OFF”。旋转编码器在正常模式下循环改变时钟颜色HSV色彩盘在设置小时或分钟模式下增减对应的数值。按钮检测用到了adafruit_debouncer库它能有效消除机械按键的抖动并区分短按和长按。编码器的位置变化通过seesaw芯片读取主循环中比较当前位置和上次位置得到变化量delta然后根据state.set_alarm的值决定这个变化量是应用于颜色值、闹钟小时还是闹钟分钟。# 在主循环中处理编码器 position -encoder.position # 可能需要根据安装方向取反 if position ! last_position: delta 1 if position last_position else -1 if state.set_alarm 0: # 改变颜色 state.color_value (state.color_value delta * 5) % 255 state.color colorwheel(state.color_value) display.draw_time(state.time_str, state.color, state.is_pm) elif state.set_alarm 1: # 改变小时 alarm_hour (alarm_hour delta) % 24 # ... 更新显示 elif state.set_alarm 2: # 改变分钟 alarm_min (alarm_min delta) % 60 # ... 更新显示 last_position position这里有个细节颜色变化步长我设为5delta * 5这样旋转一下就有明显的颜色跳跃感如果设为1变化会过于细腻调节起来太慢。而时间设置的步长就是1符合直觉。3.5 音频播放与闹铃触发闹铃响起时系统需要播放音频。CircuitPython的audiomixer模块允许我们混音和循环播放。初始化时我们扫描根目录下所有的.wav文件形成一个列表。import audiobusio import audiomixer import audiocore import os import random audio audiobusio.I2SOut(board.D9, board.D10, board.D11) # BCLK, LRCLK, DATA引脚 wav_files [“/”f for f in os.listdir(‘/’) if f.lower().endswith(‘.wav’)] mixer audiomixer.Mixer(voice_count1, sample_rate22050, channel_count1, bits_per_sample16, samples_signedTrue) mixer.voice[0].level alarm_volume # 全局音量控制 audio.play(mixer) # 开始播放混音器静音状态 def open_audio(): 随机打开一个WAV文件 if wav_files: filename random.choice(wav_files) return audiocore.WaveFile(open(filename, “rb”)) return None当系统时间与设定的闹钟时间匹配且闹钟未关闭no_alarm_plz为False时触发闹铃if f“{state.am_pm_hour:02}:{state.mins:02}” state.alarm_str and not no_alarm_plz: print(“ALARM!”) wave open_audio() if wave: mixer.voice[0].play(wave, loopTrue) # 循环播放 state.active_alarm True state.alarm_start_time ticks_ms() state.scroll_offset 0 # 开始滚动“WAKE UP”我特意加了一个自动静音功能如果闹铃响了一分钟60000毫秒还没被手动关闭就自动停止。这是为了防止你出门后它一直响个不停的尴尬情况。实现方式就是在主循环里检查state.active_alarm为真时计算当前时间与state.alarm_start_time的差值。4. 系统主循环与状态流转所有模块准备就绪后就由主循环while True来调度。这个循环必须足够快才能让交互感觉灵敏动画流畅。它的基本结构是一个大的状态判断树while True: # 1. 更新输入设备状态 button.update() # 检查编码器位置变化... # 2. 处理按钮事件可能改变state.set_alarm等状态 if button.long_press: # ... 处理长按 if button.short_count 1: # ... 处理短按 # ... 其他按钮逻辑 # 3. 根据当前状态更新显示和逻辑 if state.showing_status: # 处理“ON/OFF”状态滚动显示 pass elif state.active_alarm: # 处理闹铃响起的逻辑滚动“WAKE UP”检查超时 pass elif state.set_alarm 0: # 处理设置模式下的数字闪烁 if state.blink_timer.check(): state.blink_state not state.blink_state # 根据blink_state决定显示或清除设置位 else: # 正常时钟模式 # 检查是否该眨眼了 if state.wink_timer.check(): display.wink_animation(state.color) # 4. 时间维护与闹钟检查 if state.refresh_timer.check(): # 每小时同步一次 sync_time() if state.clock_timer.check(): # 每秒更新一次 state.seconds 1 # ... 进位处理更新state.time_str # 检查是否到达闹钟时间 # ... # 5. 根据最终状态刷新显示 if not state.active_alarm and not state.showing_status and state.set_alarm 0: display.draw_time(state.time_str, state.color, state.is_pm) # 一个很小的延时避免CPU跑满非必须但有益 # time.sleep(0.01)这个循环的精髓在于优先级。按钮事件用户主动交互的检测和处理放在最前面因为它需要最高的响应度。然后是各种状态下的视觉反馈滚动、闪烁。最后才是后台的时间更新和闹钟检查。所有的绘制操作最终都汇聚到根据state里的标志位决定在屏幕show()之前画什么。5. 硬件组装与调试经验理论说得再多动手组装才是真正考验人的地方。这里分享几个我踩过坑才总结出来的经验。焊接与连接矩阵屏地址修改这是最容易出错的一步。割断0x30的跳线时一定要用锋利的刀头轻轻划一下即可用力过猛可能会损伤旁边的焊盘或走线。然后用尖头烙铁和少量焊锡快速地点一下0x31的两个焊盘让它们连接起来。完成后务必用万用表通断档检查一下0x30地址的两个焊盘之间应该是断开的0x31地址的两个焊盘之间应该是导通的。STEMMA QT连接虽然防反插但也要确认插到底听到轻微的“咔哒”声。线材不要过度弯折尤其是靠近接头的地方。喇叭焊接注意正负极。通常喇叭线材红色为正黑色为负。焊接到I2S Amplifier BFF板上标有“”和“-”的焊盘。焊接时间不宜过长以免烫坏喇叭音圈。3D打印与装配外壳打印建议使用PLA材料层高0.2mm填充率20%即可。打印时确保有支撑特别是内部用于固定PCB的支柱部分。打印完成后仔细清除所有支撑料和毛边特别是螺丝孔内的残留否则螺丝可能拧不进去。螺丝规格准备M2.5x5mm螺丝用于固定RGB矩阵屏和旋转编码器板准备M3x5mm螺丝用于固定QT Py主板。螺丝长度宁短勿长长了可能会顶到屏幕或元件导致短路。装配顺序我推荐的顺序是1) 将两个矩阵屏用螺丝固定在前壳内2) 将网格盖板压入前壳3) 将QT Py已插好音频板安装到中间的支架上4) 连接所有STEMMA QT线缆和喇叭线5) 将旋转编码器板安装在后壳内旋钮穿过孔洞6) 将喇叭放入后壳的卡槽7)最后将前后壳对准均匀用力扣合。这个顺序可以避免线缆在狭窄空间内纠缠。上电与调试首次上电用USB-C线连接电脑和QT Py。电脑应该识别出一个名为CIRCUITPY的U盘。如果没有可能需要先给QT Py刷入CircuitPython固件。文件部署将写好的code.py、settings.toml以及你的.wav铃声文件全部拷贝到CIRCUITPY磁盘的根目录。CircuitPython会自动运行code.py。串口监视器打开Mu Editor或VS Code的串口监视器设置波特率为115200。这是你最重要的调试工具。代码里的print语句输出都会在这里显示。你应该能看到WiFi连接成功、NTP时间同步成功等信息。常见问题排查屏幕不亮首先检查I2C地址。在串口监视器里可以写一段简单的I2C扫描代码看看是否能找到0x30和0x31两个设备。如果只找到一个说明地址修改可能失败了。WiFi连不上检查settings.toml文件格式是否正确TOML格式很严格不能有多余的空格或引号错误检查SSID和密码是否正确检查路由器是否设置了MAC地址过滤。时间不对检查tz_offset参数是否正确东八区是8。检查NTP服务器是否可访问有时需要等一会儿。按钮/编码器无反应检查STEMMA QT线是否插反或没插紧。检查seesaw的I2C地址是否是0x36大部分模块默认是这个。没有声音检查喇叭线是否焊牢。检查WAV文件格式是否为16-bit, 22.05 kHz, 单声道。检查代码中I2S的引脚定义BCLK,LRCLK,DATA是否与你的QT Py板型匹配。6. 功能扩展与优化思路这个项目的基础框架非常稳固留下了很多可以发挥和扩展的空间。这里提供几个我实践过或构思过的方向1. 增加环境感知与自动调节光线传感器添加一个APDS-9960或TLS2591光线传感器通过I2C连接。在State类里增加一个ambient_light变量在主循环中定期读取。然后可以根据环境光强度动态调节BRIGHTNESS_DAY和BRIGHTNESS_NIGHT的阈值甚至实现无级亮度调节让屏幕在黑暗环境中不刺眼在明亮环境中看得清。温湿度传感器比如SHT40或DHT22。可以设定在整点或通过某种触发方式比如快速按两下按钮在时钟屏幕上滚动显示当前的温度和湿度。2. 增强闹钟与提醒功能多组闹钟将alarm_hour和alarm_min从一个变量改为一个列表或字典用来存储多组闹钟时间。通过编码器和按钮进入一个“闹钟列表”菜单进行管理。工作日与周末模式在状态类里增加一个alarm_days的位标志例如周一到周五对应一个闹钟周末对应另一个闹钟。需要实现一个简单的日历逻辑或者通过网络获取星期信息。渐进式闹铃闹铃音量可以从小逐渐变大或者灯光从暗逐渐变亮实现更温和的叫醒。3. 网络服务集成获取天气信息利用WiFi连接通过Requests库向免费的天气API如OpenWeatherMap发送请求解析返回的JSON数据。可以在屏幕的第二行显示一个简单的天气图标晴、雨、云等和温度。Web配置界面利用CircuitPython的adafruit_httpserver库让时钟自己成为一个WiFi热点或者局域网内的Web服务器。你可以在手机或电脑的浏览器上输入它的IP地址打开一个网页来设置WiFi密码、闹钟时间、选择颜色主题等比用旋转编码器一个个设置要方便得多。4. 显示效果升级更多动画除了眨眼可以设计更多小动画比如整点报时时的庆祝动画、连接WiFi时的搜索动画。颜色主题不仅仅是彩虹循环可以预设几套配色方案经典红、冷光蓝、暖光黄通过编码器切换。低功耗模式如果使用电池供电可以在检测到长时间无操作后自动调暗屏幕甚至关闭显示通过晃动或按下按钮唤醒。扩展时牢记状态机架构的优势。增加一个新功能通常的步骤是1) 在State类中添加相关的状态变量2) 在初始化部分配置好对应的硬件3) 在主循环的相应位置如定时器检查、事件判断后添加对新状态的处理逻辑4) 在显示模块中增加新的绘制函数。只要遵循这个模式代码就能保持整洁和可维护性。
基于CircuitPython的智能RGB矩阵时钟:从硬件选型到状态机设计的完整实现
1. 项目概述与设计思路几年前我还在用那种老式的数码管时钟走时不准不说功能也单一。后来接触到Adafruit的RGB矩阵和CircuitPython一个想法就冒出来了能不能自己做一个既好看又好玩的智能时钟它得能自动联网对时摆脱手动调校的麻烦要有闹钟但闹铃不能太刺耳最好能自定义显示效果要酷能换颜色操作还得简单直观拧一拧、按一按就能搞定所有设置。这个基于CircuitPython的智能RGB矩阵时钟项目就是这些想法的落地。它本质上是一个集成了网络时间同步、可编程闹钟、全彩视觉反馈和物理交互的嵌入式系统非常适合作为学习嵌入式开发、状态机设计和人机交互的练手项目成品也是一个极具个性的桌面摆件。整个系统的核心设计思路是“分层解耦”和“事件驱动”。硬件层负责最底层的输入输出两个RGB矩阵负责显示旋转编码器负责输入WiFi模块负责联网I2S音频模块负责发声。软件层则用一个主循环while True来轮询所有事件——按钮是否被按下、编码器是否被旋转、定时器是否到期。最关键的是State类它像一个中央指挥部集中管理所有状态当前时间、闹钟时间、显示模式、颜色、各种定时器的倒计时等等。这种设计让代码逻辑非常清晰添加新功能比如增加一个温度显示也不会牵一发而动全身只需要在状态类里加个变量在主循环里加个判断就行。下面我们就从硬件选型开始一步步拆解这个项目的实现。2. 核心硬件选型与电路解析做硬件项目选对核心部件就成功了一半。这个项目的主控我选择了Adafruit的QT Py RP2040。为什么是它首先RP2040芯片性能足够双核ARM Cortex-M0运行CircuitPython绰绰有余还有充足的GPIO和硬件I2C、I2S接口。其次QT Py板型小巧自带STEMMA QT连接器通过防反插的JST SH电缆就能连接各种传感器和屏幕省去了焊接杜邦线的麻烦特别适合快速原型开发。显示部分是两个11x7的RGB LED矩阵屏Adafruit RGB Matrix QT。这种屏幕每个像素都是一个可独立寻址的RGB LED能显示丰富的色彩和简单的图形。我用了两块屏拼接就能得到一个22x7的显示区域足够显示时间“HH:MM”和做一些动画。这里有个关键点I2C地址冲突。两块一模一样的屏幕默认I2C地址都是0x30直接连上总线肯定会冲突。解决方法就在屏幕背面的一个小焊盘上。你需要用美工刀小心地割断标有“0x30”的焊盘连接然后用焊锡桥接旁边的“0x31”焊盘这样第二块屏幕的地址就改成了0x31。这个操作需要一点耐心和稳手算是硬件组装里的第一个小挑战。交互的核心是一个集成了旋转编码器和按键的STEMMA QT模块。旋转编码器用来无级调节比如循环切换颜色、设置时间按键则用于模式切换和确认。编码器通过I2C与一颗seesaw协处理器芯片通信这芯片帮我们处理了编码器脉冲去抖和按键检测的底层细节让主控代码只需关心“位置变化了多少”和“按了多久”这些高级事件大大简化了编程。音频播放用的是Adafruit的I2S Amplifier BFF贴片式扩展板。它直接插在QT Py背面通过I2S总线接收数字音频数据驱动一个8欧姆的小喇叭。我试过几种音频格式最后发现16-bit, 22.05 kHz的单声道WAV文件兼容性最好文件体积也适中。你可以把自己喜欢的铃声比如一段轻音乐或自然音效转换成这个格式重命名为.wav后直接拷贝到CircuitPython设备的根目录代码启动时会自动扫描加载。最后是“骨架”和“皮肤”——3D打印的外壳。设计上要考虑几点一是给屏幕留出透光孔我用了网格状的盖板来柔化LED的点状光让显示效果更均匀二是要预留喇叭的出音孔三是要把旋转编码器的旋钮和USB-C电源接口露出来。装配时用M2.5和M3的螺丝将各块板子固定到外壳内部的支柱上整个过程像拼乐高一样非常有成就感。硬件连接的总线拓扑很简单QT Py作为I2C主机通过一条STEMMA QT线连接第一块矩阵屏和编码器模块第一块矩阵屏再用另一条线级联到第二块屏。I2S音频板则直接插在QT Py背面。3. 软件架构与核心代码实现硬件搭好了接下来就是让它们“活”起来的软件部分。整个项目的代码结构围绕一个主事件循环展开但在这之前有大量的初始化工作和核心类需要构建。3.1 网络时间同步让时钟自己“对表”一个时钟准是第一位。我们利用NTP网络时间协议通过WiFi获取精确时间。在CircuitPython中这变得异常简单。首先你需要将WiFi的SSID和密码以环境变量的形式存储在settings.toml文件里这样代码可以安全地读取而无需硬编码敏感信息。# settings.toml 文件内容 CIRCUITPY_WIFI_SSID “你的WiFi名称” CIRCUITPY_WIFI_PASSWORD “你的WiFi密码”在代码中连接和同步过程如下import wifi import socketpool import ssl import adafruit_ntp import os # 连接到WiFi wifi.radio.connect(os.getenv(“CIRCUITPY_WIFI_SSID”), os.getenv(“CIRCUITPY_WIFI_PASSWORD”)) print(f“已连接到 {os.getenv(‘CIRCUITPY_WIFI_SSID’)}”) # 创建NTP客户端 pool socketpool.SocketPool(wifi.radio) ntp adafruit_ntp.NTP(pool, tz_offset8, cache_seconds3600) # 东八区缓存1小时 def sync_time(): try: # 从NTP服务器获取时间 current_time ntp.datetime # 这里将current_time分解为年、月、日、时、分、秒并设置到RTC或状态变量中 # ... return True except Exception as e: print(“时间同步失败:”, e) return False这里有个关键细节cache_seconds参数。它告诉NTP客户端在成功获取一次时间后多久之内不再向服务器发起请求。我设置为3600秒1小时既保证了长期准确性每天最多误差几秒可通过定期同步修正又避免了对NTP服务器造成不必要的频繁请求。同步失败的处理也很重要代码里加了try...except一旦失败会等待10秒后重启单片机这是一种简单粗暴但有效的恢复策略。3.2 状态机设计系统的“大脑”这是整个项目的软件核心。与其让一堆全局变量散落在代码各处不如用一个State类把它们管起来。这个类记录了系统在任何时刻的样子。class State: def __init__(self): # 显示与交互状态 self.color_value 0 # 颜色盘角度0-255 self.color colorwheel(0) # 当前RGB颜色 self.set_alarm 0 # 0:正常1:设置小时2:设置分钟 self.active_alarm False # 闹铃是否正在响 self.showing_status False # 是否正在显示“ON/OFF”状态 # 时间相关 self.am_pm_hour 0 # 24小时制的小时数 self.mins 0 self.seconds 0 self.time_str “00:00” self.is_pm False # 当前是否为下午12小时制下 # 闹钟相关 self.alarm_str f“{alarm_hour:02}:{alarm_min:02}” # 闹钟时间字符串 self.alarm_is_pm False # 闹钟时间是否为下午 self.alarm_start_time 0 # 闹铃开始响的时刻毫秒时间戳 # 显示效果控制 self.scroll_offset 0 # 滚动文字的偏移量 self.blink_state True # 设置时间时数字的闪烁状态True为显示 self.current_brightness BRIGHTNESS_DAY # 当前亮度 # 定时器非阻塞式延迟的关键 self.refresh_timer Timer(3600000) # 1小时同步一次时间 self.clock_timer Timer(1000) # 1秒更新一次时间 self.wink_timer Timer(30000) # 30秒眨眼一次 self.blink_timer Timer(500) # 500毫秒用于设置模式下的数字闪烁 self.scroll_timer Timer(80) # 80毫秒控制滚动速度 self.alarm_status_timer Timer(100) # 100毫秒控制状态文字滚动这个Timer类是我自己实现的一个简易非阻塞定时器。在while True循环里你不能用time.sleep()那会卡住整个系统。Timer的原理是检查自上次触发后是否已经过了设定的时间间隔。class Timer: def __init__(self, interval_ms): self.interval interval_ms self.last_check ticks_ms() # CircuitPython的毫秒计时器 def check(self): now ticks_ms() if ticks_diff(now, self.last_check) self.interval: self.last_check now return True return False def reset(self): self.last_check ticks_ms()有了状态机和定时器主循环的逻辑就非常清晰了检查各个定时器是否到期检查按钮和编码器是否有动作然后根据当前状态state.set_alarm,state.active_alarm等执行相应的更新。这种状态驱动的编程模式是编写复杂嵌入式系统交互逻辑的利器。3.3 显示驱动与动画效果在5x7的像素点上显示数字和简单动画需要自己定义字模。我定义了一个简单的5x7字体字典以及睁眼、闭眼两套图案。# 自定义5x7字体只包含数字和冒号 FONT { ‘0’: [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110], ‘1’: [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], # ... 其他数字和冒号定义 ‘:’: [0b00000, 0b00100, 0b00000, 0b00000, 0b00000, 0b00100, 0b00000] } EYE_OPEN [0b10101, 0b01110, 0b10001, 0b10101, 0b10001, 0b01110, 0b00000] EYE_CLOSED [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000]Display类封装了所有和屏幕打交道的操作。它的核心方法是draw_time负责将时间字符串如“12:34”绘制到两个矩阵屏上。计算每个字符的起始X坐标然后遍历字模的每一行Y方向根据比特位是1还是0来设置对应像素的颜色。为了做出“眨眼”动画我实现了一个wink_animation方法先画睁眼延迟一小会儿再画闭眼再延迟最后恢复睁眼并重画时间。所有的动画都依靠state.wink_timer.check()这类定时器来触发保证主循环不被阻塞。滚动显示“WAKE UP”或“ON/OFF”的原理也类似。state.scroll_offset变量从0开始逐渐增加每次绘制时根据这个偏移量来决定显示字符串的哪一部分。当偏移量超过字符串总像素宽度加上屏幕宽度时就重置为0完成一次滚动循环。3.4 交互逻辑按钮与编码器交互设计追求直觉。我定义了三种主要的用户操作长按按钮约1秒在正常模式下进入闹钟设置先设置小时在闹铃响起时关闭闹铃。短按按钮一次在设置模式下循环切换“设置小时” - “设置分钟” - “退出设置”。快速短按三次开关闹钟功能屏幕上会滚动显示“ON”或“OFF”。旋转编码器在正常模式下循环改变时钟颜色HSV色彩盘在设置小时或分钟模式下增减对应的数值。按钮检测用到了adafruit_debouncer库它能有效消除机械按键的抖动并区分短按和长按。编码器的位置变化通过seesaw芯片读取主循环中比较当前位置和上次位置得到变化量delta然后根据state.set_alarm的值决定这个变化量是应用于颜色值、闹钟小时还是闹钟分钟。# 在主循环中处理编码器 position -encoder.position # 可能需要根据安装方向取反 if position ! last_position: delta 1 if position last_position else -1 if state.set_alarm 0: # 改变颜色 state.color_value (state.color_value delta * 5) % 255 state.color colorwheel(state.color_value) display.draw_time(state.time_str, state.color, state.is_pm) elif state.set_alarm 1: # 改变小时 alarm_hour (alarm_hour delta) % 24 # ... 更新显示 elif state.set_alarm 2: # 改变分钟 alarm_min (alarm_min delta) % 60 # ... 更新显示 last_position position这里有个细节颜色变化步长我设为5delta * 5这样旋转一下就有明显的颜色跳跃感如果设为1变化会过于细腻调节起来太慢。而时间设置的步长就是1符合直觉。3.5 音频播放与闹铃触发闹铃响起时系统需要播放音频。CircuitPython的audiomixer模块允许我们混音和循环播放。初始化时我们扫描根目录下所有的.wav文件形成一个列表。import audiobusio import audiomixer import audiocore import os import random audio audiobusio.I2SOut(board.D9, board.D10, board.D11) # BCLK, LRCLK, DATA引脚 wav_files [“/”f for f in os.listdir(‘/’) if f.lower().endswith(‘.wav’)] mixer audiomixer.Mixer(voice_count1, sample_rate22050, channel_count1, bits_per_sample16, samples_signedTrue) mixer.voice[0].level alarm_volume # 全局音量控制 audio.play(mixer) # 开始播放混音器静音状态 def open_audio(): 随机打开一个WAV文件 if wav_files: filename random.choice(wav_files) return audiocore.WaveFile(open(filename, “rb”)) return None当系统时间与设定的闹钟时间匹配且闹钟未关闭no_alarm_plz为False时触发闹铃if f“{state.am_pm_hour:02}:{state.mins:02}” state.alarm_str and not no_alarm_plz: print(“ALARM!”) wave open_audio() if wave: mixer.voice[0].play(wave, loopTrue) # 循环播放 state.active_alarm True state.alarm_start_time ticks_ms() state.scroll_offset 0 # 开始滚动“WAKE UP”我特意加了一个自动静音功能如果闹铃响了一分钟60000毫秒还没被手动关闭就自动停止。这是为了防止你出门后它一直响个不停的尴尬情况。实现方式就是在主循环里检查state.active_alarm为真时计算当前时间与state.alarm_start_time的差值。4. 系统主循环与状态流转所有模块准备就绪后就由主循环while True来调度。这个循环必须足够快才能让交互感觉灵敏动画流畅。它的基本结构是一个大的状态判断树while True: # 1. 更新输入设备状态 button.update() # 检查编码器位置变化... # 2. 处理按钮事件可能改变state.set_alarm等状态 if button.long_press: # ... 处理长按 if button.short_count 1: # ... 处理短按 # ... 其他按钮逻辑 # 3. 根据当前状态更新显示和逻辑 if state.showing_status: # 处理“ON/OFF”状态滚动显示 pass elif state.active_alarm: # 处理闹铃响起的逻辑滚动“WAKE UP”检查超时 pass elif state.set_alarm 0: # 处理设置模式下的数字闪烁 if state.blink_timer.check(): state.blink_state not state.blink_state # 根据blink_state决定显示或清除设置位 else: # 正常时钟模式 # 检查是否该眨眼了 if state.wink_timer.check(): display.wink_animation(state.color) # 4. 时间维护与闹钟检查 if state.refresh_timer.check(): # 每小时同步一次 sync_time() if state.clock_timer.check(): # 每秒更新一次 state.seconds 1 # ... 进位处理更新state.time_str # 检查是否到达闹钟时间 # ... # 5. 根据最终状态刷新显示 if not state.active_alarm and not state.showing_status and state.set_alarm 0: display.draw_time(state.time_str, state.color, state.is_pm) # 一个很小的延时避免CPU跑满非必须但有益 # time.sleep(0.01)这个循环的精髓在于优先级。按钮事件用户主动交互的检测和处理放在最前面因为它需要最高的响应度。然后是各种状态下的视觉反馈滚动、闪烁。最后才是后台的时间更新和闹钟检查。所有的绘制操作最终都汇聚到根据state里的标志位决定在屏幕show()之前画什么。5. 硬件组装与调试经验理论说得再多动手组装才是真正考验人的地方。这里分享几个我踩过坑才总结出来的经验。焊接与连接矩阵屏地址修改这是最容易出错的一步。割断0x30的跳线时一定要用锋利的刀头轻轻划一下即可用力过猛可能会损伤旁边的焊盘或走线。然后用尖头烙铁和少量焊锡快速地点一下0x31的两个焊盘让它们连接起来。完成后务必用万用表通断档检查一下0x30地址的两个焊盘之间应该是断开的0x31地址的两个焊盘之间应该是导通的。STEMMA QT连接虽然防反插但也要确认插到底听到轻微的“咔哒”声。线材不要过度弯折尤其是靠近接头的地方。喇叭焊接注意正负极。通常喇叭线材红色为正黑色为负。焊接到I2S Amplifier BFF板上标有“”和“-”的焊盘。焊接时间不宜过长以免烫坏喇叭音圈。3D打印与装配外壳打印建议使用PLA材料层高0.2mm填充率20%即可。打印时确保有支撑特别是内部用于固定PCB的支柱部分。打印完成后仔细清除所有支撑料和毛边特别是螺丝孔内的残留否则螺丝可能拧不进去。螺丝规格准备M2.5x5mm螺丝用于固定RGB矩阵屏和旋转编码器板准备M3x5mm螺丝用于固定QT Py主板。螺丝长度宁短勿长长了可能会顶到屏幕或元件导致短路。装配顺序我推荐的顺序是1) 将两个矩阵屏用螺丝固定在前壳内2) 将网格盖板压入前壳3) 将QT Py已插好音频板安装到中间的支架上4) 连接所有STEMMA QT线缆和喇叭线5) 将旋转编码器板安装在后壳内旋钮穿过孔洞6) 将喇叭放入后壳的卡槽7)最后将前后壳对准均匀用力扣合。这个顺序可以避免线缆在狭窄空间内纠缠。上电与调试首次上电用USB-C线连接电脑和QT Py。电脑应该识别出一个名为CIRCUITPY的U盘。如果没有可能需要先给QT Py刷入CircuitPython固件。文件部署将写好的code.py、settings.toml以及你的.wav铃声文件全部拷贝到CIRCUITPY磁盘的根目录。CircuitPython会自动运行code.py。串口监视器打开Mu Editor或VS Code的串口监视器设置波特率为115200。这是你最重要的调试工具。代码里的print语句输出都会在这里显示。你应该能看到WiFi连接成功、NTP时间同步成功等信息。常见问题排查屏幕不亮首先检查I2C地址。在串口监视器里可以写一段简单的I2C扫描代码看看是否能找到0x30和0x31两个设备。如果只找到一个说明地址修改可能失败了。WiFi连不上检查settings.toml文件格式是否正确TOML格式很严格不能有多余的空格或引号错误检查SSID和密码是否正确检查路由器是否设置了MAC地址过滤。时间不对检查tz_offset参数是否正确东八区是8。检查NTP服务器是否可访问有时需要等一会儿。按钮/编码器无反应检查STEMMA QT线是否插反或没插紧。检查seesaw的I2C地址是否是0x36大部分模块默认是这个。没有声音检查喇叭线是否焊牢。检查WAV文件格式是否为16-bit, 22.05 kHz, 单声道。检查代码中I2S的引脚定义BCLK,LRCLK,DATA是否与你的QT Py板型匹配。6. 功能扩展与优化思路这个项目的基础框架非常稳固留下了很多可以发挥和扩展的空间。这里提供几个我实践过或构思过的方向1. 增加环境感知与自动调节光线传感器添加一个APDS-9960或TLS2591光线传感器通过I2C连接。在State类里增加一个ambient_light变量在主循环中定期读取。然后可以根据环境光强度动态调节BRIGHTNESS_DAY和BRIGHTNESS_NIGHT的阈值甚至实现无级亮度调节让屏幕在黑暗环境中不刺眼在明亮环境中看得清。温湿度传感器比如SHT40或DHT22。可以设定在整点或通过某种触发方式比如快速按两下按钮在时钟屏幕上滚动显示当前的温度和湿度。2. 增强闹钟与提醒功能多组闹钟将alarm_hour和alarm_min从一个变量改为一个列表或字典用来存储多组闹钟时间。通过编码器和按钮进入一个“闹钟列表”菜单进行管理。工作日与周末模式在状态类里增加一个alarm_days的位标志例如周一到周五对应一个闹钟周末对应另一个闹钟。需要实现一个简单的日历逻辑或者通过网络获取星期信息。渐进式闹铃闹铃音量可以从小逐渐变大或者灯光从暗逐渐变亮实现更温和的叫醒。3. 网络服务集成获取天气信息利用WiFi连接通过Requests库向免费的天气API如OpenWeatherMap发送请求解析返回的JSON数据。可以在屏幕的第二行显示一个简单的天气图标晴、雨、云等和温度。Web配置界面利用CircuitPython的adafruit_httpserver库让时钟自己成为一个WiFi热点或者局域网内的Web服务器。你可以在手机或电脑的浏览器上输入它的IP地址打开一个网页来设置WiFi密码、闹钟时间、选择颜色主题等比用旋转编码器一个个设置要方便得多。4. 显示效果升级更多动画除了眨眼可以设计更多小动画比如整点报时时的庆祝动画、连接WiFi时的搜索动画。颜色主题不仅仅是彩虹循环可以预设几套配色方案经典红、冷光蓝、暖光黄通过编码器切换。低功耗模式如果使用电池供电可以在检测到长时间无操作后自动调暗屏幕甚至关闭显示通过晃动或按下按钮唤醒。扩展时牢记状态机架构的优势。增加一个新功能通常的步骤是1) 在State类中添加相关的状态变量2) 在初始化部分配置好对应的硬件3) 在主循环的相应位置如定时器检查、事件判断后添加对新状态的处理逻辑4) 在显示模块中增加新的绘制函数。只要遵循这个模式代码就能保持整洁和可维护性。