触摸调光台灯:呼吸灯 + 触摸/按键调光
- 做出一台能触摸/按键切三档亮度、待机时呼吸的桌面小台灯
- 把 LEDC PWM 调光和按键/触摸输入两个单练过的知识点接成一条完整链路
- 学会用 TTP223 触摸片或轻触按键做人机输入,并想清两者怎么取舍
- 给设备加"记住上次亮度"的收尾,体会一个 demo 到一件成品差在哪
| 器材 | 数量 | 参考 |
|---|---|---|
| ESP32-S3 开发板 | 1 | —约 25-45 元(以商城实际为准) |
| LED 灯珠(5mm 白光)或 5V 小灯带一小段 | 1 | —约 1-8 元(以商城实际为准) |
| 限流电阻 220Ω(配单颗 LED 用) | 1 | —几分钱一颗(以商城实际为准) |
| TTP223 触摸感应片(或轻触按键) | 1 | —约 1-3 元(以商城实际为准) |
| 面包板 + 杜邦线 | 1 套 | —约 8-15 元(以商城实际为准) |
价格随渠道波动,以购买页实时为准。
想象桌角放着一盏巴掌大的小灯。你不碰它的时候,它不是死板地亮着,而是像睡着了一样一呼一吸地缓缓明灭;你伸手在触摸片上一点,它"啪"地醒来,亮到一档;再点一下,更亮;第三下,最亮;第四下,回到呼吸待机。没有旋钮、没有 App,一根手指就把它伺候明白了——这就是这个项目要带你做出来的东西。
它不难,元件加起来不到五十块钱,一个下午就能点亮。但它对你的意义比"又点了个灯"大得多:这是你第一次把两个单独练过的招式——PWM 调光和按键输入——真正接成一件能摆在桌上天天用的成品。 之前你在 PWM 调光那节让灯会了呼吸,在读按键那节让芯片学会"听你的",但它们各是各的。做完这盏灯,你手里第一次有了一个"输入驱动输出、还带状态记忆"的完整小系统。
这一篇不重复讲 LEDC 怎么配、按键怎么去抖——那些原理前两节讲透了,这里默认你跑通过。我们只干一件事:把它们拼成台灯,讲清拼的过程里那些单看一个知识点时看不到的坑。
第一步:想清楚要做成什么样,再定选型
动手前先把"成品长什么样"钉死,选型才有依据。我们的台灯就三个行为:
- 待机:没人操作时,灯以呼吸方式缓慢明灭,告诉你它活着、在待命。
- 调档:每点一下输入,亮度在"关→低→中→高"四个档位间循环。
- 记忆(进阶):断电再上电,回到上次那一档,而不是每次都从头来。
行为定了,选型的每一步就都有了理由。
LED:单颗灯珠还是小灯带?
先用单颗 5mm LED 把逻辑跑通,再考虑换灯带。 单颗 LED 接线极简、限流电阻一颗搞定、烧了也不心疼,最适合调试阶段。等逻辑全对了想真照亮一片桌面再换 5V 灯带——灯带电流大不能直接挂 GPIO,得靠 MOS 管转接,这条岔路留到最后一节点。
限流电阻怎么定 220Ω?
单颗 LED 直接接 GPIO 会烧,这是限流电阻那篇反复强调的红线,PWM 也救不了你——高电平那段时间电流照样是满的。算法很简单:
电阻 = (电源电压 − LED 正向压降) ÷ 目标电流
ESP32-S3 的 GPIO 高电平约 3.3V,白光/蓝光 LED 正向压降约 3V 出头(红/黄光约 2V),想给它约 5-10mA 的安全电流(点灯够亮又不逼近 GPIO 的输出上限):
- 白光:(3.3 − 3.0) ÷ 0.008 ≈ 37Ω……这个算出来偏小,因为白光压降接近电源电压、余量太少,实践里直接给 220Ω 让它安分地暗一点更稳;
- 红/黄光:(3.3 − 2.0) ÷ 0.006 ≈ 220Ω,正好。
所以220Ω 是一个对红/黄光正正好、对白光偏保守(灯会暗些但绝对安全)的通用值,新手无脑用它。想更懂这个压降和电流的关系,看 LED 是怎么工作的。
触摸片还是按键?两者取舍
这是这个项目唯一需要你做的选择题:
| TTP223 触摸片 | 轻触按键 | |
|---|---|---|
| 手感 | 一碰即触,有"科技感" | 有段落感,反馈明确 |
| 接线 | VCC/GND/SIG 三根,模块自带处理 | 两根,接 GPIO 和 GND |
| 抖动 | 模块内部已处理,输出干净 | 有机械抖动,代码里必须去抖 |
| 代码 | 当普通数字输入读高低电平即可 | 需要去抖逻辑(见 l2-button) |
| 成本 | 略贵一两块 | 最便宜 |
一句话决策:想省代码、要触摸手感用 TTP223;想练去抖、手边有按键就用按键。 两者能无缝替换——它们最终都归结成同一件事:给芯片一个"用户点了一下"的信号。下面代码以按键为主线写(它逼你处理抖动,练到的更多),关键处标出用 TTP223 怎么改。
第二步:接线——避开 S3 的选脚雷区
台灯要用两根信号脚:一根 PWM 输出接 LED,一根 输入接按键/触摸片。ESP32-S3 的 GPIO 看着一大排,但有一批是碰不得的雷区,选脚前先记牢这几条:
ESP32-S3 选脚雷区,接线前对照排除:
- GPIO0 / 3 / 45 / 46:strapping 脚,上电时的电平决定芯片启动模式,接了外设可能让板子刷不进、起不来;
- GPIO26-37:绝大多数模组内部连着 SPI flash / PSRAM,动了直接死机;
- GPIO19 / 20:默认是 USB D-/D+,用它俩会断掉 USB 串口,你连日志都看不到;
- GPIO22 / 23 / 24 / 25:ESP32-S3 上根本不存在这几个号,写了编译不报错、运行时行为诡异(S3 的 GPIO 号从 21 直接跳到 26,中间这段是空的,不是文档漏写)。
避开这些,安全区里随手挑两根就行。本项目选:
LED(PWM 输出):
GPIO5 ──[220Ω 电阻]──▶| LED 长脚(+) LED 短脚(−)── GND
按键(输入):
GPIO4 ──┐
│
[按键]
│
GND ────┘
- LED:
GPIO5 → 220Ω → LED 长脚,LED 短脚回 GND。长脚是正极,别插反(插反了不亮,但不会烧)。 - 按键:一端接
GPIO4,另一端接 GND,靠芯片内部上拉,不用外部电阻——这套接法和 l2-button 一模一样。 - 换成 TTP223:模块的
VCC接开发板 3V3、GND接 GND、SIG(信号输出)接GPIO4。TTP223 默认摸到时输出高电平,正好和按键相反,代码里判断电平时改个方向即可(下面会标)。
GPIO4 和 GPIO5 都在安全区,两根挨着好接线。
第三步:分步把代码写出来
我们不一次甩一大段代码,而是分三步长出来,每步都能单独烧进去看到效果——出问题时你才知道是哪一步坏的。
步 1:先让 LED 用 PWM 亮起来(不是纯开关)
第一步只验证一件事:LEDC 配对了、LED 接对了、能输出中间亮度。PWM 靠快速开关"骗过眼睛"调出中间亮度的机制见 PWM 是怎么调出来的。这段就是把 l2-pwm-led 的 timer+channel 搬过来,把灯固定点到"中等亮度":
#include "driver/ledc.h"
#include "esp_log.h"
static const char *TAG = "lamp";
#define LED_GPIO GPIO_NUM_5 // PWM 输出脚,避开雷区
#define LEDC_MODE LEDC_LOW_SPEED_MODE // S3 只有低速这一组
#define LEDC_CH LEDC_CHANNEL_0
#define LEDC_TIMER LEDC_TIMER_0
#define DUTY_MAX 255 // 8 位分辨率满占空比
static void led_pwm_init(void)
{
ledc_timer_config_t timer = {
.speed_mode = LEDC_MODE,
.duty_resolution = LEDC_TIMER_8_BIT, // 0~255 档,台灯够用
.timer_num = LEDC_TIMER,
.freq_hz = 5000, // 5kHz,绝对看不出闪
.clk_cfg = LEDC_AUTO_CLK,
};
ESP_ERROR_CHECK(ledc_timer_config(&timer));
ledc_channel_config_t ch = {
.gpio_num = LED_GPIO,
.speed_mode = LEDC_MODE,
.channel = LEDC_CH,
.timer_sel = LEDC_TIMER,
.duty = 0,
.hpoint = 0,
};
ESP_ERROR_CHECK(ledc_channel_config(&ch));
}
// 封装一个"设亮度"的小函数,后面全靠它,别再到处写 set+update
static void led_set(int duty)
{
ledc_set_duty(LEDC_MODE, LEDC_CH, duty);
ledc_update_duty(LEDC_MODE, LEDC_CH); // set 完必须 update 才生效
}
void app_main(void)
{
led_pwm_init();
led_set(128); // 固定点到一半亮
ESP_LOGI(TAG, "LED 已点到中等亮度");
}
烧进去 idf.py build flash monitor。你应该看到:LED 稳定亮在一个不刺眼的中等亮度(不是全亮、不是灭)。看到这个,说明 PWM 通路全对——这一步的意义就是把"LED 接线 + LEDC 配置"这两个最容易错的地方先隔离验证掉。
这里我们把
set_duty + update_duty封进了led_set()一个函数。为什么?因为台灯后面要在很多地方改亮度,每处都手写两行、还容易漏掉 update,是新手最常见的"灯不变"故障。封一次,后面只调led_set(x),省心又不出错——这就是从"跑通例子"到"写成品"的第一个思维转变:把易错的成对操作收进函数。
步 2:让它在待机时呼吸
现在把"固定亮度"换成"呼吸"。呼吸的原理 l2-pwm-led 讲透了(占空比 0→255→0 循环),这里直接用,但要放进一个独立的 FreeRTOS 任务里跑——因为主线程后面要腾出来盯着按键,不能被呼吸的循环占死。
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static void breathe_task(void *arg)
{
while (1) {
for (int v = 0; v <= DUTY_MAX; v++) { // 渐亮
led_set(v);
vTaskDelay(pdMS_TO_TICKS(8)); // 一次吸气约 2 秒
}
for (int v = DUTY_MAX; v >= 0; v--) { // 渐暗
led_set(v);
vTaskDelay(pdMS_TO_TICKS(8));
}
}
}
void app_main(void)
{
led_pwm_init();
xTaskCreate(breathe_task, "breathe", 2048, NULL, 5, NULL);
ESP_LOGI(TAG, "进入呼吸待机");
}
你应该看到:灯匀速地一亮一暗,像深呼吸,周而复始。vTaskDelay 的毫秒数调呼吸快慢,8ms 约两秒一个来回,觉得急就加大。
为什么非要开一个任务,不能像 l2-pwm-led 那样直接写在
app_main的while(1)里?因为台灯有两个"同时要干的事"——呼吸和听按键。塞在一个循环里,你按键的那一刻可能正卡在呼吸的vTaskDelay里,响应就迟钝。把"背景动画"独立成一个任务,主线程专心听输入,这是多行为设备的标准骨架。 这也是第二个成品级思维:一个设备往往要同时做几件事,用任务把它们分开。
步 3:接上按键,切换亮度档
最后一步,把输入接进来。逻辑是:平时呼吸任务在跑;一旦检测到按键按下,就切到下一个亮度档;按到"关"档再按,回到呼吸待机。
关键设计:用一个全局的"当前模式"变量,呼吸任务每轮循环开头先看它——是待机就呼吸,是固定档就直接跳出去、把灯钉在那一档。
#include "driver/gpio.h"
#include "esp_timer.h"
#define BTN_GPIO GPIO_NUM_4
// 四个亮度档:0=呼吸待机,1/2/3=固定低/中/高
static const int LEVEL_DUTY[] = {0, 40, 128, 255};
static volatile int g_level = 0; // 当前档位,被两个任务共享(见下方边界说明,别盲目照抄 volatile)
static void btn_init(void)
{
gpio_config_t cfg = {
.pin_bit_mask = 1ULL << BTN_GPIO,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE, // 按键接 GND,用内部上拉:松开读高、按下读低
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&cfg);
}
// 呼吸任务:只在 g_level==0(待机)时呼吸,否则钉住当前档
static void breathe_task(void *arg)
{
while (1) {
if (g_level != 0) { // 不是待机档,钉住亮度、别呼吸
led_set(LEVEL_DUTY[g_level]);
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
for (int v = 0; v <= DUTY_MAX && g_level == 0; v++) {
led_set(v);
vTaskDelay(pdMS_TO_TICKS(8));
}
for (int v = DUTY_MAX; v >= 0 && g_level == 0; v--) {
led_set(v);
vTaskDelay(pdMS_TO_TICKS(8));
}
}
}
// 按键任务:非阻塞时间戳去抖,检测到一次有效按下就切下一档
static void button_task(void *arg)
{
int stable = 1; // 已确认稳定的电平(上拉,松开=高)
int last = 1; // 上一轮读到的原始电平
int64_t last_change = 0; // 电平最近一次变化的时刻(ms)
while (1) {
int now = gpio_get_level(BTN_GPIO); // 按键:按下=0;TTP223:摸到=1,这里判断方向要反过来
int64_t t = esp_timer_get_time() / 1000; // 转成毫秒
if (now != last) { // 电平变了:可能是真按,也可能是抖动,先记下时刻
last = now;
last_change = t;
} else if (now != stable && (t - last_change) > 30) {
// 这个电平已经稳住超过 30ms,且和上次确认的不一样,才认账
stable = now;
if (stable == 0) { // 稳定的下降沿 = 一次有效按下
g_level = (g_level + 1) % 4; // 0→1→2→3→0 循环
ESP_LOGI(TAG, "切到档位 %d", g_level);
}
}
vTaskDelay(pdMS_TO_TICKS(5));
}
}
void app_main(void)
{
led_pwm_init();
btn_init();
xTaskCreate(breathe_task, "breathe", 2048, NULL, 5, NULL);
xTaskCreate(button_task, "button", 2048, NULL, 6, NULL);
ESP_LOGI(TAG, "台灯就绪:点一下切亮度,回到 0 档呼吸");
}
你应该看到:上电先呼吸;点一下按键,灯跳到低档并钉住不呼吸了;再点变中档、高档;第四下回到呼吸待机。串口每次都打出当前档位。
去抖这里用的是 l2-button 教过的非阻塞时间戳法(esp_timer_get_time 记上次跳变时间,30ms 内的抖动一律忽略),没用 vTaskDelay 死等——因为它和呼吸任务并行跑,不能阻塞。
关于
g_level只用volatile int跨任务共享:这里够用,是因为只有按键任务这一个写者、且每次是一次单纯的整数赋值(g_level = ...),呼吸任务只读;volatile保证读到的是内存里的最新值、不被编译器优化进寄存器。但这是本例的特殊条件——一旦多个任务都要写它,或你要做"读改写"(比如g_level++这种非原子操作),就得换上队列(queue)或信号量(semaphore)来保护,别看这里能用 volatile 就到处照抄。 共享变量该不该加锁,看的是"谁在写、写的是不是原子操作",不是变量类型。
换成 TTP223 只改两处:把
button_task里判断有效触发的if (stable == 0)改成if (stable == 1),再把两个初值int stable = 1/int last = 1都改成0。因为触摸模块是"摸到输出高、静止输出低",和按键的"按下拉低、松开拉高"方向相反,静止电平和触发电平都跟着翻个个儿。去抖那套时间戳逻辑一字不动——这就是前面说的"两者最终都是给一个点击信号",接口一致,只差电平方向。
到这里核心功能全齐了。回头看,你没写多少新东西:LEDC 是 l2-pwm-led 的,去抖是 l2-button 的,你干的是把它们用两个任务 + 一个共享档位变量组织起来——这个"组织",就是 project 比 guide 多出来的那层功夫。
第四步:调试——对不上就查这张表
分步烧的好处是,哪一步出问题你已经缩小了范围。真出了岔子,照这张表查:
| 现象 | 最可能的原因 | 怎么办 |
|---|---|---|
| LED 完全不亮 | 长短脚插反 / GPIO 号写错 / 只 set 没 update | 长脚朝电阻、短脚朝 GND;确认 LED_GPIO 是你接的那根;用了 led_set() 就不会漏 update |
| 灯亮但只有全亮全灭、不分档 | duty 值太极端,或没走 PWM 走了 GPIO 输出 | 先单独跑第 1 步,确认 led_set(128) 是半亮而非全亮 |
| 板子刷不进 / 一直重启 | 信号脚踩了 strapping(GPIO0/3/45/46)或 flash 区(26-37) | 把信号脚换到 GPIO4/5 这类安全脚,对照第二步雷区表 |
| 串口没日志 / 一连就断 | 用了 GPIO19/20(USB 脚)当信号脚 | 换脚,这两根是 USB D-/D+ |
| 按一下亮度跳好几档 | 没去抖 / 去抖窗口太短 | 确认走的是 button_task 的时间戳去抖;把 30ms 加到 50ms 再试 |
| 触摸片一直乱跳 / 不响应 | 电平判断方向没跟着改,或 TTP223 供电不对 | 触摸片要把触发判断改成 stable == 1、两个初值改成 0;确认 VCC 接的是 3V3 不是悬空 |
| 呼吸一顿一顿不平滑 | vTaskDelay 太大、步进太粗,或任务被高优先级抢 |
减小 delay(如 5ms)、别把别的重活塞进呼吸任务 |
| 切到固定档后灯还在微微呼吸 | 呼吸任务的 for 没检查 g_level 就一路跑完 |
确认 for 条件里带了 && g_level == 0,切档时能立刻跳出 |
一次只改一处再烧。同时改三个地方、灯还是不对,你根本分不清是哪个改动的锅。分步烧、单点改,是硬件调试省时间的铁律。
第五步:从"能跑的 demo"做成"像样的产品"
到这它已经是台能用的灯了。但"能用"和"像个产品"之间,还差几步——这几步正好通向后面的阶梯,先给你指条路。
加"记住上次亮度"
现在每次上电都从呼吸档开始:昨晚调到高档,今早一开又回呼吸,真当台灯用会烦。解决办法是把 g_level 存进 NVS(ESP-IDF 的非易失键值存储,掉电不丢),开机先读回来、切档时写进去。就是 nvs_get_i32 / nvs_set_i32 一对,给设备加"记忆"的通用做法。
把灯带真正点亮
想照亮一小片桌面,单颗 LED 不够,得换 5V 灯带。但灯带电流几百毫安,绝不能直接挂 GPIO——GPIO 那点电流带不动,硬接会烧脚。正确接法是加一颗 NMOS 管:GPIO 的 PWM 接 MOS 栅极,灯带经 MOS 的漏极通断,源极回地,灯带另一头接 5V。这样 GPIO 只出个微弱控制信号,真正的大电流走 MOS。代码一个字不用改,还是 led_set(duty)——被控对象从 LED 换成了 MOS,PWM 逻辑完全复用。这正是 l2-pwm-led 里说的"同一招换个被控对象"。
做成真正的成品
面包板上一团线只能算原型。想变成能放桌上天天用、甚至送人的东西,要走产品化那条路:稳定供电(别再靠 USB 线吊着)、电路从面包板挪到洞洞板或画一块小 PCB、配个 3D 打印或亚克力外壳、固件量产烧录。这些是 L5 阶梯 的活儿,等你多做几个项目、手感稳了再来啃——这盏台灯正是最适合拿来做成第一件实体产品的对象。
小结 · 你做出了什么、下一步去哪
- 你做出了一台触摸/按键切三档、待机呼吸的桌面小台灯,从选型、避雷区接线、分步写码到调试,走完了一件成品的全流程。
- 你第一次把 LEDC PWM 调光(l2-pwm-led)和按键去抖输入(l2-button)两个单练的知识点,用两个 FreeRTOS 任务 + 一个共享档位变量接成了一条完整链路——这个"组织零件"的功夫,就是 project 比 guide 多出来的那一层。
- 你学到了几个从 demo 到成品的关键思维:把易错的成对操作(set/update)封进函数、把并行行为拆成独立任务、给设备加掉电记忆、大电流负载用 MOS 而非直挂 GPIO。
下一步:想让灯"动"起来(比如加个可转动的灯头),把这套 PWM 手感用到舵机上;想给它加联网远程控制,那就是 L3 的活儿了。回看全部实战项目见项目总览,想系统补基础顺着 L1 阶梯往下走。这盏灯是你所有硬件项目里最该留着的一个——它够简单,又五脏俱全。