splattner/oekoboiler-uart-reverse-engineering

GitHub: splattner/oekoboiler-uart-reverse-engineering

通过逆向工程嗅探热泵热水器内部 UART 总线,无需云平台即可将水温、运行模式和隐藏的光伏模式转化为 ESPHome / Home Assistant 传感器数据。

Stars: 5 | Forks: 0

# 逆向工程:Ökoboiler 的内部 UART 总线 *无需云平台和 API,直接从热泵热水器两块电路板之间的线缆上读取水温、运行模式和光伏(PV)状态——并将其转化为 ESPHome / Home Assistant 传感器。* ## TL;DR 我的 [Ökoboiler](https://www.oekoboiler.com/) 热泵热水器属于**完全没有连接功能的老款** —— 没有 WiFi,没有 App,没有 API,什么集成的机会都没有。在它内部,位于前方的**显示/操作面板**和**主控板**之间,通过一条单线串行总线进行通讯。这条总线成为了唯一的突破口。 - 🌡️ **水箱水温** —— 精度约 0.3 °C,*比前面板 1 °C 的显示精度更高* - ⚙️ **运行模式**(加热 / 待机 / 化霜) - ♻️ **光伏 / 太阳能模式** —— 我的型号官方不支持该功能,但固件中其实完全存在;我只是加上了缺失的触发器(见下文) 这个过程带来了两个不错的成果。**水温**是最难搞定的:它隐藏在一个**会发生溢出(循环翻转),且分辨率比显示屏更高**的字节中,所以在好几天里它看起来都像是随机噪声。而**光伏模式**最终被证明一直存在于“较便宜”的硬件中 —— 这条总线让我证实了这一点,而一个价值 2 欧元的继电器让我用上了它。 ![解码后的水温字节追踪到了真实温度](https://raw.githubusercontent.com/splattner/oekoboiler-uart-reverse-engineering/main/images/fig_ce14_sawtooth_time.png) ## 硬件 | | | |---|---| | 设备 | Ökoboiler 热泵热水器 | | 主控板 | **SY-384**,基于 ATmega16 | | 总线 | 单线,半双工 UART,**2400 波特率, 8N1**,光电隔离 (PC817) | | 面板接线 | 仅 3 根线:**UART 数据 + 5 V + GND** —— 因此面板本身*没有*传感器;它显示的所有信息必然是通过总线传输过来的 | | 嗅探器 | ESP32-C6,运行 ESPHome,RX 引脚接入数据线 | “仅 3 根线”这个细节很关键:它证明了面板上显示的水温**必然**是通过这条总线从主控板发送过来的。数据就藏在这条线的某个地方 —— 我只是需要把它找出来。 ## 步骤 1 — 帧定位:在字节中寻找结构 Dump 出原始 UART 数据流后,一个重复的 **32 字节帧**非常显眼,它总是以相同的两个字节开头: ``` 64 41 ... (32 bytes) ... ``` - **`0x64 0x41`** —— 固定的帧头。 - 帧的到达频率约为每秒 3 次,持续不断,分为两种类型(下文详述)。 ### XOR 掩码 载荷数据看起来像是被扰乱过 —— 直到我注意到**字节 5 每一帧都在变化**,如果你将字节 2 到 29 与它进行 XOR(异或)运算,这帧数据就会变得稳定且易读: ``` decoded[i] = raw[i] ^ raw[5] for i = 2 … 29 ``` 字节 0、1、30、31 保持原样。解除掩码后,在字节 6 到 9 处会出现一个恒定的**签名:`F2 D4 F9 61`** —— 这是一个绝佳的检查标准,能证明你的解码是正确的(在我的抓取数据中,它跨越几十万个帧的方差为零)。
为什么要对载荷进行掩码处理? 人们很容易将其称为“加密”,但其实不是 —— 密钥就是明文发送的 `raw[5]` 本身,而且它解码后 `dec[5] = raw[5] ^ raw[5] = 0`。总线上的任何人都能解除掩码(这正是我所做的)。因此它并不能带来任何保密性。更有可能的原因是平淡无奇的电气考量: 我最好的猜测是前两者的结合:一个被用作廉价“白化器”的逐帧计数器,以保持隔离线路的行为良好。(你可以通过观察 `raw[5]` 的演变来倾向于哪一种解释:如果呈现干净的递增/回绕,说明它是一个计数器;如果是伪随机的,则指向故意的扰乱器。)
### 校验和 字节 30 到 31 是基于字节 0 到 29 计算出的 **CRC-16/Modbus**,采用小端序存储: ``` crc = crc16_modbus(frame[0..29]) // poly 0xA001, init 0xFFFF crc_frame = frame[30] | (frame[31] << 8) ``` 100% 格式良好的帧都通过了验证。从这时起,我只信任 CRC 校验有效的帧 —— 这至关重要,因为一个已损坏但帧头有效的帧否则会导致你解码出的数值发生翻转。 ### 两个方向 解码后的**字节 11** 不是 **`0xCE`** 就是 **`0xCF`** —— 这是两种帧系列,分别对应通讯的两个方向。在本文中,我将它们称为 **CE** 帧和 **CF** 帧。(至于哪块板子发送哪一种帧,是另一个深坑;对于构建传感器来说并不重要 —— 重要的是*给定的值存在于哪个帧系列中*。) ## 步骤 2 — 轻松搞定:模式和光伏(PV) 只需在拨动机器的同时观察解码后的**字节 10**,两个数据字段很快就被破解了: - **`byte10 & 0x1F`** → 运行模式:`0x10` = 加热,`0x19` = 待机/保温,`0x1D` = 化霜。 - **`byte10 & 0x40`** → 光伏(PV)位:**清零(0)表示 PV(太阳能)模式开启**。我通过切换 PV 模式并实时观察该位的翻转证实了这一点。 这些状态冗余地存在于多个字节中,但 `byte10` 是最纯净的数据源。搞定 —— 但这个 PV 位本身却演变成了一段小小的历险。 ### 福利:在没有该功能的型号上驱动 PV/太阳能模式 当我买下它时,Ökoboiler 提供了两个几乎完全相同的版本:一个宣称**带有** PV/太阳能模式(它会提高水箱设定温度以吸收过剩的太阳能),另一个较便宜的则**没有**。我的是“没有”的那个。 只不过它基本上是有的。PV 模式赫然出现在**设置菜单**中 —— 事实上,它**在随设备附赠的说明书中也有记载** —— 包括了 PV 开启和关闭时的不同目标温度。固件完全支持它。那个更昂贵的“PV”型号唯一真正增加的,只是*用来驱动该触发器的某个东西*。 ## 步骤 3 — 寻找水温的征途 这耗费了*好几天*,产生了几个错误的答案,还改变了方法。这也是最有趣的部分。 ### 为什么“想当然”的方法行不通 最直观的想法:记下显示屏上的温度,查看对应的字节,重复这个过程,然后找出相匹配的字节。我试了很多次。每一个候选字节都在下一次读数时失效了。例如,显示屏显示 58 °C 时,一个字节读数为 `0x58` = 88(看起来像是一个经过缩放的值!)—— 但在下一次显示 58 °C 时,它变成了 94,而在 59 °C 时它变成了 **80**(温度*更高*时,数值反而*更低*)。 我需要跨越一次真实温度*扫描*过程的大量标记数据。 ### 真值:对显示屏进行 OCR 我已经有一个带有 7 段数码管 OCR(光学字符识别)功能的摄像头对准了前面板(来自之前的一个项目),它能将显示的温度记录到 InfluxDB 中。我重新启用了它。现在我有了两个同步的时间序列: - **显示的温度**(真值),每约 30 秒一次。 - **每一个解码后的字节**,每秒约 3 次。 现在我可以进行机械化的**相关性分析**,而不是靠猜了。 ### 陷阱 1:被动冷却的退化 第一晚的数据看起来很有希望 —— 随着水箱整晚冷却,出现了干净的 6 °C 下漂 —— 但这种相关性毫无用处。当温度*单调*下降时,**每一个**碰巧单调下降的字节都会与它产生约 0.98 的相关性。线圈、环境、排气 —— 它们都在一起冷却。你根本无法将它们区分开来。 ![整晚的被动冷却 —— 每个字节都在同步漂移](https://raw.githubusercontent.com/splattner/oekoboiler-uart-reverse-engineering/main/images/fig_overnight.png) 我所需要的判定条件是一次**加热**事件:一个水箱温度与其他所有事物变化*不同*的时刻。 ### V 型扫描 白天有光伏/太阳能可用时,机器会对水箱进行再加热。所以我等待了一个完整的循环:**夜间冷却(55 → 49 °C),然后白天再加热(49 → 60 °C)** —— 在温度曲线上形成了一个“V”。现在,真正的水箱字节必须描绘出*这两条*轨迹;而在压缩机驱动的再加热期间,线圈传感器则会出现偏离。 ### 陷阱 2:赢家是假的 —— 直到使用滞后扫描 即使在完整的 V 型曲线上,普通的相关性分析仍然将几个线圈字节的关联度标为约 0.9。为了区分真实关联和巧合,我运行了一次**时间滞后扫描**(time-lag scan)。这是整个破译过程的转折点: ![滞后扫描 —— 决定性的测试](https://raw.githubusercontent.com/splattner/oekoboiler-uart-reverse-engineering/main/images/fig_lagscan.png) `ce[14]` —— 即 CE 帧解码后的字节 14 —— 就是答案。它之前一直藏在我的眼皮底下,甚至在早期还被当作“噪声”排除了。 ### 为什么字节 14 看起来像噪声 `ce[14]` 使用了**完整的 0-255 范围**并且跳来跳去,因为它的**分辨率比显示屏高得多,并且会发生溢出翻转**。像我早期那样随时间对它进行平均,会将一个快速翻转的斜坡变成一堆杂乱无章的碎片。一旦我以全速率观察它 —— 并重新构造完整的帧数据,以确认它在*温度稳定时也是稳定的*,且*没有被多路复用* —— 它的结构就显现出来了: ![ce[14] mod 64 与温度的关系](images/fig_sawtooth.png) 所以: ``` fine temperature ≈ (constant − 10.14 × T) encoded in ce[14], wrapping ``` ### 粗调伴侣 一个不断翻转的精调值本身是存在歧义的(我们到底处在哪个 6.4 °C 的窗口中?)。结果发现,起消除歧义作用的是**解码后的字节 15**,它充当了一个粗调区间的标志: - **`ce[15] = 206`** → 水箱处于 **~47–52 °C** 的区间内 - **`ce[15] = 205`** → 水箱处于 **~51–60 °C** 的区间内 至关重要的是,我**仅从字节流本身**就发现了 `ce[15]` 的规律,完全不需要借助 OCR 标签数据 —— 因为字节数据的速率足够高,能够直接揭示编码结构。(几天的更大温度波动就能映射出更多的值。)
为什么温度被拆分成了一个会翻转的精调字节加上一个粗调区间? 这种 `(会溢出的精调值,粗调区间)` 的布局并非偶然 —— 这正是当原始传感器读数通过总线传输时自然而然产生的情况,事实证明这也是一个明智的选择: - **这是原始测量数据的本来面貌。** 如此精细的温度(在整个工作范围内约为 ~0.1 °C/步)需要超过 8 位的数据。固件没有将其打包到一个别扭的字段中,而是直接发送了**低位字节**(快速变化的精细部分)和一个**高位字节 / 区间**(缓慢变化的粗略部分)—— 这纯粹是对一个更宽计数器进行了一次普通的拆分。精调字节之所以会“溢出翻转”,仅仅是因为作为低位字节,它每 256 步就会循环一次(在 ~10 单位/°C 的情况下约为每 6.4 °C)。 - **相同的格式适用于所有传感器。** 所有五个通道都使用了相同的 `(精调,粗调)` 配对方式(见下文),所以这是该主控板传输传感器数值的*通用*方式,而不是专门为水箱定制的特殊编码。一个布局重用五次,比定制五种不同的编码成本更低。 - **显示端丢弃了分辨率。** 面板永远只显示整数度,因此精调字节中的额外精度在设备上是不可见的 —— 这正是为什么在我将其关联起来之前,它看起来就像是噪声。主控板在内部保留了完整的精度(它自身的调节功能需要它)并直接将其传输出来;显示器只在最后一步进行了四舍五入。 因此,这种溢出翻转是将一个高分辨率的值拆分成字节后产生的副作用,而粗调区间则是告诉你目前处于哪一个翻转周期的高位部分。一旦你把它看作“同一个数字的低位字节 + 高位字节”,它就不再神秘了。
## 步骤 4 — 解码器 综上所述,温度的计算公式为: ``` T (°C) = 60.62 − 0.0986 × unwrapped(ce[14]) ``` 其中 `unwrapped(ce[14])` 用于解析溢出。我实现了**两种**方法来完成这项工作,因为它们的失效模式不同,而且在实际运行中对比它们很有趣: 解码器 A — 连续性(增量式)。** 像旋转编码器一样在帧之间追踪 `ce[14] mod 64`;每次溢出翻转都会加上/减去 64。每当水箱温度落入明确的 `ce[15] = 206` 区间(该区间比一个溢出周期窄)时,就会重新锚定到绝对真值。**精确(~0.3 °C)**,但在重启后,它需要经历一次低于 52 °C 的降温才能重新锚定。 **解码器 B — 无状态。** 在每一帧中直接根据 `(ce[15], ce[14])` 重新计算温度,并在宽区间内使用全字节作为判断条件。**无需记忆,重启后立即生效**,~1 °C。 在整个扫描过程中通过 OCR 真值进行了验证: ![解码器与真值对比](https://raw.githubusercontent.com/splattner/oekoboiler-uart-reverse-engineering/main/images/fig_accuracy.png) ## 步骤 5 — ESPHome 集成 整个系统运行在 ESP32 上,作为一个 ESPHome UART 嗅探器,将数据发布到 Home Assistant。解码过程只是一小段 lambda 代码;有关完整且可直接刷写的配置(需填入你自己的密钥),请参见 [`esphome/oekoboiler-sniffer.yaml`](esphome/oekoboiler-sniffer.yaml)。其核心部分如下: ``` // CE frames only, CRC-valid. byte14 = fine temp (wraps), byte15 = coarse band. if (masked_frame[11] == 0xCE) { const float WT_A = 60.62f, WT_B = 0.0986f; // T = A - B*(fine + acc) int fine = masked_frame[14] % 64; int b15 = masked_frame[15]; // Decoder A: continuity, re-anchored in the unambiguous b15==206 band if (b15 == 206) { id(wt_acc) = 64 * (int) lroundf((112.8f - fine) / 64.0f); id(wt_anchored) = true; } else if (!id(wt_anchored)) { id(wt_acc) = 64 * (int) lroundf((51.9f - fine) / 64.0f); id(wt_anchored) = true; } else { int d = fine - id(wt_prev_fine); if (d > 32) id(wt_acc) -= 64; else if (d < -32) id(wt_acc) += 64; } id(wt_prev_fine) = fine; id(water_temp_continuity).publish_state(WT_A - WT_B * (fine + id(wt_acc))); // Decoder B: stateless int raw = masked_frame[14]; float center = (b15 == 206) ? 49.5f : 55.5f; int k = (int) lroundf((((WT_A - center) / WT_B) - fine) / 64.0f); float tempB = WT_A - WT_B * (fine + 64 * k); if (b15 != 206) { if (raw >= 64 && tempB < 57.0f) tempB = WT_A - WT_B * (fine + 64 * (k - 1)); if (raw < 64 && tempB > 57.5f) tempB = WT_A - WT_B * (fine + 64 * (k + 1)); } id(water_temp_stateless).publish_state(tempB); } ``` ## 福利:映射其他四个传感器 | 配对 | 传感器 | 我是如何识别它的 | |---|---|---| | `ce14/15` | **水箱** | 通过显示器(已经解码) | | `ce16/17` | **环境 / 进气** | 与我在 Home Assistant 中的一个**同房间温度传感器**相关联 —— 设备待机时 r = **−0.82** | | `ce20/21` | **蒸发器 / 盘管** | **恰好在锅炉处于 `heating` 模式时骤降** —— 典型的蒸发器表现 | | `ce22/23` | **排气 / 排气** | 在 `heating` 期间**变热** | | `ce18/19` | **回气** | 未使用的通道 —— 没有连贯信号 | 有两件事让这在*没有这些传感器的任何显示读数*的情况下成为可能: 1. **借用的环境真值。** 锅炉位于车库,而我在那里有一个室温传感器。待机期间,环境温度通道与它紧密吻合(r = −0.82);而在运行期间相关性减弱,正是因为热泵**冷却了它自身的进气** —— 这是一个真实且可见的效应。 2. **来自我已解码字段的交叉验证。** 将运行模式(`cf[10]&0x1F = heating`)叠加到传感器轨迹上,*每一个*加热阶段都与盘管骤降、排气上升和环境温度下降相吻合。协议中两个独立解码的部分相互印证,是对传感器 ID *和*模式解码的强力确认。 ![叠加了运行模式的其他传感器通道](https://raw.githubusercontent.com/splattner/oekoboiler-uart-reverse-engineering/main/images/fig_sensor_channels.png) 化霜也会出现在模式字段中,但仅仅是**加热周期结束时约 2 分钟的尖峰信号** —— 太短暂了,在上面的 3 天图表中根本看不见。将时间轴放大到 1 小时就能看到它了: ![化霜放大图](https://static.pigsec.cn/wp-content/uploads/repos/cas/44/440827ae0f0c42ae47bf5ef2abc4e2f2d1b3d04acf1b8ec13694504b7e7ff230.png) **诚实的警告:** 这些绝对数值是*近似的*。我假设所有通道都与水箱具有相同的斜率(~10 单位/°C),这几乎肯定太陡了 —— 环境温度的波动比实际情况要宽,而盘管的最低温度达到了不切实际的 -30 °C。**波形特征和身份识别是可靠的;但幅度被拉伸了。** 每个传感器都有其自身的标度,如果没有针对特定通道的真值,我就无法确定它(盘管/排气需要卡入式探头;环境温度则需要更宽的室温波动)。但即使没有经过校准,盘管的温度轨迹也是极好的蒸发器/化霜信号,而环境温度也有一两度范围内的准确度。 ## 协议总结 完整详情见 [`PROTOCOL.md`](PROTOCOL.md)。简短版本如下: | 字段 | 位置(使用 `raw[5]` 进行 XOR 解掩码后) | 含义 | |---|---|---| | Header | `raw[0..1] = 64 41` | Frame start | | XOR mask | `raw[5]` | XOR key for bytes 2–29 | | Signature | `dec[6..9] = F2 D4 F9 61` | Constant; decode sanity check | | Frame family | `dec[11] = CE / CF` | Direction / message type | | Operating mode | `dec[10] & 0x1F` (CF) | `10`=heat `19`=idle `1D`=defrost | | PV mode | `dec[10] & 0x40` (CE) | clear = PV on | | **Water temp (fine)** | **`dec[14]` (CE)** | **~10 units/°C, wraps ~6.4 °C, falls as temp rises** | | **Water temp (coarse band)** | **`dec[15]` (CE)** | **206 ⇒ 47–52 °C, 205 ⇒ 51–60 °C** | | Ambient / intake | `dec[16]/[17]` (CE) | `(fine,coarse)` pair, same format | | Evaporator / coil | `dec[20]/[21]` (CE) | `(fine,coarse)` pair | | Exhaust / discharge | `dec[22]/[23]` (CE) | `(fine,coarse)` pair | | Return gas | `dec[18]/[19]` (CE) | present in format, sensor unpopulated | | CRC | `raw[30..31]` | CRC-16/Modbus over `raw[0..29]`, little-endian | ## 仍未解决的问题 - **标定范围。** 温度常数是在 **47–60 °C** 之间拟合出来的(这也是抓取期间水箱经历的全部温度范围)。超出这个范围,`ce[15]` 会取我尚未映射的值。大幅降温至 ~40 °C 并进行一次彻底的再加热将扩展这个范围,常数也将被细化为一个单一的解析公式。 - **其他传感器的绝对标定。** 环境、盘管和排气现在已被*识别*(见上文),但它们的绝对数值是近似的 —— 每个都需要自己的标度比例。盘管/排气需要在管道上安装**卡入式探头**;环境温度只需要房间温度在更大范围内波动即可。(回气是一个废弃通道 —— 未使用。) - **设定温度** 似乎是根据 PV 状态推导出来的,而不是作为一个纯数字发送的;仍在确认中。 ## 经验教训 1. **抽查会骗人。** 面对多个存在相关性的传感器,仅在几个时刻将字节与数值进行匹配,会让你整天陷入假阳性。你需要跨越真实*扫描*过程的标记数据。 2. **相关性需要合适的事件。** 单调冷却会让所有事物都产生关联;而一次*加热*循环才能将水箱和盘管区分开来。 3. **滞后扫描被低估了。** “相关性在时间上的峰值出现在哪里?”可以清晰地将实时读数(滞后 0)与偶然的日常节律伴生物(滞后 ≠ 0)分离开来。 4. **“噪声”可能只是分辨率不对的信号。** 温度字节看起来是随机的,只是因为它比显示屏更精细,并且会循环翻转。在抛弃一个字节之前,请以原始速率观察它,重构帧数据,并检查*输入稳定时的稳定性*。 ## 仓库结构 ``` esphome/oekoboiler-sniffer.yaml Full ESPHome config (UART sniffer + decoders) PROTOCOL.md Byte-level protocol reference images/ Figures used in this write-up scripts/ Analysis helpers (InfluxDB correlation, plotting) ``` ## 致谢与前人工作 针对这款设备的所有现有公开研究,均停留在 **云 API** 层面或借助 **显示器 OCR** 进行 —— 据我所知,此前还没有人发布过对*内部*板对板总线的解码。相关项目: - `johannrichard/oekoboiler-api` —— Ayla 云 API 客户端(提供有用的字段名提示)。 - `splattner/oekoboiler-homeassistant` —— 摄像头 + 7 段数码管 OCR(我自己之前的项目;正是它提供的真值标签让这次解码成为可能)。 ## License MIT — 见 [`LICENSE`](LICENSE)。分享这些发现是希望它们能有用;与 Ökoboiler 没有任何关联。接入你自己的硬件的风险需你自己承担。