☰
ESP8266读取智能电表D0口SML报文,MQTT推送能耗数据
2026/10/15 1:44:56 网站建设 项目流程

简介:SMLReader项目是一套基于ESP8266/ESP32的智能电表数据网关方案,专注于将D0接口的SML协议数据转换为MQTT消息,实现电力消耗的远程监控与智能家居联动。资源定位清晰,适合智能家居开发者、能源管理爱好者及Arduino进阶用户参考学习。资源包共29个文件,压缩后仅839KB,包含Arduino主程序(.ino/.cpp)、头文件、硬件接线图(Fritzing源文件)、平台配置文件(platformio.ini)及多份markdown/readme说明文档,并配有实物与界面截图,方便对照调试。目前已有267人学习浏览,可快速上手。通过该项目,读者能掌握SML协议解析、OBIS对象识别、MQTT发布流程以及ESP8266/ESP32的Wi-Fi配网与串口操作技巧,获得从电表数据读取到家庭自动化系统集成的完整参考实现,对提升能源管理效率与自助开发智能电表网关有实际价值。

1. SMLReader:用ESP8266把D0口变成MQTT数据源

想统计家里空调到底耗了多少电,最直观的办法是拆开电表看脉冲灯,但那种方法既没法自动化也没法记录。真正靠谱的入口,是电表背面那个不起眼的D0数据口——标准化表计几乎都带它,以20mA电流环输出SML报文。SMLReader做的事情很简单:用一块ESP8266接上D0口,读回SML二进制帧,解析出有功总电能、瞬时功率这些OBIS码,再以JSON格式通过MQTT推到局域网broker,Home Assistant或自己的数据库就能直接消费。

这个方案成本低、功耗小、不拆表不改线路,适合有ESP8266基础的开发者和自建能源监控的玩家。我在给某公司做能耗看板Demo时踩过不少坑,从电平转换到帧切分再到MQTT重连,每一步都有看似合理实则翻车的做法。

这篇笔记按一条可以照抄的路径走:先把D0和SML协议说透,再给最小接线和验证方法,然后是解析代码和MQTT参数,最后是五条避坑记录和一个稳定运行技巧。

2. D0口与SML协议:先把电表的报文格式吃透

2.1 D0接口的物理层:20mA电流环不是TTL

D0口标准叫法是IEC 62056-21的数据接口,物理层绝大多数是20mA电流环,而不是RS232或TTL。电流环用“有电流”和“无电流”表示0和1,通过光电耦合器传递信号,天然隔离且抗干扰强。很多第一次接触的人,包括我,都会拿USB-TTL模块直接往电表D0口上怼,结果收不到任何字节,严重的还把转换芯片烧了。

正确的接法是把电表D0输出端接到光耦原边,光耦副边再接到MCU的串口RX。电流环是电流型驱动,串一个限流电阻到光耦发光二极管即可。波特率方面,国内常见的智能电表D0口有两种:2400和9600,数据格式固定为8个数据位、偶校验、1个停止位,也就是8E1。有些表默认不输出数据,需要外部先发一个读请求序列,这在2.3里单独说。

我一般会先做一个判定动作:把电表型号和D0波特率拍下来记到配置注释里。你可能会问,不是可以自动探测吗?可以,但没必要,D0口一共就两种波特率,手动设置比自动探测省掉大量误判时间。用USB-TTL加光耦先在电脑上看原始数据,是排查一切问题的第一步,这一步能过滤掉至少一半的“玄学”问题。

2.2 SML帧结构:从开始标志到结束标志

SML全称Smart Message Language,是德国标准化的电表数据语言,采用ASN.1二进制编码。实际到串口上是这样一帧:以四个0x1B加01 01 01 01开头,中间是若干消息块,最后以四个0x1B加0x1A结束。这种结构的好处是边界清晰,坏处是帧长不固定,有的几百字节,有的超过一千字节,必须按块切分。

