SergeyZh/emotion-control-ha
GitHub: SergeyZh/emotion-control-ha
通过 ESP32 与 CC2500 射频模块将 L&S Emotion Control 智能灯具桥接至 Home Assistant,实现双向控制与遥控器状态同步。
Stars: 0 | Forks: 0
# ESPHome ↔ L&S Emotion Control (CC2500)
通过 **ESPHome** 在 **Home Assistant** 中控制 **L&S Emotion Control** 灯具:
ESP32 + **CC2500** 无线电模块(2.4 GHz)复现了原始遥控器的协议,并且
额外**监听无线电波**,将物理遥控器按键的操作同步到 HA 状态中。
**状态:可用。** 支持:开/关、色温、绝对亮度(5 个档位)、配对、接收遥控器命令和状态同步、重启后状态恢复、在无线电波中搜索遥控器 ID。仅剩下可选的细节打磨。
协议已在代码库 [`emotion-control-re`](https://github.com/SergeyZh/emotion-control-re) 中完全解密(其中还包含 RF 参数、帧格式、CC2500 寄存器转储)。项目计划和阶段状态见 [`PLAN.md`](PLAN.md);AI 代理的备注见 [`CLAUDE.md`](CLAUDE.md)。
## 工作原理
- CC2500 使用从遥控器提取的寄存器配置进行设置(MSK,250 kBaud,2436.2 MHz,sync `0xD391`,FEC,关闭 whitening,硬件 CRC)。芯片在**硬件层面**处理了所有这些 —— 软件只需准备 14 字节的 payload。
- **发送:** 组装数据包 `0E | ID | cmd | val | 00×6 | chk | 16`(`chk = 0x7E ⊕ ID₀⊕ID₁⊕ID₂⊕ID₃ ⊕ 0x16 ⊕ cmd ⊕ val`),写入 TX FIFO,然后发送。
- **接收:** 芯片保持在 RX 状态;验证帧(CRC、长度、`chk`),并且那些来自我们 `remote_id` 的帧会**无需反向传输**地更新 HA 状态。来自任何其他遥控器的帧可提供给 sniffer 组件 —— 这样就能知道我们自己的遥控器 ID。
## 硬件
- **ESP32**(已在 DevKit V1 / ESP-WROOM-32 上测试)。
- **CC2500 模块** —— 必须是 2.4 GHz(不是 CC1101),**26.000 MHz 晶振**(否则提取的 RF 寄存器会产生不同的频率/速率)。引脚图见 [`datasheets/CC2500_module_pinout.md`](datasheets/CC2500_module_pinout.md)。
- **模块电源为 3.3V(`3V3` 引脚),不是 5V。**
### 接线(CC2500 模块 → ESP32,SPI)
| 模块 | 信号 | ESP32(示例,VSPI) |
|---|---|---|
| SI (2) | MOSI | GPIO23 |
| SCLK (3) | SCLK | GPIO18 |
| SO (4) | MISO | GPIO19 |
| CSn (8) | CS | GPIO5 |
| GDO0 (7) | 发送结束(可选) | GPIO4 |
| VDD (1) / VSS (6) | 3V3 / GND | 3V3 / GND |
`GDO2` 未使用。`GDO0` 是可选的:如果未连接/未指定,发送结束将通过轮询 `MARCSTATE` 寄存器来确定。在接收过程中不使用 `GDO0`。
## 快速开始
```
# 1. 安装 ESPHome(例如 — 在项目目录中使用 venv)
python3 -m venv .venv
./.venv/bin/pip install esphome
# 2. 获取遥控器 ID:刷入 sniffer 并按下遥控器上的按钮
./.venv/bin/esphome run sniffer.yaml --device /dev/cu.usbserial-0001
# → [I][cc2500_emotion_sniffer]: remote 68049F25 cmd=0x01 (on) ...
# 详情 — 参见“如何获取遥控器 ID”章节。
# 3. 在 example.yaml 旁创建 secrets.yaml
printf 'wifi_ssid: "ВашаСеть"\nwifi_password: "пароль"\n' > secrets.yaml
# 4. 将找到的 ID 填入 example.yaml 中的 remote_id,并通过 USB 刷入
# (首次通过 USB,之后可以通过 OTA)
./.venv/bin/esphome run example.yaml --device /dev/cu.usbserial-0001
```
日志中成功初始化的标志:
```
CC2500 PARTNUM=0x80 VERSION=0x03
Radio check: SYNC1=0xD3 MDMCFG2=0x73 CHANNR=0x10
Radio configured and calibrated.
Listening for remote commands (RX enabled).
```
## 配置
组件位于 [`components/`](components/) 中,并作为 external component 连接。完整示例见 [`example.yaml`](example.yaml);不带 Wi-Fi 的最小测试平台见 [`bench.yaml`](bench.yaml)。
```
external_components:
- source:
type: local
path: components
spi:
clk_pin: GPIO18
mosi_pin: GPIO23
miso_pin: GPIO19
cc2500_emotion:
id: emotion
cs_pin: GPIO5
gdo0_pin: GPIO4 # опционально
remote_id: "68049F25" # ID вашего пульта (4 байта hex) — см. «Как узнать ID пульта»
receive: true # слушать эфир и синхронизировать состояние
step_interval: 200ms # пауза между посылками шагов яркости
light:
- platform: cc2500_emotion
name: "L&S Emotion"
cc2500_emotion_id: emotion
brightness_levels: 5 # число ступеней яркости лампы
ramp_duration: 1000ms # время полного плавного хода мин↔макс
cold_white_color_temperature: 6500 K
warm_white_color_temperature: 2700 K
```
### `cc2500_emotion` 选项(hub)
| 选项 | 默认值 | 描述 |
|---|---|---|
| `cs_pin` | —(必填) | 模块的 CS (CSn) |
| `gdo0_pin` | —(可选) | 用于发送结束计时的 GDO0;否则回退到 `MARCSTATE` |
| `remote_id` | —(可选) | 4 字节的遥控器 ID(hex,可带 `:`)。若不提供,则禁用发送,设备仅监听无线电波 |
| `receive` | `true` | 接收遥控器命令并同步 HA 中的状态 |
| `step_interval` | `200ms` | 亮度步长单次发送之间的间隔(发送过快灯会将其合并忽略) |
加上标准的 `spi` 键(`cs_pin` 取自 SPI 设备 schema)。
### `light` 平台选项
| 选项 | 默认值 | 描述 |
|---|---|---|
| `cc2500_emotion_id` | (自动) | 指向 `cc2500_emotion` hub 的引用 |
| `brightness_levels` | `5` | 灯的离散亮度级数 |
| `ramp_duration` | `1000ms` | 完整亮度渐变所需时间(用于评估遥控器的 ramp) |
| `cold_white_color_temperature` | `6500 K` | 色温的冷端极值 |
| `warm_white_color_temperature` | `2700 K` | 色温的暖端极值 |
| `restore_mode` | `RESTORE_DEFAULT_OFF` | 重启时的状态恢复(从基础的 `ALWAYS_OFF` 重写) |
| `gamma_correct` | `1.0` | 线性(请勿更改 —— 否则会破坏步长映射) |
| `default_transition_length` | `0s` | 步进式设备没有平滑过渡 |
### 额外按钮(来自 `example.yaml` 的示例)
作为调用 hub 方法的原生 `template button` 实现:
- **配对** → `id(emotion).send_pair();`
- **调亮 / 调暗** → `id(emotion).brightness_step(1);` / `(-1);`(始终发送步进指令,重新同步 UI↔灯)
- **Raw send** + 两个文本字段(`raw_cmd` / `raw_val`,hex) → `id(emotion).send_raw(cmd, val);`
—— 用于直接在 HA 中实验协议,无需重新刷写。
## 如何查找遥控器 ID
`remote_id` 是您的遥控器附加到每个传输信号的 4 个字节;灯在配对时会记住它们。每个遥控器都有自己唯一的 ID,因此没有默认值。您可以使用同一个 CC2500 模块通过 `cc2500_emotion_sniffer` 组件来找到您的 ID。
1. 刷入预置配置:`esphome run sniffer.yaml --device /dev/cu.usbserial-0001`。由于未设置 `remote_id`,设备只会监听,不会发送任何信号。
2. 按下遥控器上的按键。每一个识别到的帧都会打印一行:
[I][cc2500_emotion_sniffer]: remote 68049F25 cmd=0x01 (on) val=0x80 RSSI=-38 dBm LQI=2
3. 您自己的遥控器是 `RSSI` 明显高得多的那个(它在您的手中)。将找到的 ID 写入正式配置的 `remote_id` 中。
Sniffer 会接收接收范围内的**所有** Emotion 遥控器,而不仅仅是您的:帧会通过硬件 CRC 以及根据接收到的 ID 字节重新计算的 checksum 进行校验 —— 因此无需猜测 ID,它要么匹配,要么帧被丢弃。
未知命令会连同完整的帧(14 字节)一起打印 —— 其中六个“保留”字节可能不为零。这同样是用于补充命令映射表的理想模式:如果您的遥控器能执行下表中没有的操作,日志将显示其代码和字节。
### 如果接收不稳定
遥控器每次按键都会重复一系列大约 5 个相同的帧 —— 这就是该协议对抗丢包的保护机制。Sniffer 会打印**每一个**副本,因此每次按键对应的行数直接反映了连接质量:4–5 行表示连接良好,1 行表示有一半副本丢失,0 表示完全没有检测到帧。
在日志行中,注意 `LQI`(解调错误计数器,**0 = 完美**)和 `offset`(载波偏移的估计值)。诊断:
| 症状 | 原因 |
|---|---|
| 大量 `Frame rejected: CRC failed` 行 | 信号已捕捉,但帧被损坏 —— 信号微弱或受到干扰 |
| 没有拒绝,但帧很少 | 接收器未检测到传输:天线、距离或屏蔽问题 |
| `offset` 持续向一个方向偏移较大 | 遥控器和模块之间晶振存在偏差 |
| `offset` 在正负 68 kHz 之间波动 | 这是 FOC 边缘(±BW/8)的噪声估计,而不是偏移 —— 无法补偿 |
| 未按键时出现 `RX FIFO overflow` 和 `length byte 0x…` | Wi-Fi 噪声的误触发(我们处于 2.4 GHz 频段内)。对数据无害,但会占用接收;已通过 `PKTLEN` 限制 |
| 读取某个遥控器信号比另一个差 | 检查电池 —— 没电的电池会导致“偶尔能收到”的现象。重新安装电池通常有效 |
⚠️ 不要尝试通过写入 `FSCTRL0` 来“修复”波动的 `offset`:在噪声估计的情况下,这会引入随机频偏,并导致接收效果变差。
### Home Assistant 中已发现的遥控器列表
如果配置中包含 sniffer 并且启用了 Wi-Fi/API,您可以直接在 HA 中查看发现的遥控器,而不仅仅是在日志中 —— 通过一个包含列表的诊断实体:
```
cc2500_emotion_sniffer:
id: sniffer
cc2500_emotion_id: emotion
text_sensor:
- platform: cc2500_emotion_sniffer
cc2500_emotion_sniffer_id: sniffer
discovered_remotes:
name: "Найденные пульты"
```
状态包含自启动以来听到过的所有遥控器,显示每个遥控器的**最佳**信号电平:
```
6804F74D -49dBm, 68049F25 -71dBm
```
按下另一个遥控器上的按钮 —— 它将出现在列表中。您的遥控器通过信号强度来确定:它在您的手中,因此电平明显更高。特意取最佳电平而不是最后一次的电平 —— 这样值就能稳定下来,实体也停止更新,而不是在每个帧上都跳动(按住按钮时每秒大约有 85 个帧)。
⚠️ **ESPHome 中的实体是在编译时创建的**,因此为空气中检测到的每个遥控器创建单独的实体在技术上是不可能的 —— `esp32_ble_tracker` 也是如此(只有预先在 YAML 中指定的地址会出现在 UI 中,其余的保存在日志中)。因此存在限制:列表最多保留 **8** 个遥控器,之后不再添加新的遥控器 —— 但日志中可见全部。
### `cc2500_emotion_sniffer` 选项
| 选项 | 默认值 | 描述 |
|---|---|---|
| `cc2500_emotion_id` | (自动) | 指向 `cc2500_emotion` hub 的引用。强制开启接收 |
| `dump_raw` | `false` | 为**每个**命令打印全部 14 字节的帧(未知命令始终会打印) |
| `on_frame` | — | 接收到帧时触发的自动化,**每次按键仅触发一次**(参见“空中电波表”) |
### Home Assistant 中的空中电波表
Sniffer 暴露了 `on_frame` 触发器 —— 它对接收范围内的任何遥控器的**每次按键**触发一次(不会重复触发系列重发)。您可以利用它在 HA 中构建一个实时的空中电波表:谁按的、按了什么、什么时候按的以及信号如何。
第 1 步 —— 将事件发送到 HA 总线(这已包含在 [`example.yaml`](example.yaml) 中):
```
cc2500_emotion_sniffer:
id: sniffer
cc2500_emotion_id: emotion
on_frame:
- homeassistant.event:
event: esphome.emotion_frame
variables:
rid: !lambda "return remote_id;"
cmd: !lambda "return command;"
cname: !lambda "return command_name;"
val: !lambda "return value;"
sig: !lambda "return rssi;"
data_template:
remote_id: "{{ rid }}"
command: "{{ cmd }}"
command_name: "{{ cname }}"
value: "{{ val }}"
rssi: "{{ sig }}"
```
第 2 步 —— 在 HA 端的实体属性中累积事件(`configuration.yaml`)。与 `text_sensor` 不同,属性没有 255 个字符的限制,因此表格可以是完整的:
```
template:
- trigger:
- platform: event
event_type: esphome.emotion_frame
sensor:
- name: "Emotion эфир"
state: "{{ trigger.event.data.remote_id }}"
attributes:
remotes: >
{% set old = this.attributes.get('remotes', {}) %}
{% set d = trigger.event.data %}
{{ dict(old, **{d.remote_id: {
'command': d.command_name,
'value': d.value,
'rssi': d.rssi,
'last_seen': now().strftime('%H:%M:%S')}}) }}
```
第 3 步 —— 仪表板上的卡片:
```
type: markdown
content: |
| Пульт | Команда | Знач. | RSSI | Слышали |
|---|---|---|---|---|
{% for id, r in state_attr('sensor.emotion_efir', 'remotes').items() -%}
| `{{ id }}` | {{ r.command }} | {{ r.value }} | {{ r.rssi }} dBm | {{ r.last_seen }} |
{% endfor %}
```
同样的触发器也适用于普通的自动化 —— 例如,将 HA 脚本挂载到另一个不控制任何灯的遥控器按钮上。
⚠️ **这不仅仅限于您自己的事件:** `on_frame` 会对接收范围内的所有 Emotion 遥控器做出反应。如果只需要自己的事件,请在自动化条件中通过 `remote_id` 进行过滤。
### `on_frame` 参数
| 变量 | 类型 | 值 |
|---|---|---|
| `remote_id` | `std::string` | 遥控器 ID,8 个十六进制字符 (`"6804F74D"`) |
| `command` | `uint8_t` | 命令字节 |
| `command_name` | `std::string` | 命令解释或 `"UNKNOWN"` |
| `value` | `uint8_t` | 数值字节 |
| `rssi` | `int` | 信号强度,dBm |
| `lqi` | `uint8_t` | 解调错误计数器(0 = 完美) |
### `cc2500_emotion_sniffer` 平台的 `text_sensor` 选项
| 选项 | 描述 |
|------|
| `cc2500_emotion_sniffer_id` | 指向 `cc2500_emotion_sniffer` 的引用 |
| `discovered_remotes` | 带有已发现遥控器列表的实体(标准 `text_sensor` schema) |
Sniffer 也可以保留在带有 `light` 的正式配置中 —— 它仅用于记录日志,不会影响对灯的控制。
## 与灯配对
如果您知道遥控器的 ID(见上文),则无需进行任何配对:设备以相同的 ID 呈现,灯会立即听从它。只有在您想使用**新** ID 而不是克隆现有 ID 时,才需要配对:
1. 将灯置于学习模式(根据 L&S 手册 —— 按住接收器上的按钮)。
2. 按下 HA 中的**“配对”**按钮 —— 设备将广播宣告其 `remote_id`(命令 `0x60`),灯会将其记住。
3. 完成:配置中的 `remote_id` 现在就可以控制这盏灯了。
⚠️ 如果使用您自己(非克隆)的 ID,无线电波的状态同步将无法工作:设备仅监听 `remote_id` 中指定的 ID,而物理遥控器发送的是它自己的。如果希望遥控器的按键反映在 HA 中,请克隆遥控器 ID。
## Home Assistant 中的实体
- **Light** (`COLOR_TEMPERATURE`):开/关,亮度(5 个步长 → 1/25/50/75/100 %),色温。
- **Button**:“配对”、“调亮”、“调暗”、“Raw send”。
- **Text**:“Raw cmd (hex)”,“Raw value (hex)”。
- **Text sensor**(诊断信息,如果启用了 sniffer):“发现的遥控器”。
**物理遥控器**的按键会自动反映在 HA 中(开/关和色温完全准确,亮度通过步长/ramp 估算)。
## 特性和限制
- **没有来自灯具的反馈。** HA 中的状态是我们的估计。准确点包括:绝对命令(开/关,色温)和亮度的边缘值;接收遥控器指令可校正估计。
- **亮度为 5 个离散档位**,滑块会进行量化(1/25/50/75/100 %)。拖动到极限位置最多需要约 1 秒(步长按 `step_interval` 间隔执行)。
- **遥控器的亮度命令是单次发出的**(不重复) → 单个信号有时会在空气中丢失;这会在到达边缘时以及下一次操作时自我纠正。
- **遥控器的平滑 ramp**(`0x10`/`0x20` 按住,`0x40` 释放)是基于时间估算的(`ramp_duration`)—— 是近似值。
- **重启时状态会恢复,但不会发送给灯具**(灯具会自行记忆)。如果在 ESP 停机期间使用遥控器更改了灯具状态,HA 将显示最后一次已知的而不是实际的状态 —— 会在首次操作时对齐。
## 协议命令(摘要)
| cmd | 动作 | val |
|---|---|---|
| `0x01` | 打开 | 当前色温 |
| `0x02` | 关闭 | `0x00` |
| `0x11` / `0x21` | 亮度向上 / 向下调一级 | `0x00` |
| `0x10` / `0x20` | 开始平滑 ramp 向上 / 向下(约 1 秒),`0x40` 停止 | `0x00` |
| `0x50` | 色温(`0x00` 暖光 … `0xFF` 冷光) | 等级 |
| `0x60` | 配对 | `0x00` |
此表之外的命令会被 sniffer 连同完整帧一起打印为 `UNKNOWN` —— 如果您的遥控器发送了其他内容,将立即可见。
按住按钮不是单次发送,而是连续流:只要按住按钮,`0x10`/`0x20` 大约**每 12 毫秒**重复一次,并以单个 `0x40` 结束。因此,在 sniffer 中,按住按钮看起来就像每秒几十行。
完整分析(RF、寄存器、帧、发现)见 [`captures/PROTOCOL-FINDINGS.md`](https://github.com/SergeyZh/emotion-control-re/blob/main/captures/PROTOCOL-FINDINGS.md)。
## 项目结构
| 路径 | 内容 |
|---|---|
| `components/cc2500_emotion/` | external component:CC2500 驱动程序 + `light` 平台 |
| `components/cc2500_emotion_sniffer/` | external component:记录空气中所有 Emotion 帧的日志 |
| `example.yaml` | 正式配置(Wi-Fi/API/OTA + light + 按钮) |
| `bench.yaml` | 工作台配置(USB,不带 Wi-Fi;BOOT 按钮切换灯具) |
| `sniffer.yaml` | 用于查找遥控器 ID 的配置(仅接收) |
| `datasheets/` | CC2500 模块的引脚分配、照片和电路图(完整的 CC2500 数据手册见[这里](https://github.com/SergeyZh/emotion-control-re/tree/main/datasheets)) |
| `PLAN.md` | 计划和阶段状态 |
| `CLAUDE.md` | AI 代理的备注(陷阱、解决方案、工作流程) |
## 开发
- ESPHome 位于 `./.venv/`(项目的 venv)。
- 仅编译(代码检查):`./.venv/bin/esphome compile bench.yaml`。
- `bench.yaml` 从 `secrets.yaml` 获取遥控器 ID(`emotion_remote_id: "…"`),这样它就不会被提交到代码库中。
- 刷写 + 日志:`./.venv/bin/esphome run example.yaml`(第一次使用 USB `--device`,之后通过 OTA)。
- ESP32 工具链缓存在 `~/Library/Caches/esphome` 中 → 后续编译非常快。
标签:CC2500, ESPHome, Home Assistant, 无线通信, 智能家居, 智能照明, 物联网