最近我把家里的乐歌 E2 升降桌接入了 Home Assistant,并进一步暴露到 Apple Home 里。最后的效果是:在 Home App 里点一下“sit”或“stand”,桌子就会自动去到坐姿或站姿记忆高度。
这件事一开始看起来像是“找两根线上下短接一下”的简单小活,实际折腾下来才发现,乐歌这类桌子的手控器接口并不是传统按键短接,而是一套跑在 RJ45 线里的 UART 串口协议。
这篇文章记录完整过程:怎么识别引脚、怎么确认协议、怎么用 ESP32 + ESPHome 接入 Home Assistant,以及最后如何解决供电和 HomeKit 控制。
最终成果
硬件上,桌子的原手控面板仍然保留,ESP32 只是并联接入手控接口的串口线。
软件上,现在有这样一条链路:
1
2
3
4
5
6
7
8
9
Apple Home / Siri
↓
Home Assistant
↓
ESPHome API
↓
ESP32 UART
↓
乐歌 E2 升降桌控制器
最终可用能力:
- Home Assistant 中有两个按钮:
sit和stand - Apple Home 中也可以控制这两个动作
- ESP32 不需要额外 USB 电源,直接由桌子 RJ45 接口中的 5V 供电
- 原桌子手控器不受影响,仍然可以正常按实体按钮
硬件清单
这次用到的东西不少,我第一次接触了单片机:
- 乐歌 E2 升降桌
- ESP32-WROOM-32 开发板,30Pin,Type-C 口
- RJ45 转 8Pin 免焊接线端子
- 杜邦线若干
- 万用表
- 一台树莓派,运行 Docker 版 Home Assistant
因为我不会焊接,所以整个方案尽量围绕“免焊、可拆、可回滚”来做。所有连接都是杜邦线加螺丝端子。
第一阶段:确认它不是普通按键
我最开始的直觉是:升降桌手控器上有上升、下降、1、2、3、4 记忆位,那 RJ45 里面也许就是几根按键线,按下某个按钮就是某根线接地。
结果不是。
用 ESP32 的 UART 调试后发现,桌子在静止时也会持续广播数据。波特率测试下来,9600 最稳定。
静止时能看到类似这样的帧:
1
2
9B:04:11:7C:C3:9D
9B:07:12:00:00:00:B8:94:9D
桌子移动时,数据会明显变化,例如:
1
2
3
7F:FD:06:F2:65:9D
7F:87:3F:80:86:9D
06:06:7F:F9:36:9D
这一步非常关键。它说明:手控器接口不是简单按键短接,而是 UART 串口通信。
RJ45 引脚测量
RJ45 水晶头方向按下面这个姿势看:
- 金属触点朝上
- 卡扣朝下
- 线朝远离自己
我测到的结果:
pin1:约 5.11Vpin2:GNDpin3:约 4.1Vpin4:约 4.2V- 其它脚约 0V
后续验证下来:
pin1可以给 ESP32 的VIN供电pin2是 GNDpin3/pin4是 UART TX/RX
这里有一个风险点:ESP32 的 GPIO 严格来说不是 5V tolerant,而这里 UART 线测出来有 4V 多。我的实际测试能工作,但长期更严谨的方案应该在桌子 TX 到 ESP32 RX 之间加电平转换或分压。当前版本先按最小改动完成。
ESP32 接线
ESP32 这边使用:
GPIO16/D16GPIO17/D17GNDVIN
最终接线:
1
2
3
4
RJ45 pin1 +5V -> ESP32 VIN
RJ45 pin2 GND -> ESP32 GND
RJ45 TX -> ESP32 RX / GPIO16
RJ45 RX -> ESP32 TX / GPIO17
注意:ESP32 的 TX 要接桌子 RX,ESP32 的 RX 要接桌子 TX。如果收不到数据,就先交换 TX/RX。
另外,桌子的 5V 要接 ESP32 的 VIN,不要接 3V3。VIN 是 5V 输入,会经过板载稳压芯片变成 ESP32 使用的 3.3V;3V3 是板上已经稳压后的 3.3V 电源脚,把 5V 接上去很可能烧板。
测试桌子 5V 供电时,我先拔掉了电脑 USB,只让桌子的 pin1 -> VIN、pin2 -> GND 给 ESP32 上电。确认它能稳定上线后,再接回 TX/RX 测试 sit 和 stand。
ESPHome 配置
最终 ESPHome 配置非常短:
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
esphome:
name: desk
esp32:
board: esp32dev
framework:
type: arduino
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
logger:
baud_rate: 0
api:
ota:
platform: esphome
uart:
id: desk_uart
baud_rate: 9600
tx_pin: GPIO17
rx_pin: GPIO16
button:
- platform: template
name: "sit"
id: sit
on_press:
- uart.write:
id: desk_uart
data: [0x9b, 0x06, 0x02, 0x04, 0x00, 0xac, 0xa3, 0x9d]
- platform: template
name: "stand"
id: stand
on_press:
- uart.write:
id: desk_uart
data: [0x9b, 0x06, 0x02, 0x08, 0x00, 0xac, 0xa6, 0x9d]
这里一开始我用的是 switch,后来改成了 button。原因很简单:坐姿和站姿不是“开关状态”,而是“执行一次命令”。如果用开关,Home Assistant 会误以为它有持续的 on/off 状态;但实际上点完 stand 并不代表桌子一定已经到位,也无法知道中途是否被手控器打断。
所以现阶段最准确的模型是:
sit:发送一次坐姿记忆位命令stand:发送一次站姿记忆位命令
等以后解析出高度帧,再考虑升级成 sensor + number + select,甚至做成类似窗帘的 cover 模型。
当前状态读取
后来我又继续做了一轮状态读取测试。目标不是继续深度逆向整套协议,而是先解决一个实际问题:Home Assistant 至少要知道桌子现在大概是坐姿、站姿,还是正在移动。
静止时,桌子会持续发这样的帧:
1
2
9B:04:11:7C:C3:9D
9B:07:12:00:00:00:B8:94:9D
但这两种静止帧本身不能区分坐姿和站姿。也就是说,如果 ESP32 刚刚冷启动,而桌子没有发生过任何动作,单靠这两条空闲数据无法判断桌子当前到底在什么高度。
真正有用的是坐站切换过程中的帧。测试下来,目前可以用下面几类信号做判断:
1
2
3
9B:04:15:BF:C2:9D # 桌子正在移动
9B:07:12:07:CF:...:...:9D # 坐姿预设相关状态
9B:07:12:06:06:...:...:9D # 站姿预设相关状态
所以现在 ESPHome 里多了几个状态实体:
1
2
3
4
text_sensor.desk_posture # sitting / standing / unknown
binary_sensor.desk_sitting
binary_sensor.desk_standing
binary_sensor.desk_moving
这里没有把坐姿和站姿做成一个 switch。原因是它并不是真正的开关:物理面板也可以改变桌子状态,中途还可能被打断。更准确的设计是保留 sit / stand 两个按钮,再单独暴露只读状态。
实际测试结果如下:
1
2
3
4
5
6
7
8
9
10
11
stand -> sit:
初始 standing
按 sit 后 moving=true
约 21 秒后 moving=false
最终 sitting=true, standing=false, posture=sitting
sit -> stand:
初始 sitting
按 stand 后 moving=true
约 21 秒后 moving=false
最终 sitting=false, standing=true, posture=standing
另外,我还测试了直接按桌子物理面板的 1 键。桌子回到坐姿后,Home Assistant 也同步读到了:
1
2
3
4
sitting=true
standing=false
moving=false
posture=sitting
这说明 ESP32 现在不是只知道“自己发出的命令”,而是真的在旁路监听桌子的 UART 通信。无论是 Home Assistant 触发,还是手按物理面板,只要桌子完成一次坐站切换,状态都能被更新回来。
当前限制也很清楚:冷启动后的第一次静止状态可能只能恢复上一次记录,不一定代表真实高度。但只要之后发生一次坐站动作,状态就会重新变得可信。对我现在的用法来说,这已经够用了。
Home Assistant 接入
Home Assistant 是新装在树莓派上的 Docker 版,地址类似:
1
http://192.168.31.123:8123
ESPHome 节点刷好后,Home Assistant 自动发现了 desk。添加 ESPHome integration 后,就能看到两个实体:
1
2
button.desk_sit
button.desk_stand
我还在路由器里给 ESP32 做了 DHCP 静态绑定,这样每次断电重连后,IP 不会漂移,Home Assistant 连接更稳定。
接入 Apple Home
要把 Home Assistant 里的实体暴露到 Apple Home,使用的是 Home Assistant 的 HomeKit Bridge。
路径:
1
Settings -> Devices & services -> Add Integration -> HomeKit Bridge
创建 HomeKit Bridge 时,只选择暴露桌子相关实体:
1
2
button.desk_sit
button.desk_stand
然后 Home Assistant 会给出 HomeKit 配对二维码或 PIN。打开 iPhone 的 Home App,添加配件,扫码或输入 PIN 即可。
Docker 版 Home Assistant 如果遇到 HomeKit 找不到 Bridge,通常和网络模式、mDNS 广播有关。最省心的方式是让 Home Assistant 容器使用 host network,让它和局域网设备处在同一网络广播环境中。
供电:从电脑 USB 到桌子 5V
一开始 ESP32 是电脑 USB-C 供电,只接了:
1
2
RJ45 GND -> ESP32 GND
RJ45 TX/RX -> ESP32 RX/TX
这叫“监听模式”,比较安全,但不适合长期使用:桌子下面还要吊一根 USB 电源线,非常不优雅。
后来确认 RJ45 pin1 是约 5.11V 后,我把它接到了 ESP32 的 VIN。测试结果稳定,不需要外接 USB 电源。
这里的注意事项:
- 5V 接
VIN,不要接3V3 - GND 接真正的
GND - 测试时不要同时接电脑 USB 和桌子 5V
- 用万用表电压档测
pin1到pin2,不要用电流档直接短接测量
我还犯过一个小错误:曾经把 GND 接到了旁边的 D13。好在当前固件没有使用 D13,实际没有造成影响。但这个错误提醒我,最后一定要把线固定好,不能让杜邦线长期处于受力和晃动状态。
现在还不完美的地方
功能上已经可用了,但外观还比较工程样机:一根粗 RJ45 线、几根杜邦线、一块裸露 ESP32 板子吊在桌子下面。
后续准备做一个小盒子,把 RJ45 端子和 ESP32 收进去:
- 使用小 ABS 项目盒
- 换短款柔软扁平 RJ45 线
- ESP32 和端子用双面胶或固定座固定
- 线入口做应力释放,避免外部拉扯直接作用到螺丝端子
- 整个盒子用魔术贴固定在桌板底部
目标是最后只看到一个小盒子和一根短线,而不是一团实验线。
这次折腾的收获
这次最大的收获不是“让桌子能被手机控制”,而是把一个原本封闭的普通家具有条理地拆成了几个可理解的问题:
- 它的接口是不是电源和按键?
- 如果不是按键,它在说什么协议?
- 怎么只监听,不冒险干扰原控制器?
- 怎么发最小命令完成坐姿/站姿?
- 怎么让 Home Assistant 和 Apple Home 认为它是一个正常智能家居设备?
- 怎么把临时 USB 供电改成桌子内部供电?
最后答案很简洁:
1
2
3
4
5
6
乐歌 E2 RJ45 手控接口
-> UART 9600
-> ESP32 + ESPHome
-> Home Assistant
-> HomeKit Bridge
-> Apple Home
现在这张桌子终于不只是“电动升降桌”,而是家里智能家居系统的一部分了。
下一步:把桌子变成真正的健康提醒
现在的桌子已经可以被 Home Assistant 和 Apple Home 控制,但它本质上还是“手动触发”:我想坐下或站起来时,点一下按钮。
下一步我想把它做成一个更主动的健康工作流:结合传感器和番茄钟,让桌子在合适的时机自动提醒甚至强制切换坐站状态。
初步设想是:
- 用传感器判断我是否真的在桌前,比如人体存在传感器、椅子压力传感器,或者电脑是否处于活跃状态
- 用 Home Assistant 做番茄钟逻辑:工作 25 分钟后自动切到
stand - 站立 5 分钟后再允许切回
sit - 如果传感器判断我不在桌前,就不触发升降,避免空桌子自己动
- 后续如果能解析出桌子的高度数据,再加入“已经在站姿就不重复执行”的判断
这一版的关键不再是“能不能控制桌子”,而是“什么时候应该控制”。智能家居真正有用的地方,往往不是把手机变成遥控器,而是让设备根据场景自己做对的事。
实战续集:把番茄钟真的做出来了
上面是设想,后来我把它完整落地了。最终效果是:
- 我在桌前工作满 30 分钟,桌子自动切换坐 / 站姿势(节律就是“每 30 分钟换一次姿势”,换姿势后的前 5 分钟当作“换脑子”)
- 我离开桌子,番茄钟自动暂停,桌子不会对着空座位自己升降
- 离开超过 10 分钟(比如午饭)回来,自动重开一轮,回到坐姿重新计时
- 录歌、开 LogicPro、开会时,我进一个 macOS 专注模式,桌子整体冻结
- 晚上 23:00 到次日 07:00 硬冻结,任何原因都不动,免得半夜被动作惊到
- 全程 Mac 上不挂任何 GUI 程序,纯后台
这一版折腾下来,真正花时间的不是“写自动化”,而是踩平台环境的坑。下面把架构、判断逻辑和三个硬核坑都记下来。
怎么判断“我在不在桌前”
这是整件事的输入信号。我比较过几条路:
- ping / 在线探测:只能判断“开机在线”,区分不了“在用”和“合盖睡眠”,不行。
- HA Companion App:装一个官方 macOS App 就能上报
Active状态,最省事。但它是个常驻 GUI 程序,而且默认上报一大堆信息(前台 App、摄像头、位置……)。我想要无感,不行。 - 最终方案:读 macOS 自带的
HIDIdleTime(距离上次键鼠操作的纳秒数),后台脚本定时上报给 HA。这是唯一能真正反映“键鼠有没有在动”的信号。
1
ioreg -c IOHIDSystem | awk '/HIDIdleTime/ {print int($NF/1000000000)}'
整体架构与数据流
最终链路长这样(后面会解释为什么中间要绕一道 webhook):
1
2
3
4
5
6
7
8
Mac launchd(每 20 秒)
└─ /usr/bin/curl ──HTTP──▶ HA Webhook(接收 idle 秒数)
└─ 自动化 mqtt.publish ─▶ mosquitto(topic: mac/desk/idle)
└─ MQTT sensor(expire_after 90s)
└─ binary_sensor.mac_at_desk(idle < 180s)
└─ should_run(在桌前 且 未专注 且 非夜间)
└─ timer(30 分钟)
└─ 到点 ─▶ button.desk_sit / desk_stand
mosquitto 用 Docker 跑在树莓派上,关掉匿名、开账号密码和 ACL。HA 用 ha 账号连它。
判断逻辑:把“该不该动”拆成可独立验证的布尔量
我没有把逻辑堆成一坨,而是拆成几个一眼能测真值的中间量:
1. 在不在桌前
1
2
3
4
# binary_sensor.mac_at_desk
state: >
{% set v = states('sensor.mac_idle_seconds') %}
{{ v not in ['unknown','unavailable','none', None] and (v | int(99999) < 180) }}
注意 MQTT sensor 上挂了 expire_after: 90:Mac 睡眠 / 合盖后 launchd 不再上报,90 秒内收不到数据,传感器自动失活 → at_desk 变 off → 桌子默认不动。这是个“失联即安全”的兜底。
2. 番茄钟该不该运行(三个闸门合一)
1
2
3
4
5
6
7
# binary_sensor.desk_pomodoro_should_run
state: >
{% set t = now() %}
{% set night = t.hour >= 23 or t.hour < 7 %}
{{ is_state('binary_sensor.mac_at_desk','on')
and is_state('input_boolean.desk_pause_focus','off')
and not night }}
计时器只在 should_run 为 on 时走;离桌 / 专注 / 夜间任一成立,立刻 timer.pause。
3. 回来是“续上”还是“重开一轮”(这里藏着一个我后来才发现的坑)
这是体验上最关键的一条。短暂离开(上厕所、倒水)希望接着原进度;长时间离开(午饭)希望重新开始。我用“离开时长”统一区分:
1
2
3
4
5
6
7
8
9
10
11
# 触发器:HA 启动 + should_run 任意 → on(关键:别只写 from: "off")
# 条件:should_run 为 on,且 timer 当前不是 active(避免重复启动)
away_secs: >
{% if trigger.platform == 'state' and trigger.from_state
and trigger.from_state.state == 'off' and trigger.to_state %}
{{ (trigger.to_state.last_changed - trigger.from_state.last_changed).total_seconds() }}
{% else %}
99999
{% endif %}
# away_secs < 600 且 timer 处于 paused → timer.start(续上)
# 否则 → 起 30 分钟(开新一轮,从当前姿势开始,不强制移动桌子)
这一条同时覆盖了午饭、夜间结束、长时间录制结束等所有“离开很久”的情况,不用各写一遍。
⚠️ 别学我最初的写法:我第一版的触发器只写了
from: "off" → to: "on",埋了个雷。每次 Home Assistant 一重启,should_run是从unknown → on,并不匹配off→on,于是启动自动化根本不触发——番茄钟就一直停在 idle,桌子再也不会动;而整套又没有“开机即启”的兜底,等于只要重启过一次就废了(我清理配置连着重启了十来次,才发现它早就不转了)。修法:把触发器放宽成「任意 → on」并补上「HA 启动(
platform: homeassistant, event: start)」,再用「timer 不是 active」这个条件兜住重复启动;away_secs只在真正的off→on时才计算,其余(开机、unknown→on)一律当作开新一轮。“只认某一种状态跳变”是 HA 自动化里最常见的坑之一——一定要把重启 / 冷启动时的unknown也算进去。
4. 到点换姿势
1
2
# timer.finished 时
# 当前是坐姿 → 按 stand;否则 → 按 sit
补一条后来加的小改进:计时的本质其实是“当前姿势已经保持了多久”。所以不只“到点自动翻”会重置——你自己手动起坐切了姿势,30 分钟也从落位那一刻重新算,免得出现“刚站起来没几分钟又被翻回坐”的别扭。实现上就是监听桌子落到坐 / 站任一姿势、且
should_run为 on 时,timer.start30 分钟即可。
因为夜间 should_run 恒为 off、计时器被暂停,永远不会触发 finished,所以 23:00–07:00 的硬冻结是天然成立的,不需要额外拦截。
5. 录歌 / 开会怎么暂停
没有去逐个识别“LogicPro 在不在前台”“摄像头是否在用”这种又多又脆的条件,而是用一个 macOS 专注模式当总开关:用「快捷指令」建两条个人自动化,专注模式打开 / 关闭时各 curl 一个 HA Webhook,HA 翻转 input_boolean.desk_pause_focus。单一真相来源,干净。
三个把我卡住的坑(这次最值钱的部分)
坑一:macOS 26 的“本地网络隐私”按二进制授权
我一开始让 Mac 端脚本直接用 mosquitto_pub 把数据发给树莓派上的 broker。手动在终端跑——成功;交给 launchd 后台跑——runs 在涨、退出码 0,但 broker 一条都收不到。
换成 Python 直连,报错变清晰了:[Errno 65] No route to host。但同一个 launchd 上下文里,curl 访问树莓派却返回 200。
结论:macOS 26 的本地网络隐私是按可执行文件授权的。终端里手动跑,进程继承了终端 App 的“本地网络”授权;launchd 拉起的无头进程是独立身份,没有这个授权,于是 Homebrew 的 python3 / mosquitto_pub 访问局域网被静默拒绝。而 /usr/bin/curl 是苹果系统签名的二进制,被放行。
诊断的关键一步,是在同一个 launchd 脚本里同时试 curl 和直连 MQTT:
1
2
launchd 上下文:curl_to_pi=200 ← 网络是通的
launchd 上下文:mosquitto/python 失败 ← 只有三方二进制被拦
解法:让被放行的 curl 来跨局域网这一跳。Mac 不再直连 broker,而是 curl 一个 HA Webhook,由 HA 内部再 mqtt.publish。这样既绕开了本地网络隐私,又保住了 broker、最小权限账号和 expire_after 安全网。
坑二:mosquitto_pub 在 launchd 下报 “Bad file descriptor”
其实就是坑一的“劣质报错”——socket 被本地网络隐私拒绝后,mosquitto 的网络循环踩到坏 fd,报成 Error: Bad file descriptor,把人往 stdin/fd 的方向带偏。换 Python 看到 No route to host 才认清真相。
坑三:中文 name 生成拼音 entity_id,跨实体引用全断
HA 里 MQTT / 模板实体的 entity_id 是由 name 做 slug 生成的。我图省事给传感器起了中文名 Mac 空闲秒数,HA 把它转成了拼音:
1
2
sensor.mac_kong_xian_miao_shu
binary_sensor.mac_zai_zhuo_qian
而我模板里引用的是英文 sensor.mac_idle_seconds —— 永远取不到值,at_desk 恒为 off。现象很迷惑:数据明明在面板上跳,传感器却一直 off。
(input_boolean / timer 不受影响,它们的 entity_id 取自配置里的键名,不是 name。)
教训:会被别的模板 / 自动化引用的技术实体,name 一律用英文,让 entity_id 干净可预测;中文留给 friendly name 是不安全的,它会进到 id 里。
附带还有个小坑:
ioreg | awk '… exit}'里 awk 命中即停,会给上游ioreg发 SIGPIPE,叠加脚本的set -e -o pipefail,会让脚本拿到 idle 后“静默早退”,根本执行不到上报。把 awk 的提前exit去掉就好。
一点收获
这次最大的体会有两条。
一是把复杂判断拆成一串可独立灌值验证的布尔量:在桌前 / 未专注 / 非夜间,每一层都能单独 curl 一个值进去、再去数据库里读真值确认,排错时不至于面对一个黑盒。
二是跨平台自动化的魔鬼全在环境细节里,不在主逻辑:本地网络隐私按二进制授权、slug 的中文转拼音规则——这些东西配置语法上完全正确,却让整条链路静默失效。先把“每一跳到底通没通”验证清楚,比对着 YAML 反复读要快得多。