解析SML至少要分三层来做。第一层是帧同步,找到起始序列和结束序列之间的完整内容。第二层是消息块切分,一个帧里有多个Message Block,每个Block内部再按TLV结构展开,也就是Type-Length-Value。第三层才是OBIS码匹配,OBIS码是唯一的测量对象标识,比如1-0:1.8.0*255代表有功总电能,找到它之后取其跟随的数值字段。

这里有个关键点:SML没有让开发者“按字段名取数据”的便利接口,你必须自己遍历TLV。早期我写过一个完整解码器,后来发现对于一个固定型号的电表根本不需要,只需要扫描关键OBIS码即可。这也是SMLReader这类网关项目普遍采用的做法——不是实现完整的ASN.1解码器,而是做一个面向OBIS码的提取器。

2.3 为什么是ESP8266而不是树莓派或Arduino

选ESP8266的理由很直接:成本低,WiFi原生,3.3V供电就能跑,整板功耗百毫瓦级别,可以用充电宝或者电表附近的USB口长期供电。树莓派当然也能做,但一台树莓派的功耗和价格是ESP8266的几十倍,为了一个Meter Reading的数据采集任务明显过重。Arduino Uno虽然便宜,但板载没有WiFi,还要外挂串口转WiFi模块,麻烦得多。

ESP8266的痛点在于只有一个真正的UART,也就是UART0。UART1只能发不能收,想同时挂调试串口和电表串口会冲突。常见做法是用Serial.swap()把UART0的引脚从默认的GPIO1/GPIO3切到GPIO15/GPIO13,这样调试口和电表口各占一组。首次烧录时程序用的是默认引脚,跑起来后swap到GPIO13接收电表数据,调试信息继续从默认口输出。

还要注意ESP8266的串口缓冲区只有128字节,而SML一帧可能超过1KB。如果直接在终端回调里做解析,数据必然丢。正确姿势是开一个环形缓冲区,把电表字节流先存进去,然后在主循环里处理完整帧。环形缓冲的实现不复杂,但它是这个项目能否稳定运行的基石之一,我在第4章的解析代码里会给出一个可用的同步逻辑。

3. 最小硬件接线与电脑端验证:先见原始帧再谈解析

3.1 光耦隔离电路与接线清单

接线是第一步,也是翻车率最高的地方。我推荐用PC817光耦,便宜且容易买到,而HCNR200那类线性光耦在这里没有意义——SML是数字信号,速度也不高。PC817的电流传输比(CTR)在波特率2400/9600下完全够用,数控信号不需要线性度。

整套接线可以按下面这张表来准备:

电表D0端子中间器件ESP8266引脚说明
D0+限流电阻1kΩ串联到PC817原边正极-电流环正极,不能直接接GPIO
D0-PC817原边负极-电流环负极,通常与电表地共地
PC817副边集电极10kΩ上拉电阻到3.3VGPIO13(RX0 swap后)集电极开路输出,必须上拉
PC817副边发射极-GND与ESP8266共地
3.3V-上拉电阻另一端为光耦副边提供高电平

上拉电阻这点经常被漏掉。PC817副边是集电极开路,不加上拉时输出是浮空的,GPIO读到的是噪声电平,帧永远是碎的。我见过A同学排查了一晚上,最后发现就是少了一个10kΩ电阻。限流电阻选1kΩ到2.2kΩ都行,视电表D0驱动能力而定,电流环本身电流不会太大,烧不了光耦。

3.2 电脑端验证:用minicom看HEX字节流

不建议直接把ESP8266装上就跑解析。最稳妥的做法是先用USB-TTL转换器加同样的光耦电路接电表,在电脑上打开串口工具看原始数据。只有能看到以1B 1B 1B 1B开头的HEX字节流,才说明物理层是通的。

在Linux下我一般用minicom:

sudo minicom -D /dev/ttyUSB0 -b 2400 -8 --noinit

