我的升降桌改造:破解原厂面板,接入多种控制方式

封面:改造后的智能升降桌

首发于 少数派: AI 助力改造非智能升降桌:智能升降、语音控制、多端联动……

1 背景

早在 2019 年搬入新家之后,就一直在迭代优化我的办公桌, 现在应该是 V3 版本了:

陪伴我多年的办公桌 V3 版本

这个桌子从搬入新家就一直陪伴着我, 它承载了太多太多….

是的, 就是字面上的意思:
桌面堆满设备,承载太多

后来因为工作的原因有点轻微腰椎间盘突出, 所以决定给它换一个”躯干”, 桌板还是用的原来的, 毕竟好一点的升降桌都要 3000+ …

最终选择了一款非智能的双电机版本, 升降范围是 64~129cm, 因为我确信凭借自己对硬件的热爱, 可以很容易的改装成智能控制, 但是可能对自己的技术太自信了, 这个想法拖到近段时间才得以实现, 一是有充足的时间, 二是有 AI 的加持.


我的升降桌之前只能通过原厂控制面板操作,面板上有上升、下降和四个档位键:

原厂控制面板:上升、下降和四个档位键

有时候会出现 B12 错误码, 不过同时按住上下键 5 秒就能重置.

我最初想增加一个旋钮,顺时针旋转升高,逆时针旋转降低,按下旋钮停止。

动手前,我看了少数派的《从零开始,改造一张可以用 Siri 控制的升降桌》,也研究了开源项目 Upsy Desky。它们使用的通信协议都和我的升降桌不同,我需要重新分析原厂控制面板与控制盒之间的通信。

除了旋钮,我还准备接入键盘、手机、Web、手表和语音控制。ESP32-S3 负责接收这些入口的指令,再按照原厂协议控制升降桌。后来我又接入了小智 AI:一块 JC3636W518C 放在桌上作为语音终端。

硬件

如果你和我的控制面板一样, 那大概率是可以复刻的, 不过最好还是拆开面板看一下, 看看线序和网线的线序是否一致.

如果不一样可能就需要使用逻辑分析仪来抓包分析原始数据了. 只要能通过 ESP32-S3 发送正确的指令控制升降, 那多端接入就非常简单了.

2 改造目标与方案选择

2.1 原厂控制面板的使用局限

原厂控制面板有上升、下降和四个档位键。单击档位键可以移动到保存的高度,长按会覆盖对应档位。每次操作都需要使用安装在桌边的面板。

目前最大的问题就是没有童锁功能, 家里的小孩儿觉得好玩儿, 会经常趁我不注意按着玩儿, 有一次离家之前将升降桌设置到了最高位置, 正好超过了椅子的高度, 不知道是不是老人的安全意识不够, 把孩子抱起来按了档位 1, 椅子抵住了桌面差点把桌子顶翻(这也是一个局限的点, 没有障碍检测).

所以我老早就决定改造成智能升降桌, 而且必须加上童锁的功能.

契机是一个 zFrontier 的上的一个 旋钮控制方案.

2.2 从旋钮控制扩展到多终端接入

玩儿客制化键盘的都知道 zFrontier, 我没事就会在 ZF 上逛逛. 正好看到了 Serendipity Dial,我觉得它的可定制化功能非常多, 正好我桌面上有 2 个旋钮设备, 想着是不是可以通过旋钮来控制升降桌, 然后又在少数派上搜索到了 《从零开始,改造一张可以用 Siri 控制的升降桌》并了解到了 Upsy Desky 这个开源项目, 脑海中逐渐有了可落地的方案:

落地方案-软件

这套方案的中心是 ESP32-S3。App、手表、键盘、旋钮这些控制端只发统一指令,不用懂原厂协议;ESP32-S3 把这些指令翻译成控制盒认的键码,桌子才会动。

硬件架构:
落地方案-硬件

所以整套方案的核心,是拿到原厂控制盒认的那套指令。当前分层和硬件拓扑记在仓库的架构总览里。

2.3 现有开源方案的适配限制

少数派的 Siri 升降桌改造和 Upsy Desky,思路都是在控制盒与面板之间加一块控制模块,这点和我后来做的网关接近。但升降桌面板没有统一协议,品牌不同、批次不同,通信方式都可能不一样。

我把这两份方案对照自己的桌子看过之后,发现都不适配。Upsy Desky 支持的控制盒和我的不是一种,我的原厂面板用 RJ45 插头只引出四根线。线序、协议和按键数据都得自己确认,ESP32-S3 南向才能发出控制盒认的键码。后面的拆解和抓包,都是在补这一环。

3 原厂控制面板拆解与总线识别

3.1 RJ45 接口与四线定义

