把乐歌 E2 升降桌改造成智能家居:ESP32 + ESPHome + Home Assistant 实战

从 RJ45 手控接口到 Apple Home,一张普通升降桌的智能化改造记录

作者: shisaq 日期: May 31, 2026

最近我把家里的乐歌 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 中有两个按钮:sitstand
  • 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.11V
  • pin2:GND
  • pin3:约 4.1V
  • pin4:约 4.2V
  • 其它脚约 0V

后续验证下来:

  • pin1 可以给 ESP32 的 VIN 供电
  • pin2 是 GND
  • pin3 / pin4 是 UART TX/RX

这里有一个风险点:ESP32 的 GPIO 严格来说不是 5V tolerant,而这里 UART 线测出来有 4V 多。我的实际测试能工作,但长期更严谨的方案应该在桌子 TX 到 ESP32 RX 之间加电平转换或分压。当前版本先按最小改动完成。

ESP32 接线

ESP32 这边使用:

  • GPIO16 / D16
  • GPIO17 / D17
  • GND
  • VIN

最终接线:

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,不要接 3V3VIN 是 5V 输入,会经过板载稳压芯片变成 ESP32 使用的 3.3V;3V3 是板上已经稳压后的 3.3V 电源脚,把 5V 接上去很可能烧板。

测试桌子 5V 供电时,我先拔掉了电脑 USB,只让桌子的 pin1 -> VINpin2 -> GND 给 ESP32 上电。确认它能稳定上线后,再接回 TX/RX 测试 sitstand

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
  • 用万用表电压档测 pin1pin2,不要用电流档直接短接测量

我还犯过一个小错误:曾经把 GND 接到了旁边的 D13。好在当前固件没有使用 D13,实际没有造成影响。但这个错误提醒我,最后一定要把线固定好,不能让杜邦线长期处于受力和晃动状态。

现在还不完美的地方

功能上已经可用了,但外观还比较工程样机:一根粗 RJ45 线、几根杜邦线、一块裸露 ESP32 板子吊在桌子下面。

后续准备做一个小盒子,把 RJ45 端子和 ESP32 收进去:

  • 使用小 ABS 项目盒
  • 换短款柔软扁平 RJ45 线
  • ESP32 和端子用双面胶或固定座固定
  • 线入口做应力释放,避免外部拉扯直接作用到螺丝端子
  • 整个盒子用魔术贴固定在桌板底部

目标是最后只看到一个小盒子和一根短线,而不是一团实验线。

这次折腾的收获

这次最大的收获不是“让桌子能被手机控制”,而是把一个原本封闭的普通家具有条理地拆成了几个可理解的问题:

  1. 它的接口是不是电源和按键?
  2. 如果不是按键,它在说什么协议?
  3. 怎么只监听,不冒险干扰原控制器?
  4. 怎么发最小命令完成坐姿/站姿?
  5. 怎么让 Home Assistant 和 Apple Home 认为它是一个正常智能家居设备?
  6. 怎么把临时 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.start 30 分钟即可。

因为夜间 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 反复读要快得多。