进入界面后按Ctrl+A再按E,循环切换校验位到Even。参数含义:-D指定串口设备,-b指定波特率为2400,-8表示8数据位,--noinit表示不发送初始化字符串。如果电表是9600,把-b改成9600。

这里有个很实用的经验:minicom默认显示ASCII,SML是二进制,屏幕上会是一堆乱码。别慌,按Ctrl+A再按X退出,然后用带HEX模式的工具重开,比如:

stty -F /dev/ttyUSB0 2400 cs8 even -cstopb cat /dev/ttyUSB0 | xxd | head -20

xxd输出里如果看到连续的1b 1b 1b 1b,说明D0链路已经通了。如果只有乱码没有帧头,先怀疑波特率不是9600而是2400,或者校验位不是偶校验,再怀疑D0使能没开。

3.3 电表D0使能:有些表要先被读才开口

这是D0口最容易踩的坑之一。部分电表在D0口上不是主动输出数据的,需要外部先发送一个读请求信号,电表才会开始往外吐SML帧。读请求的格式通常是ASCII字符/ ? !,实际发送时要带上对应波特率和校验位。

我遇到过一种情况:电表接线全对、波特率也试了两遍,就是没数据,最后发现是该表D0口默认关闭输出,必须在连接后先发送一个请求序列。具体字符因表而异,常见的是一行以/开头、!结尾的字符串,比如:

printf '/?!\r\n' > /dev/ttyUSB0

发送之后等一两秒,电表应该开始吐数据。如果还是没反应,可以去电表设置菜单里找一下“数据输出”或“DLMS”开关。这个开关在一些表上是藏在设置项里,默认是关的。我的习惯是拿到电表后先翻说明书里的通信章节,把D0口是否主动输出、波特率、校验位一次记全,后面省很多事。

4. SML报文解析:OBIS码才是网关的灵魂

4.1 帧同步与切分:用状态机而不是字符串查找

拿到完整帧的第一步是同步。SML帧以连续的1b 1b 1b 1b 开头,中间是01 01 01 01。我见过有人用字符串查找函数在缓冲区里找帧头,这在一个固定场景下勉强能用,但SML报文内容本身也可能出现1b字节,如果中间出现不完整序列,indexOf就会误判。

更稳的做法是用状态机逐字节匹配。在ESP8266上这样实现:

// SML帧同步状态机:匹配 1B 1B 1B 1B 01 01 01 01 作为帧起始 const uint8_t START_SEQ[8] = {0x1B, 0x1B, 0x1B, 0x1B, 0x01, 0x01, 0x01, 0x01}; const uint8_t END_SEQ[5] = {0x1B, 0x1B, 0x1B, 0x1B, 0x1A}; void findFrameStart(uint8_t c) { static uint8_t matchIndex = 0; if (c == START_SEQ[matchIndex]) { matchIndex++; if (matchIndex == sizeof(START_SEQ)) { // 命中帧头,记录当前位置,开始缓存帧内容 frameBufPos = 0; synced = true; matchIndex = 0; } } else { matchIndex = (c == START_SEQ[0]) ? 1 : 0; } }

这段代码的核心思路是:每收到一个字节就推进匹配状态,只有连续8个字节完全匹配才算同步。中间任何一个字节不匹配,就退回到只检查是否等于起始字节。这样的状态机不会把报文内容里的0x1B误判成帧头。

同步之后继续收字节,直到连续匹配到结束序列的1b 1b 1b 1b 1a,就认为一帧结束了。这个逻辑里还有一个隐藏参数:最大帧长。我一般设1600字节,超过阈值就强制丢弃重新同步,防止异常数据把缓冲区撑爆。

4.2 OBIS码定位:从TLV里抽出数值字段

SML的报文内容本质是TLV结构。TLV就是每个字段前面有类型标签和长度,后面跟值。我们不需要完整解码所有字段,只需要在消息块里找到关心的OBIS码,再往后取一个长度字段和一个数值字段。

以下是我在固件里用的一个简化提取逻辑:

// 在SML消息体里扫描指定OBIS码,命中后提取后续数值 // obis参数为6字节,例如 {0x01,0x00,0x01,0x08,0x00,0xFF} 表示 1-0:1.8.0*255 bool extractObisValue(uint8_t* p, size_t len, const uint8_t* obis, int32_t* outVal) { for (size_t i = 0; i < len - 8; i++) { if (p[i] == 0x01 && memcmp(&p[i+1], obis, 6) == 0) { // 命中后:类型通常是 0x02(octet string)或 0x06(integer),长度在下一字节 uint8_t valType = p[i + 7]; uint8_t valLen = p[i + 8]; if (valType == 0x06 || valType == 0x02) { // 大端有符号数,valLen常见为4或8 int64_t raw = 0; for (uint8_t j = 0; j < valLen; j++) { raw = (raw << 8) | p[i + 9 + j]; } *outVal = (int32_t)raw; return true; } } } return false; }

参数说明:p指向帧体基地址,len是帧体长度,obis是你要匹配的6字节标识。注意SML里的OBIS码是带通道和上下文信息的,结尾的0xFF代表“全局”,很多表的OBIS码最后一段是255。如果你的表不输出0xFF而输出别的值,匹配会失败,所以OBIS表要按你的电表实际输出来修正。

这里有一个非常重要的单位问题。SML的数值字段是整型或octet string,原始值并不直接等于物理量,要乘一个缩放因子。以有功总电能为例,很多表的原始值是Wh的四位小数,也就是单位是0.0001 kWh,或者有的表是0.1 Wh。我在第一个版本里直接memcpy成int丢给MQTT,结果Home Assistant上显示的电量数据大了10倍,查了半天才发现是缩放因子没处理。

4.3 常见OBIS码参数表

不同电表的OBIS码基本遵循IEC 62056-61标准,但具体是否输出、通道号是1还是2,要看电表固件。我列一份最常用的映射表,适合大多数两费率或三费率电表:

OBIS码(格式化为字符串)含义常见单位
1-0:1.8.0*255有功总电能kWh
1-0:1.8.1*255峰时无功或有功电能kWh
1-0:1.8.2*255谷时电能kWh
1-0:2.8.0*255反向有功总电能kWh
1-0:1.7.0*255瞬时有功功率W
1-0:32.7.0*255瞬时电压V

这份表不是万能的,尤其“1-0:1.8.1”的费率编号在不同地区定义不同。我的建议是先把电表输出的所有OBIS码打印出来,拿实物比对之后再写死到配置里。不少网关项目做了动态OBIS码识别,把电表所有OBIS码原样保留在MQTT payload里,这样灵活得多,代价是数据量变大。如果你的下游是Home Assistant,用静态映射表加固定的topic就够了。

5. MQTT连接配置与五条避坑记录:从连不上到丢顺序

5.1 网关侧MQTT参数:buffer size和keepalive写死在代码里

ESP8266上MQTT客户端一般用PubSubClient库,但这个库的默认配置有两个坑:默认接收缓冲区只有128字节,而一段包含好几组OBIS值的JSON payload轻松超过300字节;另一个是keepalive默认值在某些版本里不够友好。我一般在connect之前设置三个参数:

// 初始化MQTT客户端,必须在connect()之前调用setBufferSize和setKeepAlive client.setBufferSize(512); client.setKeepAlive(30); client.setServer(mqttHost, 1883); client.connect("sml_gateway_01", "username", "password");

setBufferSize(512)是解决发送大payload的关键。setKeepAlive(30)是让客户端每30秒发一次心跳,broker如果在约90秒内没收到心跳会判定连接断开。如果你用默认值,某些情况下WiFi信号弱时连接会频繁掉线,而调大keepalive反而能提升稳定性。

topic和payload我也说一下。我用的是meters/home/power这样的私有topic,payload固定为JSON数组而不是单条消息,减少MQTT消息数。每帧电表数据打包成一个JSON数组,一次性推送全部OBIS值:

{"ts": 1700000000, "values": [ {"obis": "1-0:1.8.0*255", "v": 12345.6, "u": "kWh"}, {"obis": "1-0:1.7.0*255", "v": 823, "u": "W"} ]}

pub时建议QoS用1,retain置false。瞬时功率这类高频变化数据不需要retain,电量累计值如果下游有这个需求可以单独发一条retain消息。这个细节在后面避坑第5条会展开。

5.2 避坑一:偶校验和串口模式不匹配导致半个电表读不到

现象:电脑端用USB-TTL能收到完整帧,但ESP8266接上后经常缺数据,有时一帧只收了一半,OBIS码怎么都对不上。

原因:ESP8266的Arduino核心默认Serial初始化是8N1,也就是无校验。电表D0口输出是Even偶校验,8N1模式下硬件会把校验位当成数据位的一部分,导致字节对不齐,偶发性丢字节。

解决:显式设置串口模式为SERIAL_8E1:

Serial.begin(2400, SERIAL_8E1); // 匹配D0口标准,2400或9600视电表而定

这个改动几乎是必做的。如果你在电脑端用的minicom设置了Even校验,到ESP8266上忘了设置,就会出现这种“电脑正常、板子丢数据”的诡异现象。我有一个经验:凡是收到帧头但内容对不齐,先检查串口模式,再检查波特率,顺序不要反。

5.3 避坑二:电表D0口使能或读请求未处理

现象:所有接线正确,串口模式正确,ESP8266却完全收不到数据,逻辑分析仪上看RX引脚毫无波形。

原因:电表的D0口处于被动模式,需要外部先发送一个读请求序列才能激活输出。这在某些型号的电表上是出厂默认,需要先通过串口发送/?!之类的内容。

解决:在ESP8266启动后,延几百毫秒,主动向同一串口发送一次读请求:

delay(500); Serial.print("/?!\r\n");

发送时机放在WiFi连接之前还是之后都可以,我习惯放在Setup里,因为电表一旦开始数据输出就持续输出,不需要多次请求。如果你的电表始终不吐,就要去电表菜单里找“数据输出使能”开关,这个开关藏在通信参数设置里,不同电表位置差异很大。

5.4 避坑三:WiFi掉线后MQTT重连风暴

现象:broker所在服务器重启,或者家里路由器重启之后,ESP8266不断尝试连接MQTT,导致没有时间处理串口数据,电表帧一直堆积循环缓冲区,最后整个系统卡死。

原因:loop里无条件重连MQTT是一个死循环。PubSubClient的connect失败会立即返回false,循环紧跟着再试,WiFi本身还在恢复中,这种忙等模式挤占了所有CPU时间。

解决:给MQTT重连加退避逻辑,并且把串口数据处理放到while循环里的独立阶段:

static uint32_t lastReconnect = 0; static uint16_t reconnectCount = 0; if (WiFi.status() == WL_CONNECTED && !client.connected()) { if (millis() - lastReconnect > min(30000, 1000 * reconnectCount)) { reconnectCount++; lastReconnect = millis(); client.connect("sml_gateway_01"); } }

这段逻辑的意思是:第一次重连失败后至少等1秒,第二次等更久,封顶30秒。这样既不会在路由器重启时疯狂重连,也不会在正常网络抖动时反应太慢。blink一个LED指示MQTT连接状态也是一个好习惯,现场排查时省力很多。

5.5 避坑四:QoS和retain选择不当导致数据混乱

现象:以QoS2发布电能累计值,偶尔下游看到数据倒退或长时间不刷新。

原因:MQTT QoS2虽然能确保不丢消息,但在网络抖动时会重试,如果重试的时机不对,下游收到的顺序可能是乱的。对于ESP8266这种内存极小的设备,QoS2的状态机还占额外内存,容易把客户端压垮。

解决:电量累计值用QoS1加retain,瞬时功率和电压用QoS0。原因是累计值下游启动时需要能拿到最新值,retain可以保证新订阅者立即收到当前电量;而瞬时功率是测频数据,不需要历史值,retain反而会造成下游收到过期数据。