原厂面板通过 RJ45 插头与控制盒连接。拆开面板后,我看到电路板背面有四个测试点,标记分别为 3.3VGNDCLK 和 `DAT, 白线是时钟,绿线接地,黑线传输数据:

面板背面四个测试点:3.3V / GND / CLK / DAT

3.2 TM1650 芯片与总线类型判断

原厂面板使用 TM1650 驱动数码管, 结合 CLKDAT 两个测试点,初步判断面板与控制盒之间使用 I²C 通信。

驱动数码管的 TM1650 芯片

芯片型号还不能确定完整协议。只能继续通过总线抓包确认主从关系、设备地址、按键编码和高度数据。

3.3 逻辑分析仪接线与采样参数调整

逻辑分析仪接入 GND / CLK / DAT

抓包时,原厂面板保持与控制盒连接,逻辑分析仪接入 GNDCLKDAT 三个测试点。分析仪不连接面板的 3.3V,也不参与供电。

逻辑分析仪和控制面板连线

我最初把采样率设为 20 kHz,只能看到电平变化,无法解码完整数据。把采样率提高到 12 MHz 后,PulseView 可以稳定识别地址和数据。

12 MHz 采样后 PulseView 稳定解码

分别采集了静置、按住上升、按住下降、单击档位和长按保存的数据。采集高度数据时,我让桌子从 64 cm 升到 129 cm,同时记录面板显示的高度,方便后续对照。抓包步骤见逻辑分析仪抓包

SDA 对应 DAT

SCL 对应 CLK

然后将协议修改为 I²C 后就可以正常读取.

4 I²C 通信机制与协议解析

物理上只有一对 CLK / DAT,TM1650 用不同从机地址拆成两条通道:控制盒读 0x24 问现在按了哪颗键,写 0x340x37 刷数码管。PulseView 显示的是 7-bit 地址;TM1650 手册常用 8-bit 写地址,0x24 就是手册里的 0x480x34 就是 0x68。完整键码、时序和抓包索引在协议逆向笔记

4.1 I²C 主从关系与周期轮询

抓包确认控制盒是 I²C Master,原厂面板是 Slave。时钟由控制盒产生,SCL 大约 9.6 kHz。面板不主动上报,只在被问到时把当前键态放上总线。

静置时总线不是空闲的。控制盒大约每 3.7 毫秒做一轮完整问答:先往 0x24 写一字节 DW=0x01,再读一字节 DRDW 实测一直是 0x01,像显示控制 / 亮度一类的寄存器,键态不在这里。键码在 DR 里。静置时 DR0x2E

这张图是静置总览,写 0x01 和读 0x2E 一直在交替:

静置总览:写 0x01 与读 0x2E 交替

这一轮写和读是分开的两笔事务,中间各有一次 STOP,一次完整轮询是:

1
2
3
4
S  Address write 0x24  ACK  Data write 0x01  ACK  P
约 29 us
S Address read 0x24 ACK Data read 0x2E ACK P
约 95 us 后再开下一轮

读完那一字节之后,控制盒用的是 ACK 再 STOP,不是常见的 NACK 再 STOP。后面用 ESP32 去复放面板侧时序,就是卡在这一点上。

这张图是单次读写放大:

单次读写事务放大图

持续返回上升或下降键码时,桌子保持运动;改回 0x2E 后停止。要让桌子动,就是改 Slave 每次被读时交出的 DR。总线上并没有一条单独的「升桌命令帧」可塞。

4.2 升降、停止与档位指令解析

对比静置、按住上升、按住下降的抓包:DW 始终 0x01,变的只有 DR

操作DR说明
空闲、松手0x2E必须一直回这个,不要静默不应答
按住上升0x47要升多久就回多久
按住下降0x4F要降多久就回多久
档位 1 单击0x17前往已保存高度
档位 1 长按保存0x570x17 加上 bit6(0x40
档位 4 单击0x2F前往已保存高度
档位 4 长按保存0x6F0x2F 加上 bit6

上升和下降只差 bit3(0x47 ⊕ 0x4F = 0x08)。档位短码和长按差的是 bit6,事务骨架不变。

档位 2 和档位 3 暂时没有抓包,因为我现在常用的只有档位 1 和档位 4, 后续可以通过高度传感器来处理多档位问题.

同时按住上和下大约 5 秒,原厂可以清 B12。抓包里对应 DR=0x7F,有效区间大约 7.5 秒,结束后必须回到 0x2E

4.3 数码管高度数据解析

高度不在 0x24DR 里。按住上升时 DR 一直是 0x47,不会跟着厘米数往上爬。我最初在键通道里搜 64、102 这种字面量,找不到,还把写往 0x340x37 的帧当成电机干扰滤掉了。

拆板确认 U2 是 TM1650 之后,这些地址对上了手册里的 digit 寄存器(8-bit 0x68 / 0x6A / 0x6C / 0x6E)。控制盒一边读键,一边把七段段码写到数码管。面板自己没有高度 MCU,总线上的高度是显示镜像,整数厘米。

逻辑位7-bit显示
DIG10x34百位,两位高度时常写 0x00 消隐
DIG20x35十位
DIG30x36个位
DIG40x37第四位;静止时常等于 DIG3

总线写序多数是 DIG3 → DIG2 → DIG1 → DIG4,组装高度要按逻辑编号,不能按时间顺序当百、十、个。长按保存档位 1 / 4 的静置帧里,四元组精确重复,段码不变:

1
2
AW:36 DW:C5 / AW:35 DW:DB / AW:34 DW:00 / AW:37 DW:C5   → 64 cm
AW:36 DW:9E / AW:35 DW:5F / AW:34 DW:44 / AW:37 DW:9E → 102 cm

对照 TM1650 段码表,还能还原出 82、121。静态大约每 2 到 3 秒刷一整帧;运动中更密。

5 ESP32-S3 控制链路验证

拿到了指令数据, 就可以开始使用 ESP32-S3 来模拟指令实现升降桌控制了。

控制盒通过 RJ45 接口和控制面板连接, 如果需要使用 ESP32-S3 来下发控制指令的话, 需要一个 RJ45 模块:
双口 RJ45 模块

这里我买的是独立的双口 RJ45 模块, 左侧接口通过网线连接到升降桌的控制盒, 进而使用 ESP32-S3 下发指令来控制升降桌, 右口是连接原厂控制面板, 意思是保留原厂控制面板的控制功能, 不过这里需要 ESP32-S3 来透传指令.

5.1 ESP32-S3 模拟原厂控制面板

我把 ESP32-S3 配置成地址 0x24 的 I²C Slave,用它替代原厂面板响应控制盒。Web 页面按住上升时返回 0x47,松手时返回 0x2E;下降操作返回 0x4F

控桌不是 ESP32 主动发一条「升桌帧」。控制盒每大约 3.7 ms 来读一次,Slave 把当前键码交出去。固件在 mxtark.c 里大致是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#define ADDR_KEY_7BIT  0x24u
#define DR_IDLE 0x2Eu
#define DR_UP 0x47u
#define DR_DOWN 0x4Fu

i2c_slave_config_t cfg = {
.i2c_port = I2C_NUM_0,
.scl_io_num = GPIO_NUM_4, /* CLK */
.sda_io_num = GPIO_NUM_5, /* DAT */
.slave_addr = ADDR_KEY_7BIT,
.addr_bit_len = I2C_ADDR_BIT_LEN_7,
.send_buf_depth = 64,
.receive_buf_depth = 64,
};
i2c_new_slave_device(&cfg, &handle);
i2c_slave_register_event_callbacks(handle, &cbs, ctx);

/* 控制盒发起读请求时,ISR 只往队列丢一个记号,不在中断里写总线 */
static bool IRAM_ATTR on_request_cb(i2c_slave_dev_handle_t i2c_slave,
const i2c_slave_request_event_data_t *evt,
void *arg)
{
slave_ctx_t *ctx = arg;
xQueueOverwriteFromISR(ctx->tx_q, &token, &hp);
return hp == pdTRUE;
}

/* 真正写总线放在任务里,每次交出当前 DR */
static void slave_tx_task(void *arg)
{
for (;;) {
xQueueReceive(ctx->tx_q, &token, portMAX_DELAY);
uint8_t dr = (uint8_t)atomic_load(&s_dr); /* 0x2E / 0x47 / 0x4F */
i2c_slave_write(ctx->handle, &dr, 1, &written, 50);
}
}

Web 按住升,只是把 s_dr 改成 0x47;松手改回 0x2E。I²C 外设负责 ACK 和移位,业务代码不用去翻 CLK/DAT。

我先用 Web 页面发送指令,分别检查网络请求、固件状态、串口日志和 I²C 通信,暂时不接入旋钮。

5.2 首次联调中的通信异常

首次上电时,串口日志可以正常记录键码变化。点击上升后当前键码变为 0x47,点击下降后变为 0x4F,但升降桌没有动作。

启动后 Slave 是起来了的:

1
I (3124) mxtark: I2C slave @0x24 SCL=4 SDA=5

Web 按住升、松手、再按住降,日志大致是这样,桌子纹丝不动:

1
2
3
4
5
6
I (18420) desk_core: motion request source=rest mode=hold dir=up action=start status=idle
I (18421) mxtark: DR=0x47
I (19208) mxtark: DR=0x2E
I (22110) desk_core: motion request source=rest mode=hold dir=down action=start status=idle
I (22111) mxtark: DR=0x4F
I (22940) mxtark: DR=0x2E

mxtark: DR=0x47 只说明固件把待回传的字节改了。串口日志只能证明固件更新了待发送的键码,不能证明控制盒已经通过 I²C 读到该数据。

所以这里我卡了一天,查了很多资料,直到我再次查看原厂 PCB 才大概确认了问题:

原厂 PCB 上 TM1650 旁的两只上拉电阻

TM1650 旁边有两只贴片电阻,一端接到 3.3V,另一端分别接到 CLKDAT。断电量下来大约 1.99 kΩ。这就是上拉电阻。

I²C 的 CLKDAT 不能像普通 GPIO 那样由芯片把线推到高电平。总线上的设备只会把线拉到地,表示 0;要表示 1,得靠电阻把线拉回 3.3V。没有这只电阻,线会悬在中间,控制盒读到的既不是稳定的高,也不是稳定的低。Slave 就算把 0x47 准备好了,对面也轮询不下来。

原厂面板插着的时候,上拉焊在面板上。我用 ESP32 替换面板,等于把这两只电阻一起拔掉了。所以日志里 DR 在变,总线上却没有合格的高电平。

5.3 I²C 上拉电阻与通信恢复

确认之后,我用控制盒的 3.3V,分别通过一只 2 kΩ 电阻接到 CLKDAT。电阻是并联到信号线上的上拉,不是串在 GPIO 前面的限流电阻;白线和黑线仍直接进 GPIO4、GPIO5。红线只给这两只电阻供电,不能接到 ESP32 的 3V3

给 CLK / DAT 补 2 kΩ 上拉电阻

ESP32-S3 继续使用 USB-C 单独供电,并与控制盒共地。增加上拉电阻后,Web 页面可以控制桌子升高和降低。

接线图如下:

上拉电阻接线图

加上电阻后连续升高、连续降低和松手停止都正常工作。不过还没有启用档位功能。


ESP32-S3 已经能按原厂协议升降桌子,Web 控桌可以用了。接下来要把高度读出来。桌面右侧有键盘架,升太高会顶上去,必须限制最高位。

5.4 双口 RJ45 透传原厂面板

Web 能动桌子之后,桌边那块原厂面板还空着。双口模块左口已经接到控制盒,右口用来接回原厂面板,让按键继续能用。

左右 RJ45 的 CLK/DAT 不能直接短接,那样会绕过 ESP32,童锁和来源权限都拦不住。面板按键从右口进 GPIO6 / GPIO7,和 Web、BLE 一样先交给 desk_core,再由左口 GPIO4 / GPIO5 的硬件 I²C Slave 回给控制盒。

这张图是数据怎么走:

双口 RJ45 透传数据流示意

信号左口(控制盒)右口(原厂面板)
pin 1 / 红 / 3.3V现有两只 2 kΩ 上拉的电源端与左口 pin 1 直接跳线
pin 2 / 白 / CLKGPIO4GPIO6
pin 3 / 绿 / GNDESP32 GND与左口 pin 3 直接跳线
pin 4 / 黑 / DATGPIO5GPIO7

红线和地左右跳通,只是给原厂面板供电。CLK/DAT 不要左右对跳。右口不再补上拉,原厂面板板上那两只约 1.99 kΩ 已经在。红线仍然不接 ESP32 的 3.3V

这张图是右口接线:

右口接回原厂面板的接线

左口还是对着控制盒:ESP32 当 I²C Slave,地址 0x24,控制盒来问键码,它负责回答。

右口则相反,对面是原厂面板。面板自己不会主动上报,得有人装成控制盒去问它「现在按的是哪颗键」。所以右口这边,ESP32 得当 Master。

抓包看到的问答大概是 9.6 kHz,而且写和读是两笔独立事务:先写完一字节,中间有一次 STOP,再另开一笔去读键码。ESP-IDF 自带的硬件 I²C Master 习惯把「写 + 读」捏成一笔,结束时的 ACK/NACK 也跟控制盒不一样,对不上这套时序,所以复放不了。

所以 GPIO6、GPIO7 不用芯片里的 I²C 外设,改成程序自己翻转这两根脚。I²C 平时靠上拉电阻把线拉高,芯片只负责把线拉低,这种接法叫开漏。软件按抓到的时序去读键码,再交给和 Web 同一套仲裁。没开童锁、Panel 也开着时,桌边按键优先于 Web。按住升降时拔掉右口网线,桌子会停。童锁和单独关掉 Panel 后面单独写。左口右口针脚见真机验收清单固件 README

6 高度数据解析与通信方案调整

键码:

1
2
3
停止  0x2E
上升 0x47
下降 0x4F

目前通过 ESP32-S3 能正常控制升降,但是还不能正常显示高度。Web、REST、BLE 没有真实高度,档位闭环和最高安全高度也做不了。

原厂面板有三位七段数码管。第一反应是:既然 ESP32 已经在同一组 CLK/DAT 上应答控制盒,能不能把高度一并读出来?

继续分析抓包数据:

通道地址方向内容
键通道0x24控制盒读面板当前按键,DR 里是 0x2E / 0x47 / 0x4F 这类键码
显示通道0x34–0x37控制盒写面板TM1650 digit 段码,面板拿它刷新数码管

DR 里找不到 64、102 这种厘米字面量。高度是控制盒写给数码管的段码。旁路嗅探 0x34–0x37 的写事务,再用本面板标定过的经验字库,可以把显示高度还原成整数厘米。

6.1 ESP32-S3 硬件 I²C 资源限制

要在 ESP32-S3 中同时处理按键和高度,需要响应同一对 CLK/DAT 上的五个 Slave 地址。0x24 用于按键,0x340x37 用于四个数码管位置。

ESP32-S3 有两组硬件 I²C 控制器,但一个硬件 I²C Slave 只能配置一个精确地址。

两组控制器无法同时覆盖这五个地址。物理连接只有一对 CLK/DAT,问题出在五个 Slave 地址上。

五个 Slave 地址无法用硬件 I²C 覆盖

硬件 I²C Slave 已经占用 @0x24 回键码,同一组 GPIO4/5 上再想同时 ACK 0x34–0x37,硬件外设做不到。

于是出现两条实验路:

GPIO 边沿嗅探这条路:硬件 Slave 继续只应答 0x24,另用 GPIO ISR 听 CLK/DAT,试图在不 ACK 的情况下偷看 digit 写。真机结果是 NO-GO:SCL 每个上升沿和 SDA 边沿都会打高频中断,和硬件 Slave 抢同一组脚。控制盒在 0x34–0x37 被 NACK 后往往会停写,纯监听拿不到段码 data。更糟的是升降本身开始不稳,高度仍然未知。

6.2 软件 I²C 多地址响应方案

为了同时响应五个地址, 将控制盒一侧改成软件 I²C。

程序通过 GPIO 中断读取时钟和数据边沿,并在软件中处理 START、STOP、地址、数据和 ACK。状态机在 mxtark_soft_i2c_sm.c,GPIO 边沿在 mxtark_soft_i2c_esp.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#define ADDR_KEY_7BIT  0x24u
#define ADDR_DIG1_7BIT 0x34u
#define ADDR_DIG4_7BIT 0x37u

/* 0x24 读写都应答;0x34–0x37 只应答写,用来收段码 */
static bool address_is_supported(uint8_t addr7, bool read)
{
if (addr7 == ADDR_KEY_7BIT) {
return true;
}
return !read && addr7 >= ADDR_DIG1_7BIT && addr7 <= ADDR_DIG4_7BIT;
}

static void IRAM_ATTR scl_edge_isr(void *arg)
{
mxtark_soft_i2c_digit_event_t event = {0};

if (line_is_high(GPIO_NUM_4)) { /* CLK 上升沿:采样 DAT */
mxtark_soft_i2c_sm_scl_rising(&s_sm, line_is_high(GPIO_NUM_5));
} else { /* CLK 下降沿:ACK 或送出 DR 的下一位 */
event = mxtark_soft_i2c_sm_scl_falling(&s_sm);
apply_sda_output();
}
}

static void IRAM_ATTR sda_edge_isr(void *arg)
{
if (!line_is_high(GPIO_NUM_4)) { /* START/STOP 只在 CLK 为高时有效 */
return;
}
if (line_is_high(GPIO_NUM_5)) {
mxtark_soft_i2c_sm_stop(&s_sm);
} else {
mxtark_soft_i2c_sm_start(&s_sm);
}
}

硬件 Slave 只能挂一个精确地址,五个地址只能自己翻脚。0x24 读周期仍回当前 DR;写到 0x340x37 的字节丢进队列做段码解码。

改成软件 I²C 后,Web 页面可以显示从数码管段码解析出的高度。桌子运动时,高度数字也会随之变化。

6.3 升降过程中的通信中断

软件 I²C 启用后,桌子在上升过程中频繁停止和重新启动。下降在早期版本中相对连续,后续修改软件时序后也出现了相同问题。

我检查了 Web 是否提前发送 STOP、BLE 运动租约是否过期,以及高度刷新是否触发安全上限。日志中没有出现 0x2E。上层控制逻辑没有主动触发停止。

按住升高时串口是这样,桌子已经在一顿一顿了:

1
2
3
4
5
I (22104) desk_core: motion request source=rest mode=hold dir=up action=start status=idle
I (22105) mxtark: DR=0x47
I (23118) mxtark: motion bus stage=run dir=up dt=1012 ms tx_dr=0x47
I (24122) mxtark: motion bus stage=run dir=up dt=1004 ms tx_dr=0x47
I (25130) mxtark: motion bus stage=run dir=up dt=1008 ms tx_dr=0x47

中间没有 mxtark: DR=0x2E,也没有 desk_core: motion stop。ESP32 一直在交 0x47,停的是控制盒这一侧读不到完整键码。

6.4 软件 I²C 响应统计与根因定位

故障版本的诊断日志出现过:

1
2
3
key_tx=102/8 abort=94
key_tx=164/8 abort=156
key_tx=128/4 abort=124

三段合计发起 394 次按键响应,完整发送只有 20 次,374 次中途被打断,完成率约 5%。控制盒只有偶尔能读到完整的 0x47,桌子上的体感就是:

1
2
3
完整读到 0x47  → 上升
本轮读取失败 → 控制盒认为按键没有持续
再次读到 0x47 → 又升一段

下降用 0x4F,和上升的 0x47 只差 1 个 bit。同一套软件状态机里,下降有时看起来连续一点。这不能证明软件 I²C 可靠,只说明送出去的那个字节里 0 和 1 怎么排,失败时的体感就不一样。

后面还试过边沿过滤、START/STOP 保护、ULP 采样、任务优先级和发送阶段保护。日志数字有时好看一点,真机连续升降出现断断续续的情况。继续在同一实现上叠时序规则,等于把升降安全绑在越来越复杂的中断状态机上。

所以软件 I²C 同时负责按键和高度这条路,最后证明保证不了升降稳定性,放弃了。

6.5 控制链路回退至硬件 I²C

软件 I²C 方案先后测试了边沿过滤、START 和 STOP 保护、任务优先级调整以及 ULP 辅助采样。这些修改没有恢复硬件 I²C 的连续运动表现,并且曾导致下降路径出现相同问题。

我恢复了硬件 I²C Slave,只处理地址 0x24。ACK、数据移位和总线时序重新交给 ESP32-S3 的硬件外设。

高度和距离只能改用其他方案, 比如使用外部距离传感器。为什么把高度从这条总线上拆走,见高度数据解析与通信方案调整

7 双 ToF 传感器的距离采集

TOF050C 与 TOF400C 两颗距离传感器

我采购了 2 类距离传感器, 分别是 TOF050C 和 TOF400C:

安装位置模块 / 芯片当前用途产品数据
桌面右侧,水平朝向右侧障碍物TOF050C / VL6180X测量桌面右侧间距,参与低位上升保护right_gap_mm
桌板下方,垂直朝向地面TOF400C / VL53L1X直接作为桌面高度,参与档位闭环和最高高度保护height_mm / tof_height_mm

两块 ToF 模块安装位置

两块模块的接口丝印相同,均为 VIN/GND/SDA/SCL/INT/SHUT。TOF400C 模块正面标注
TOF400C-VL53L1X;固件还会读取芯片 ID,TOF050C 期望 0xB4,TOF400C 期望 0xEACC

当前高度不做卷尺高度换算,也不叠加安装偏移。实测参考关系为:

TOF400C 读数卷尺参考高度用途
56 cm71 cm坐姿档位
87 cm105 cm站姿档位
94 cm允许配置的最高传感器高度

两颗传感器共用 GPIO10 和 GPIO11 的硬件 I²C 总线,并由 ESP32-S3 的 3.3V 供电。它们上电后的默认地址都是 0x29。固件先通过 GPIO12 释放 TOF050C,把它的运行地址改为 0x30,再通过 GPIO13 启动保持 0x29 的 TOF400C。

两颗 ToF 共用 I²C 总线的接线

固件会过滤无效量程状态,并在数据超过 1 秒没有更新时标记为离线。高度和右侧距离都增加了稳定滤波,Web 页面可以实时显示两路数据。

Web 和 OLED 现在都能显示两颗传感器的实时数据。固件已经使用 TOF400C 的原始距离完成档位闭环和最高位限制,并使用 TOF050C 处理低位向上运动时的右侧障碍。上升策略见双 ToF 安全策略

Web 实时显示 ToF 高度与间距

8 OLED 本地状态显示

为了避免只能在 WEB 端看到当前桌面高度, 我在 ESP32-S3 上又接入了一块 0.91 英寸 SSD1306 OLED,分辨率为 128×32。OLED 与两颗 ToF 传感器共用 GPIO10 和 GPIO11,使用 3.3V 供电。固件启动时会自动检测 0x3C0x3D 两个常用地址。

OLEDESP32-S3说明
GNDGND必须与 ESP32、ToF 外部电源共地
VCC3V3不接外部 5V,避免 SDA/SCL 被上拉到 5V
SCLGPIO10共享 I2C1 时钟
SDAGPIO11共享 I2C1 数据

0.91 英寸 SSD1306 OLED 屏幕

屏幕每 4 秒轮播以下页面:

  1. 大字显示桌面高度和右侧墙面间距。
  2. 显示桌子运动状态、TOF050C/TOF400C 在线状态和童锁。
  3. 显示 Wi-Fi 模式、IP 地址、BLE 当前连接数和 Bond 数量。

桌子运动时,OLED 会优先显示 UPDOWNPRESET 以及实时高度。传感器掉线后,屏幕显示 SENSOR OFFLINE。OLED 只读取各模块的状态,不发送升降指令,也不会在传感器异常时阻止 STOP 或手动升降。页面和接线见OLED 状态屏

OLED 轮播页面

9 多终端控制、语音与本地状态显示

桌子能动、高度也能读之后,这项目算是完成了一大半了。剩下是多端控制, Web 其实已经能做完全部操作,不过都走到这一步了,多端就是锦上添花,那就多添一点花。

目前已实现的控制端有(用法汇总见多种方式控制升降桌):

控制端怎么用
局域网 Web浏览器打开网关页面,按住升降、切档位、开关童锁
REST / 脚本desk-preset.sh、curl、快捷键都走这一组接口
iPhone / Android AppBLE 优先,必要时回退局域网
Apple WatchDigital Crown 连续升降
键盘Karabiner 快捷键切坐姿、站姿、停止
桌面旋钮顺时针升、逆时针降、按下停止
GoatRemoteMac 上语音切坐姿 / 站姿
小智 AI桌上 JC3636W518C,圆屏页面和语音
Ulanzi D200H实体键坐 / 站 / 番茄
原厂面板双 RJ45 透传后,桌边按键仍可用

还没做的入口:

控制端打算怎么接现状
Home Assistant局域网 MQTT,Discovery 出 Cover 实体,坐 / 站 / 停方案写了,固件没接
MatterESP32-S3 上跑 Matter,手机当控制器添加未实现
Siri走 Matter 或快捷指令,不另做一套云未实现
米家 / 华为智慧生活正品级要认证模组,不打算在现有板上硬搓私有协议未实现

这些都不走公网端口映射。HA 如果接上,也只挂在家里的 Broker 上。方案分别在MQTT / Home Assistant米家 / 华为生态调研

9.1 先做 Web

ESP32 可以按原厂协议控制桌子升降之后,为了快速验证项目, 我先做了局域网 Web。

局域网 Web 控制页面

浏览器打开网关页面就能按住升降、松手停止,也可以切坐姿、站姿和童锁。没有 Wi-Fi 时设备会开 SoftAP,SSID 是 DeskGateway,配网页在 http://192.168.4.1/。默认密码和 Web 登录密码都是 desk-gateway,用起来之后应该改掉。

页面上的升/降是按住才动,不是点一下走到底。高度来自 TOF400C,设置页可以改档位、来源权限和童锁。Web 只挂在局域网,不要做公网端口映射。本机要改的 IP、密码和路径见本地多端部署

Web 设置页

9.2 REST API

Web 调的就是固件上的 HTTP 接口。我把这组接口固定成 REST,脚本和第三方用 X-Desk-Key,浏览器用 Authorization: Bearer,两个头等价,值等于当前 Web 密码。

键盘、旋钮、GoatRemote、D200H、小智桥接,以及手机和手表的 Wi-Fi 通道,发的都是同一套升、降、停、档位指令,不用各自去处理 I²C。后面能继续加入口,靠的就是这组 REST。契约见REST API

1
2
3
4
5
curl -s -H "X-Desk-Key: $DESK_KEY" "http://$DESK_IP/api/v1/desk/status"
curl -s -X POST -H "X-Desk-Key: $DESK_KEY" \
"http://$DESK_IP/api/v1/desk/preset/4/goto"
curl -s -X POST -H "X-Desk-Key: $DESK_KEY" \
"http://$DESK_IP/api/v1/desk/stop"

仓库里的 scripts/desk-preset.sh 把常用动作包了一层:1 坐姿,4 站姿,up / down 是旋钮那种短租约 jog,不是 Web 长按。

9.3 键盘和旋钮

有了 REST 之后,键盘和旋钮就只需要发 HTTP 请求即可操作升降桌。

我用 Karabiner 把旋钮映射成三个闲置功能键,分别对应顺时针、逆时针和按下。按键事件进 Shell 脚本,脚本再调 ESP32 的局域网接口。键盘快捷键同样走 desk-preset.sh,切坐姿、站姿或停止。映射和 jog 时序见键盘、旋钮与语音控制,Karabiner 配置在integrations/karabiner

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
{
"description": "ForcePad: F1 → 升降桌:1",
"manipulators": [
{
"type": "basic",
"from": {
"key_code": "f1",
"modifiers": {
"optional": ["any"]
}
},
"to": [
{
"shell_command": "/Users/dong4j/Developer/1.AI/ai-incubator/desk-gateway/scripts/desk-preset.sh 1"
}
],
"conditions": [
{
"type": "device_if",
"identifiers": [
{
"vendor_id": 6790,
"product_id": 65024
}
]
}
]
}
]
},
{
"description": "ForcePad: F2 → 升降桌:stop",
"manipulators": [
{
"type": "basic",
"from": {
"key_code": "f2",
"modifiers": {
"optional": ["any"]
}
},
"to": [
{
"shell_command": "/Users/dong4j/Developer/1.AI/ai-incubator/desk-gateway/scripts/desk-preset.sh stop"
}
],
"conditions": [
{
"type": "device_if",
"identifiers": [
{
"vendor_id": 6790,
"product_id": 65024
}
]
}
]
}
]
},
{
"description": "ForcePad: F3 → 升降桌:4",
"manipulators": [
{
"type": "basic",
"from": {
"key_code": "f3",
"modifiers": {
"optional": ["any"]
}
},
"to": [
{
"shell_command": "/Users/dong4j/Developer/1.AI/ai-incubator/desk-gateway/scripts/desk-preset.sh 4"
}
],
"conditions": [
{
"type": "device_if",
"identifiers": [
{
"vendor_id": 6790,
"product_id": 65024
}
]
}
]
}
]
}

旋钮不能跟 Web 长按用同一套语义。第一个刻度只记录方向,700 毫秒内出现第二个同方向刻度后才启动升降。停止旋转后,最后一次指令的运动租约大约 500 毫秒后失效,ESP32-S3 自动停止。反向旋转时先停掉当前运动,再启动相反方向。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
{
"description": "ForcePad: ⌘ + PageUp → 升降桌:持续上升",
"manipulators": [
{
"type": "basic",
"from": {
"key_code": "page_up",
"modifiers": {
"mandatory": ["command"],
"optional": ["any"]
}
},
"to": [
{
"shell_command": "/Users/dong4j/Developer/1.AI/ai-incubator/desk-gateway/scripts/desk-preset.sh up"
}
],
"conditions": [
{
"type": "device_if",
"identifiers": [
{
"vendor_id": 6790,
"product_id": 65024
}
]
}
]
}
]
},
{
"description": "ForcePad: ⌘ + PageDown → 升降桌:持续下降",
"manipulators": [
{
"type": "basic",
"from": {
"key_code": "page_down",
"modifiers": {
"mandatory": ["command"],
"optional": ["any"]
}
},
"to": [
{
"shell_command": "/Users/dong4j/Developer/1.AI/ai-incubator/desk-gateway/scripts/desk-preset.sh down"
}
],
"conditions": [
{
"type": "device_if",
"identifiers": [
{
"vendor_id": 6790,
"product_id": 65024
}
]
}
]
}
]
}

现在可以全部使用键盘和旋钮来控制升降:
用键盘和旋钮控制升降桌

9.4 手机和手表也可以走 REST

同一组 REST,iPhone、Android 和 Apple Watch 也能用。App 不需要懂原厂键码,发升、降、停、档位即可。Watch 的 Digital Crown 正向升高、反向降低,停转后发 STOP;Crown 走 jog,和旋钮一样靠固件短租约兜底,不用 Web 那种按住续期的接口。手机见App README,手表见Watch README,双通道见移动端双通道

手机与手表走同一套 REST

9.5 再补一条蓝牙通道

局域网够用,但手机离路由器远、或者不想填 IP 的时候,REST 就不方便。固件后来也做了 BLE:广播名 DeskGateway,最多三个 Central,同一时间只有一个运动所有者,非所有者会收到 Desk Busy 0x80,任意 STOP 始终有效。UUID 和字节协议见BLE Accessory Profile

手机和手表现在都是双通道:默认优先 BLE,连不上或断开后再走局域网 REST。通道切换不会重放刚才的运动命令。童锁、来源权限、运动租约和断连停止仍由 ESP32 上的同一套逻辑处理,客户端只是入口。

9.6 优篮子 D200H

桌面上还有一块优篮子 D200H,按键布局接近 Stream Deck。有了 REST 之后,我给它写了一个插件,把升降桌和番茄时钟做到三个实体键上。

三个键的动作都是固定 REST,不在插件里拼原厂协议:

按键调用键面显示
请坐POST /api/v1/desk/preset/1/goto坐姿目标高度和当前高度
站立POST /api/v1/desk/preset/4/goto站姿目标高度和当前高度
番茄时刻POST /api/v1/reminder/action{"action":"start_focus"}设备端剩余倒计时

三个键共用一次 GET /api/v1/desk/status 轮询。倒计时在 ESP32 上跑,插件不在电脑里另起一套计时器。网关离线时键面显示「离线」,不会继续刷陈旧高度。

番茄时钟默认专注 25 分钟、短休息 5 分钟,每完成 4 个专注再进 15 分钟长休息。提醒只出语音、音效和状态,不会到点自动升桌或降桌。

优篮子 D200H 实体键

源码在 integrations/ulanzi-d200h,不能直接复制进 UlanziStudio,要按插件 README 用官方 SDK 编译后再安装。任意一个键的属性面板里填网关地址和 X-Desk-Key,三个键共享这份配置,密钥不写进源码。电脑、UlanziStudio 和网关必须能互通,UlanziStudio 也要保持运行。.local 解析不了就改填 DHCP IP。

9.7 GoatRemote

Mac 上我还用 GoatRemote 通过 Apple TV Remote 来控制:

Mac 上的 GoatRemote
它可以把听到的话直接映射成写好的 Shell 命令。命令和键盘、旋钮一样,调用 scripts/desk-preset.sh。提示词和命令表见GoatRemote 集成

GoatRemote 命令映射

我只加了两句:

我说的话脚本
升降桌降到最低desk-preset.sh 1
升降桌升到最高desk-preset.sh 4

Action 选 shell。不在 Terminal 里时,GoatRemote 会包成 osascript / do shell script,所以脚本路径要用这台机器的绝对路径,并先改好脚本里的 DESK_BASE_URLDESK_KEY

9.8 小智 AI 硬件

桌上这块是晶彩的 JC3636W518C,1.8 英寸圆屏,小智固件里的板卡名是太极派 taiji-pi-s3。我没有一直用官方预编译包,而是在 xiaozhi-esp32 里给这块板加了一套空闲界面:左右滑动三个页面,小智进入对话后仍回到原来的聊天界面。点到没有按钮的区域,还是原来的对话开关。

三页分别是:

页面做什么
主页时间、日期,以及桌面高度和番茄钟是否在跑
升降桌显示当前高度;坐姿、站姿两个键,两秒内再点一次才发送,只走档位 1 和档位 4,没有持续升降
番茄钟显示设备端倒计时,可以开始、暂停、继续和停止

小智 AI 圆屏界面

后台自己轮询 Desk Gateway 的 GET /api/v1/desk/status,按键走同一组 REST。网关地址和 X-Desk-Key 写在本机 sdkconfig 里,不进 Git。URL 或密钥留空时,控制中心还能看时间,桌控和番茄钟会显示未配置。童锁打开或上升被拦住时,页面会直接提示,不会再发运动。硬件侧部署见小智固件与本地 Server

语音仍先说「你好小智」,等进入聆听后再下指令。

9.9 从小智智能体到升降桌

触摸页面会直接调用局域网 REST。语音这条没有改成设备直连,仍走小智云智能体上的 MCP,再进 Mac 上的桥接。这张图是语音链路:

小智语音到升降桌的链路示意

小智智能体只看到五个固定 MCP 工具。Mac 上的桥接程序把工具映射到固定 REST 路由,Desk Gateway 的地址和访问密钥保存在本地配置文件中。桥接已经交给 launchd 常驻,登录后自动启动,异常退出后也会重新拉起。用法见小智 AI 控桌,桥接代码在integrations/xiaozhi-mcp

小智 AI 硬件成功连接

小智官方在 MCP IoT Control Usage 中把 MCP 作为 IoT 控制协议,后端通过 initializetools/listtools/call 发现并调用工具。这里复用的是同一套机制,区别只是工具最终调用了我自己的 Desk Gateway,而不是直接控制小智硬件上的外设。

WebSocket 和 initialize 仍用官方 mcp_pipe.py,我不重写那一层。自己写的是 stdio 上的 MCP Server:tools/list 只报五个名字,tools/call 落到固定函数,函数里再打局域网 REST。路径、Method、X-Desk-Key 都写死在代码里,模型传不进来。

desk_mcp.py 核心就是注册这五个工具:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def create_mcp_server() -> FastMCP:
client = DeskGatewayClient(DeskGatewayConfig.from_env())
server = FastMCP("DeskGateway")

@server.tool(name="desk.get_status")
def get_status() -> dict:
return client.get_status()

@server.tool(name="desk.goto_sit")
def goto_sit() -> dict:
return client.goto_sit()

@server.tool(name="desk.goto_stand")
def goto_stand() -> dict:
return client.goto_stand()

@server.tool(name="desk.raise_to_max")
def raise_to_max() -> dict:
return client.raise_to_max()

@server.tool(name="desk.stop")
def stop() -> dict:
return client.stop()

return server

真正发 HTTP 的是 desk_gateway_client.py。工具没有 URL 参数,路径在客户端里写死:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def _request(self, method: str, path: str) -> dict:
request = Request(
f"{self._config.base_url}{path}",
data=b"" if method == "POST" else None,
method=method,
headers={
"Accept": "application/json",
"X-Desk-Key": self._config.api_key,
},
)
with urlopen(request, timeout=self._config.timeout_seconds) as response:
return json.loads(response.read().decode("utf-8"))

def goto_sit(self) -> dict:
self._status_preflight()
self._request("POST", "/api/v1/desk/preset/1/goto")
return {"ok": True, "state": "started", "target": "sit"}

def stop(self) -> dict:
self._request("POST", "/api/v1/desk/stop")
return {"ok": True, "state": "stopped"}

坐姿就是档位 1,站姿就是档位 4。运动前会看童锁和 REST 来源开没开;STOP 不做这些检查。okstate=started 只表示网关接了命令,不表示桌子已经到位。

9.9.1 开放的语音操作

桥接只提供固定动作,不接受大模型生成的 URL、Header 或任意目标高度:

我说的话MCP 工具Desk Gateway 执行的动作
“升降桌现在多高”desk.get_status查询当前高度、运动状态和安全状态
“把升降桌调到坐姿”desk.goto_sit前往档位 1
“把升降桌调到站姿”desk.goto_stand前往档位 4
“把升降桌升到最高”desk.raise_to_max在设备侧持续检查 ToF,到达最高安全位置后停止
“停止升降桌”desk.stop发送固定 STOP 指令

“站姿”和“最高”是两个动作。站姿使用已经配置的档位 4,最高则使用 Desk Gateway 的安全上限。raise-to-max 开始前会检查真实 ToF 高度、Driver 能力、童锁和向上阻挡状态;开始运动后,最终停止由 ESP32 本机负责,不依赖小智、MCP 或 Mac 桥接继续在线。

“升高一点”“降低一点”和“调到 75 厘米”暂时不会执行。当前桥接没有开放持续按住升降,也没有把任意高度作为参数交给大模型。语音识别有延迟,紧急情况下仍应使用原厂面板、实体 STOP 或直接断电。

10 番茄时钟

OLED 只能看状态。坐久了要有人提醒,这块我放在 ESP32 上跑,不依赖浏览器开着。Web、D200H 和小智圆屏都只是入口,倒计时、到期和语音都以网关为准。

默认专注 25 分钟、短休息 5 分钟,每完成 4 个专注再进 15 分钟长休息。到期后等我手动开始下一阶段,不会在后台把休息时间偷偷扣掉。提醒只出语音、音效和界面状态,不会到点自动升桌或降桌。设备重启后倒计时回到空闲,时长和音量配置还在,但不会补播上一次没听完的提醒。

语音走 I2S 到 MAX98357A,再推扬声器。WAV 放在独立的 audio 分区里,更新语音必须完整烧录,不能只烧 app

ESP32-S3MAX98357A说明
3V3 或板背短接后的 5VVINYD-ESP32-S3 排针 5V 出厂不通。不要用桌子 RJ45 红线
GNDGND与 ESP、USB 电源共地
GPIO14BCLKI2S Bit Clock
GPIO15LRC / WSI2S Word Select
GPIO16DINESP 到功放的数据
SPK+ / SPK-扬声器接在两端之间,任意一端都不接 GND

MAX98357A 功放与扬声器接线

REST 提供开始、暂停、继续、跳过、停止、延后 5 分钟、音量、静音和试听。状态机和接线见番茄语音提醒

11 童锁、自动童锁和禁用原厂面板

升降桌必要的童锁功能也添加上了, 裁决放在 ESP32 上,客户端只负责开关。

全局童锁打开后,除了 STOP 和解除童锁,REST、串口、蓝牙和原厂面板都不能启动或维持运动。Web 和手机 App 都能开关。童锁关掉之后,每个来源还要过自己的权限:REST、Bluetooth、Panel 可以分别关掉。关掉某个来源会先停掉当前运动, STOP 始终放行。

这里还加了个自动童锁,我离家后会自动打开。

这个功能需要一部授权手机。这部手机通过加密 BLE 或已认证 REST 大约每 30 秒发一次 Presence。两种通道都断了大约 3 分钟后,固件用 auto_away 来源锁上。其他已经配对的手机在线,不会延长倒计时。人回家后,只解除这次自动锁;我自己手动打开的童锁,仍要手动关。

原厂面板可以单独禁用。Web 设置里关掉 Panel,或者 POST /api/v1/desk/access{"source":"panel","enabled":false}。关掉后面板升、降、档位都无效,STOP 还在。重新打开时,如果手指一直按着旧按键,桌子不会立刻动,必须先松开再按。裁决顺序见架构总览里的童锁与仲裁。

12 升降桌网关的后续设计

12.1 当前原型的硬件形态

当前原型以 ESP32-S3 开发板为主控。控制盒侧使用 GPIO4 和 GPIO5,原厂面板侧使用 GPIO6 和 GPIO7。TOF050C、TOF400C 和 OLED 共用 GPIO10 和 GPIO11 的外设 I²C 总线。板型见ESP32-S3 N16R8 选型

ESP32-S3 开发板原型

Web、手机和旋钮已经分别控制过真实升降桌。小智这块圆屏已经能轮询 Desk Gateway 的实时高度。Web 和 OLED 能显示两颗 ToF 的实时数据。当前硬件仍然使用开发板和临时接线,适合继续调试,还不适合长期安装。

12.2 多厂商升降桌协议适配

我把不同升降桌的协议实现放在 Desk Driver 中,自己的升降桌使用 mxtark。Web、手机、手表和 BLE 只调用统一控制接口,不直接依赖具体桌型协议。

Upsy Desky 的分层方式可以用于后续 Driver 设计。以后会根据真实设备和抓包结果增加 UART、Loctek 或 Jiecang 等协议支持。可插拔 Driver 的边界写在平台架构设计里。

13 硬件清单

这是我这套原型实际用到的东西。当前还是开发板加飞线,不是成品套件。GPIO 和接法以仓库 README真机验收清单为准。

以下大部分硬件都是之前玩儿树莓派剩下的, 所以整体成本没有超过 100, 但是前前后后折腾了 2 周左右, 总算是完工了.

ESP32 用 USB-C 单独供电。桌子 RJ45 红线 3.3V 只给 CLK/DAT 上拉,不要接到 ESP32 的 3V3

13.1 网关本体

数量型号用途
1ESP32-S3 N16R8(源地 / VCC-GND)主控。Flash 16MB、PSRAM 8MB
1USB-C 线和 5V 电源给 ESP32 供电,日常走板载 USB 转 UART 口
1双口 RJ45 模块左口接控制盒,右口接原厂面板
2网线控制盒 ↔ 左口,原厂面板 ↔ 右口
22 kΩ 电阻(可用 2.2 kΩ)拔掉原厂面板后,补 CLK/DAT 上拉到桌子 3.3V
若干杜邦线 / 飞线临时接线

升降桌本身是非智能双电机、原厂 Mxtark 面板。面板协议和线序对不上的,这一套不能直接套。

13.2 传感器、屏幕和语音

数量型号用途
1TOF400C / VL53L1X桌板朝地,产品高度
1TOF050C / VL6180X桌面朝右,低位上升保护
10.91 英寸 SSD1306 OLED,128×32本地状态,和两颗 ToF 共用 GPIO10 / GPIO11
1MAX98357AI2S 功放,GPIO14 / 15 / 16
1扬声器接模块 SPK+ / SPK-,任意一端都不接 GND

两颗 ToF 的 SHUT 还要各占 GPIO12、GPIO13。功放和扬声器的真机音质还没验收。

红黄蓝三颗状态灯固件已经接到 GPIO1 / GPIO2 / GPIO8,各串一只限流电阻(红黄 220 Ω~330 Ω,蓝 68 Ω~100 Ω)。真机观感未验收,不做也可以,不影响控桌。

13.3 控制端(可选)

网关本身不依赖这些。有了局域网 REST 之后再加:

数量型号用途
1晶彩 JC3636W518C桌上小智终端,固件板卡名 taiji-pi-s3
1优篮子 D200H实体键:请坐 / 站立 / 番茄
1客制化键盘Karabiner 快捷键切坐姿、站姿、停止
1桌面旋钮顺时针升、逆时针降、按下停止
已授权手机App 控桌;自动童锁只绑其中一部

GoatRemote 跑在 Mac 上,不另买硬件。

13.4 调试

数量型号用途
1逻辑分析仪抓原厂 CLK/DAT,只接 GND / CLK / DAT,不接 3.3V
1万用表核对线序、上拉电阻和共地

13.5 完整接线图

这张图是当前原型的完整接线:

当前原型的完整接线图

14 开源地址

固件、手机 App、Watch、脚本和文档都在这个仓库:

github.com dong4j/desk-gateway GitHub - dong4j/desk-gateway: Open-source standing desk smart gateway for ESP32-S3 Open-source standing desk smart gateway for ESP32-S3

MIT 许可。现在是能跑的原型,V1 还没打 tag。

顶层目录大致是:

1
2
3
4
5
6
firmware/desk-gateway/   ESP32-S3 主固件(ESP-IDF)
mobile/app/ iPhone / Android(React Native + Expo)
mobile/watch/ 独立 Apple Watch App
integrations/ 小智 MCP、D200H、Karabiner、GoatRemote
scripts/ 固件检查、烧录、desk-preset.sh
docs/ 接线、协议、REST、当前状态

第三方入口在 integrations/,脚本在 scripts/,都接到同一套局域网 REST,不直接碰厂商协议。接线、GPIO 和烧录以仓库 README真机验收清单为准。文档总目录是docs/README.zh-CN.md

桌上那块小智圆屏的空闲界面改在另一份 xiaozhi-esp32 工程里,不在这个仓库。

15 总结

这套东西现在能日常用了。桌边原厂面板还在,Web、键盘、旋钮、手机、手表、D200H、GoatRemote 和小智发的是同一组升、降、停、档位指令,ESP32-S3 再翻译成控制盒认的键码。产品高度不看数码管,看桌板底下那颗 TOF400C。童锁开着的时候,除了 STOP 和开锁,REST、蓝牙和原厂面板都不能让桌子动。

过程里有两处比较大的弯路。拔掉原厂面板等于拔掉 CLK/DAT 上拉,控制盒读不到键码,补 2 kΩ 到桌子 3.3V 之后升降才通。后来想用软件 I²C 同时应答五个地址,把高度从数码管段码里抠出来,桌子会在运动中自己停掉,最后退回硬件 I²C Slave 只回 0x24,高度改外接 ToF。

目前这个项目已经达到我的预期了,后续有机会再来集成其他协议的升降桌。

下一步可能就是改我的键盘了:
下一步改造的键盘

接上 iPhone7P 的线性马达来实现打字震动的效果 😎.

目前遇到的问题是并联 3 个线性马达后震动就非常弱了, 怀疑是电流太小的问题, 有哪位大佬可以指点一下呀, 感谢 🙏🏻.