client.publish("meters/home/power_total", payload, true); // retain=true client.publish("meters/home/power_now", payload, false); // retain=false

这个搭配可以让Home Assistant重启后不用等下一帧就能显示总电量,而实时功率永远是当前值。

5.6 避坑五:flash写入和串口收发互相抢占

现象:运行中偶尔一整帧丢失,时间点和MQTT发送大payload或ESP8266自动保存配置的时刻吻合。

原因:ESP8266写flash时,内部总线会被占用,串口中断响应延迟拉长,D0口连续字节流如果在此期间到达,就会溢出并丢帧。

解决:不要在串口中断里做任何耗时操作,收完字节只进环形缓冲区,解析和MQTT发布全部放主循环。另外,如果启用了Arduino的EEPROM或Preferences自动保存,把保存频率降到最低,或者只在启动时读取、在按键或特定事件时才写。

如果丢帧依然存在,可以考虑用电表的D0口加电表侧缓存:有的电表在D0口自身就有输出缓存,丢一帧后下一帧会补上,这对电量累计值影响不大。但瞬时功率丢一帧就少一个点,为了减少影响,我通常让ESP8266在WiFi发送完成后再读串口缓冲区,而不是每收到一个字节就立即发送。

6. 长期运行的一个验证习惯:离线复现每一帧

固件写完之后,最有效的验证方法不是反复插电表试,而是把电表吐出来的原始帧保存下来,离线喂给解析逻辑。这个习惯帮我避开了至少三次“改一处OBIS映射、坏一处单位换算”的回归问题。

实际操作是这样:电脑端用minicom或者一个简单的Python脚本抓取一段原始帧,存成二进制文件,然后在PC上模拟SMLReader的解析流程。你不需要在PC上完整复刻ESP8266代码,只需要把第4章的解析函数用相同逻辑实现一份,比如:

import struct def find_frame_start(data): seq = b'\x1b\x1b\x1b\x1b\x01\x01\x01\x01' idx = data.find(seq) return idx with open('meter_dump.bin', 'rb') as f: raw = f.read() idx = find_frame_start(raw) frame = raw[idx:idx + 512] # 这里调用你本地的OBIS解析函数

Python这种写法只是为了离线验证,最终固件还是C++,但离线环境跑起来快、可加断点、能打印任意中间状态。抓到真实帧之后,把其中所有OBIS码枚举出来,与电表铭牌或说明书对照,顺手就把映射表校准了。

这个习惯的价值还在于它改变了调试路径:你不会为了一个解析bug反复刷固件、等电表下一帧,而是先离线复现,再一次性烧录验证。我在给模拟项目X做数据对接时,就是用这个方式把解析错率从“每改一次都要上板试半天”降到“离线测试过了再上板基本一遍过”。

长期运行还有一个容易被忽略的细节:电能累计值要保留到掉电不丢的位置。ESP8266没有真正的EEPROM,我一般用RTC memory保存最近一次成功解析的总电能值,每次启动后和当前解析值做对比,如果发现新值小于旧值且差值超过一个阈值,就认为电表重启或固件出错,主动发一条警告MQTT消息而不是直接覆盖。

设备稳定跑了一阵子之后,我还会定期去看broker上保留的JSON,确认每个字段的递增趋势合理。瞬时功率白天高、夜里低,总电量单调递增,这些规律本身就是网关健康度的体检报告。最后一件事是把抓到的原始帧保存好,下次改协议、换电表时直接拿来回归,不用重新折腾物理环境。

说句实在话,这个东西最大的坑不是解析、不是MQTT,而是你以为都调通了就不再管它。固件里留着看门狗,路由器断电后能自动恢复,MQTT broker重启后能退避重连,这三个底线守住了,网关才能安静放在配电箱旁边一个月不去动它。希望这篇记录能帮你在做智能电表数据采集时少走点弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询