简介:面向光伏逆变器研发、储能系统集成与工业通信调试人员的Solax SK-SU系列混合太阳能逆变器Modbus协议规范文档,可用于设备通信对接、寄存器映射核对与远程监控开发。压缩包内共1个PDF文件,约1.58MB,集中呈现协议总体定义、端口502、帧格式与请求时序,以及保持寄存器读写、单写寄存器、远控与EPS相关寄存器说明。文档保留V1.0至V2.50的版本变更记录,涉及充电器温度单位、语言选择、意大利自测、电池唤醒、BMS告警、CAN通讯丢失、Etotal修正、电池SOH和充电器输入能量等内容,便于读者追溯字段差异、排查通信异常并完成二次开发。已有88人浏览学习,适合作为协议查阅与联调参考。
1. 手上有台混合储能逆变器,想读数据却没有上位机
机房角落那台混合储能逆变器的 Modbus TCP 口一直开着,502 端口,Unit ID 1,可发电量只能靠厂商云平台的曲线看,想导出一周的分钟级数据得一张张截图。翻完随设备给的那份 V2.50 寄存器协议文档才发现,这台机器的所有状态基本都摊在桌面上:保持寄存器区放配置和保护阈值,输入寄存器区放实时量、故障字和累计电量,功能码就用 0x03、0x04、0x06、0x10 这四个。协议本身没什么花活,难的是两百多行地址表里哪些能用、哪些是保留位、单位怎么换算。
这份资源对两类人最有用。一类是想做本地采集的:不想把逆变器数据全交给云平台,自己接个树莓派或工控机轮询,进本地时序库或者丢给自建的监控页面。另一类是接手二手设备或者要做 EMS 联动的:得改并网保护阈值、改充放电时段、切远程功率控制模式,没有这张表,对着 502 端口只能瞎猜地址,猜错一个写下去就是整机停机。协议文档给的是地址和语义,剩下怎么组织轮询、怎么处理有符号数和 32 位字段,得自己落地。
2. 协议底座:502 端口之上,先守住 1 秒间隔这条硬线
这套设备走的是标准 Modbus TCP,端口 502,帧结构就是 MBAP 头加 PDU。协议文档里对帧格式的描述很简短,真正决定你能不能稳定采集的是开头那三个时间参数:两条指令之间的最小间隔 1 秒、两包之间静默时间大于 100 毫秒、响应超时 1 秒。很多人第一次接这类设备,习惯按 Modbus RTU 的经验几十毫秒发一帧,结果就是读数偶尔对、偶尔超时,回头怀疑网线,其实是把设备内部的任务周期打乱了。
我一般会先在命令行确认端口通不通,再上代码。别急着装库,先把一帧报文拼出来看回什么,这是排查问题最快的路径,也是理解 MBAP 头最直接的方式。
# 先确认 502 端口是不是真的在监听,很多"连不上"其实是网络段不通 nc -zv 192.168.1.50 502端口通只是第一步,能不能拿到数据要看 Unit ID 和从站地址对不对。有些设备默认 Unit ID 是 1,也有按机器地址寄存器 0x0084 配置的,读不到东西的时候先把 Unit ID 从 0 试到 3,比翻文档快。
2.1 三个时间参数决定了轮询表的上限
1 秒的最小间隔是硬约束,它直接决定了你的轮询表怎么排。假设你要读 100 个寄存器,如果老老实实一个地址一个请求,一轮就是 100 秒,实时性基本没了。所以正确做法是按地址连续性合并成块,一次读一段。协议里 0x0000 到 0x0023 是连续的输入寄存器,0x0040 到 0x005B 也是连续的,保持寄存器 0x007D 到 0x008A 连续,这样一轮三轮请求,三秒左右能刷完所有关心的量。
提示:合并块的时候不要为了「凑连续」把保留区(REV)也读进来。0x00A2 到 0x00B3、0x00E0、0x00E8 到 0x00E9 这些位置文档标的是 REV,部分固件读到保留地址会直接回异常码 0x02,整块数据全丢。
响应超时 1 秒这条也要落到代码里。TCP 层的 socket 超时如果设成 5 秒,设备没回你就卡 5 秒,后面的轮询节拍全乱。我的习惯是把 socket 超时设成 1.2 秒左右,留一点余量,超时就丢弃这一轮、重连,不要让一次失败拖垮整个采集循环。
2.2 一次 0x03 读保持寄存器的完整报文
读保持寄存器的请求 PDU 是固定的五个字节:功能码 1 字节、起始地址 2 字节、寄存器数量 2 字节,地址和数量都是高字节在前。响应是功能码 + 字节数 + N×2 字节数据。以读 0x0085 开始的 6 个 RTC 寄存器为例,一进一出长这样:
请求: 00 01 00 00 00 06 01 03 00 85 00 06 └TID┘ └PID┘ └LEN┘ └U┘ └FC┘ └ADDR┘ └NUM ┘ 响应: 00 01 00 00 00 0F 01 03 0C [12 字节数据]MBAP 头里 TID 是事务号,自己递增,用来把响应和请求对上;PID 恒为 0;LEN 是「Unit ID + PDU」的总长度,请求里是 0x0006(1 字节 Unit + 5 字节 PDU),响应里是 0x000F(1 + 1 + 1 + 12)。这个长度字段是最容易写错的地方,写成 PDU 长度会少算一个字节,导致后续读到的数据整体错位一个字节,表现出来就是所有数值都不对但又「看着像数」。
2.3 异常码、超时与连接管理
异常响应的功能码是请求功能码或上 0x80,后面跟一个字节的异常码。文档列了 0x01 非法功能、0x02 非法数据地址、0x03 非法数据值、0x04 从站故障这四个。实际踩下来最常见的是 0x02,原因基本都是地址越界或者读了保留区;0x03 出现在写参数的时候,比如给 0x001D 安规类型写了个 19 这种超出枚举范围的值。
连接管理上有个细节值得记一笔:这类设备作为 Modbus TCP 服务端,能同时接受的连接数有限,两三个客户端同时挂着,后来者可能连上但读不到数据。做采集服务的时候,尽量单连接单线程串行发请求,别为了「并发快一点」开多线程,1 秒间隔的约束下并发毫无意义,只会把设备打懵。
下面这段是我常用的最小客户端,把 1 秒节流、事务号校验、异常码解析都塞在一起,三十来行够用:
import socket, struct, time FC_READ_HOLDING, FC_READ_INPUT, FC_WRITE_SINGLE = 0x03, 0x04, 0x06 class ModbusTcp: def __init__(self, host, port=502, unit_id=1, timeout=1.2): self.sock = socket.create_connection((host, port), timeout=timeout) self.sock.settimeout(timeout) self.unit_id = unit_id self._tid = 0 self._last = 0.0 # 上一次收发的时刻,用于守住 1 秒间隔 def _recv_exact(self, n): buf = b"" while len(buf) < n: chunk = self.sock.recv(n - len(buf)) if not chunk: raise ConnectionError("对端关闭连接") buf += chunk return buf def _xfer(self, pdu): gap = time.monotonic() - self._last if gap < 1.0: # 文档要求两条指令最小间隔 1 秒 time.sleep(1.0 - gap) self._tid = (self._tid + 1) & 0xFFFF # LEN = Unit ID(1) + PDU 长度,这是最容易算错的一个字段 mbap = struct.pack(">HHHB", self._tid, 0, len(pdu) + 1, self.unit_id) self.sock.sendall(mbap + pdu) tid, proto, length, unit = struct.unpack(">HHHB", self._recv_exact(7)) body = self._recv_exact(length - 1) self._last = time.monotonic() if tid != self._tid: raise IOError("事务号不匹配,可能上一帧残留在缓冲区") if body[0] & 0x80: raise IOError("Modbus 异常码 0x%02X" % body[1]) return body def read(self, fc, start, count): body = self._xfer(struct.pack(">BHH", fc, start, count)) nbytes = body[1] if nbytes != count * 2: raise IOError("返回寄存器数量不符: %d != %d" % (nbytes // 2, count)) return list(struct.unpack(">%dH" % count, body[2:2 + nbytes]))参数上要注意三处:timeout别设太大,1.2 秒是配着 1 秒响应超时来的;unit_id默认 1,读不到就试 0 和机器地址;_gate()里那个 1.0 秒是文档写死的最小间隔,改成 0.2 秒在实验台上可能看着能用,连续跑两小时一定开始丢帧。返回的寄存器列表是一串无符号 16 位数,怎么解释成电压、电流、温度,全看下一页表。
3. 保持寄存器区:0x0000–0x00E7 里装着整机配置
保持寄存器区是这台设备的「配置盘」,用 0x03 读,用 0x06 或 0x10 写。地址从 0x0000 一路排到 0x00FB 附近,中间夹着大段保留,功能上大致分五段:身份信息、并网保护阈值、固件版本与时钟、电池与充放电策略、远程控制与继电器。写这块要格外小心,改错一个保护阈值,设备可能直接报并网故障停机。
上手第一步不是读实时量,而是先把身份区和版本区读出来存档。序列号、机型、固件版本这几项决定了后面所有寄存器怎么解释,尤其是版本号,V2.50 的文档和 V2.2 的文档在能量单位上差 10 倍,不看版本就换算,数据必然对不上。
3.1 身份区与并网保护阈值(0x0000–0x002B)
0x0000 到 0x0014 是三组 14 字符的 ASCII 字符串,分别是序列号、厂商名、模块名,每组占 7 个寄存器,两个字符塞一个寄存器,高字节在前。这里有个坑:如果直接按寄存器逐个chr()拼,得到的是反的。正确做法是先把每个寄存器打包成大端两字节再拼字节串,最后按 ASCII 解码。
import struct def read_ascii14(regs, start): """0x0000-0x0006 等 14 字符字段:7 个寄存器,高字节在前""" raw = b"".join(struct.pack(">H", r) for r in regs[start:start + 7]) return raw.decode("ascii", errors="replace").strip("\x00 ")紧接着 0x0015 到 0x002B 是启动条件和并网保护阈值:0x0015 是 PV 启动电压(0.1V),0x0016 是启动等待时间(1s),0x0017/0x0018 是 PV 高压停止和低压停止阈值,0x0019 到 0x001C 是电网电压上下限和频率上下限,0x001D 是安规类型枚举,0x001E 是 PV 连接模式(1 单路、2 多路),0x001F 是 10 分钟平均过压保护点,0x0020 到 0x0023 是慢速保护的四条阈值。
0x001D 这个枚举要特别注意:文档里 0 到 18 一一对应不同的并网安规,选错了不只是数据不对,是设备并网行为会变。写的时候把文档里那张对照表原样抄进字典,别自己记,我见过抄错一格的,后面调功率因数曲线调了一整天才发现源头在这。
0x0025 到 0x002B 是功率限制和功率因数相关的量:输出功率限制百分比(0 到 100)、功率因数模式、功率因数数据(0.01)、过励磁和欠励磁上下限、功率比变化的上下限。这一组都是可以直接写的,但改之前一定先把原值读出来存一份,出了问题好回滚。
3.2 固件版本与 RTC(0x007D–0x008A)
0x007D 到 0x0083 是七个固件版本号:逆变器主、逆变器从、充电器 1 到 4、管理器。每个占一个寄存器,单位写的 1,实际含义要按厂商的编码规则解读,一般高字节是主版本、低字节是次版本,具体规则文档没细说,我的做法是原样入库,别自作主张拆。
0x0084 是机器地址,也就是 Unit ID 的来源。0x0085 到 0x008A 依次是 RTC 的秒、分、时、日、月、年。年只有两位,文档没说世纪偏移,常见做法是按 2000 加,如果读出来明显不合理(比如 1925 年),说明这台机器按 1900 偏移,减 1900 再试一次就能对上。
def decode_rtc(regs): """0x0085-0x008A:秒 分 时 日 月 年""" s, m, h, d, mo, y = regs[0x0085:0x008B] return "%04d-%02d-%02d %02d:%02d:%02d" % (2000 + y, mo, d, h, m, s)时钟这块的实际用途常被忽略。故障记录、能量统计的分段都跟设备时钟挂钩,如果 RTC 是乱的,你采上来的数据时间轴就是错的。做本地采集的时候,我一般会先读一次 RTC 和系统时间对一下,偏差超过两分钟就写回去校时,省得后面查故障对不上时间。
3.3 电池、充放电时段与 EPS(0x008B–0x00B9)
0x008B 是充电器使用模式,0 自用、1 强制时段、2 远程控制。这个寄存器是整块电池逻辑的总开关,很多人写远程功率指令没反应,就是因为它还在 0 或者 1。
0x008C 到 0x0091 是电池参数:最小容量百分比、电池类型(0 铅酸、1 锂电)、充电截止电压、放电截止电压、最大充电电流、最大放电电流。电压和电流的单位文档写的是 0.01,没带物理单位,按上下文应该是 0.01V 和 0.01A,实测时用万用表对着读一次就能确认。
0x0092 到 0x00A1 是两个充放电时间窗口,每组包含充电开始时分、充电结束时分、放电开始时分、放电结束时分,小时 0 到 23、分钟 0 到 59,各占一个寄存器。这 16 个寄存器只在 0x008B 等于 1(强制时段模式)时才起作用,自用模式下写了也不生效。
0x00B4 是电网充电许可,0 禁止、1 允许时段 1、2 允许时段 2、3 两个时段都允许,用位组合的方式表达,别当成普通枚举。0x00B5 和 0x00B6 是出口功率限制的出厂值和用户值,单位 1W。0x00B7 到 0x00B9 是 EPS 相关:EPS 静音开关、EPS 频率(0 表示 50Hz、1 表示 60Hz)、EPS 放电电压(0.1V)。
3.4 远程功率指令与三路继电器(0x00D4–0x00F9)
0x00D4 是远程充电功率指令,有符号 16 位,正值充电、负值放电,这是整个协议里少数几个明确标了 Int16 的保持寄存器。0x00D5 是远程逆变功率限制,无符号。这两个寄存器要生效,前提是 0x008B 已经切到 2(远程控制模式),顺序反了写了也白写。0x00D6 是充电吸收电压(0.01V),0x00D7 是电池唤醒标志,写 1 立即唤醒。
0x00DA 是开关机标志,这个寄存器的设计很「省」:高字节是软开关,低字节是硬开关,各占一位。读出来 0x0100 表示软开关为 1、硬为 0,别把整个值当 256 处理。写的时候也要按字节操作,直接写 1 只会动到低字节。
0x00DB 到 0x00DF 是频率下垂和 Q(U) 曲线参数:过频下垂起始点(0.01Hz)、下垂斜率(1%)、延迟时间(ms)、Q(U) 曲线上下设定点(1%)。这几项属于并网支撑功能,非必要不动。
0x00E1 开始是继电器控制,三路继电器各占 9 个寄存器,结构完全一样。以第一路为例:0x00E1 是触发功率(1W)、0x00E2 是触发时间(分钟,低字节)和关断时间(小时,高字节)合在一个寄存器、0x00E3 是触发模式(低字节,0 禁用、1 循环、2 单次、3 单次已发生)和强制时间控制开关(高字节)、0x00E4 到 0x00E7 是两组起止时间,同样是低字节分钟、高字节小时。
def split_relay_time(raw): """0x00E2 这类寄存器:低字节是分钟,高字节是小时""" minutes = raw & 0x00FF hours = (raw >> 8) & 0x00FF return hours, minutes这种「一个寄存器装两个字段」的设计在这份表里出现得很密集,写解析代码的时候一旦当整型处理,读出来的时间要么是 255 小时,要么是 3 分钟,永远对不上。我后来干脆在寄存器定义里加了个字段类型u8_pair,解析时统一按字节拆,避免手滑。
4. 输入寄存器区:实时量、故障字和能量累计
输入寄存器用 0x04 读,只读,是真正的「实时面板」。这一区从 0x0000 到 0x005B 排得比较密,中间夹了几段保留。它跟保持寄存器区最大的区别是刷新快、变化大,采样策略要按块来设计,不能想到哪读到哪。
我把这一区切成三块:0x0000 起 36 个是电气量,0x0040 起 28 个是故障字、能量和继电器状态,中间 0x0014 到 0x0023 是充电器和电池的诊断量,单独一块。三轮请求,三秒一轮,节拍稳定。
4.1 电网与 PV 的七个基础量(0x0000–0x0013)
0x0000 是电网电压(0.1V,无符号),0x0001 是电网电流(有符号,文档单位列写着 0.1V,这是笔误,按 0.1A 处理),0x0002 是电网功率(有符号,1W),0x0003 和 0x0004 是 PV1、PV2 电压(0.1V),0x0005 和 0x0006 是 PV1、PV2 电流(0.1A),0x0007 是电网频率(0.01Hz)。这七个数是一台逆变器的体检指标,本地监控页面首页放这几个就够了。
有符号的处理要统一。0x0001、0x0002、0x0008 这些字段标的是 Int16,读出来是 0 到 65535 的原始值,超过 32767 就是负数。夜里逆变器反送电或者电池放电时,这几个量会变成负值,如果忘了减 0x10000,屏幕上会出现 6553.5A 这种鬼数字。
def i16(raw): return raw - 0x10000 if raw & 0x8000 else raw def scale(raw, factor, signed=False): v = i16(raw) if signed else raw return round(v * factor, 3)0x0008 是机内温度(1℃,有符号),0x0009 是运行模式。运行模式的枚举值在文档里分散在两处说明,建议按最新版表格抄全,别用自己猜的 0 待机、1 发电这种。0x000A 和 0x000B 是两路 DC 功率(1W),0x000C 到 0x0012 是一组故障值:机内温度、PV1 电压、PV2 电压、漏电流、电网电压、电网频率、DC 分量。
这一组「故障值」是我一开始理解错的地方。它们不是当前值的副本,而是故障触发瞬间的快照,设备跳闸后这几个数会停在被记录的那一刻,平时读到的可能是 0 或者上次故障的残留值。所以做告警的时候,别拿它跟实时量比,要看故障字。
0x0013 是倒计时(1ms),一般用不上,需要观察并网前的同步过程可以盯一下。
4.2 充电器、BMS 与 SOH(0x0014–0x0023)
0x0014 到 0x001B 是充电器 1 的诊断量:电池电压(0.01V,有符号)、电池电流(0.01A,有符号)、电池功率(1W,有符号)、板级温度、电池温度、Boost 温度(都是 1℃,有符号)、电流故障值、Boost 电压故障值。电池电流的正负号文档没明说,实测充电为正、放电为负,跟 0x00D4 的功率指令方向一致。
0x001C 是剩余容量百分比,0x001D 和 0x001E 是充电器输出能量,32 位无符号,单位 0.1kWh,0x0020 和 0x0021 是输入能量,同样 32 位。0x001F 是 BMS 告警,一个 16 位掩码,具体位定义在文档的告警附录里,不同电池厂的映射还不完全一样,接入第三方电池的时候这张表必须跟电池厂对。
0x0022 是电池包数量,0x0023 是电池 SOH(百分比)。SOH 是 V2.50 才加的寄存器,更老的固件在这个地址上是保留位,读出来是 0 或者直接抛 0x02 异常。做兼容的时候先读版本号,再决定要不要读 SOH,比无脑读然后兜异常干净。
def u32(lo, hi): """文档标注 LSB 在前:0x001D 是低字,0x001E 是高字""" return (hi << 16) | lo def s32(lo, hi): v = (hi << 16) | lo return v - 0x100000000 if v & 0x80000000 else v4.3 32 位累计量与 EPS、继电器状态(0x0040–0x005B)
0x0040 和 0x0041 是逆变器故障字,低字在前,拼成 32 位掩码;0x0042 是充电器故障字,0x0043 是管理器故障字。故障字的位名在文档附录里,我一般先只报位号,等现场真出问题再逐个补名字,比一上来把 32 个名字全填上更实际。
0x0046 和 0x0047 是馈入电网功率,有符号 32 位,单位 1W。0x0048 和 0x0049 是馈入电量,无符号 32 位,0.01kWh;0x004A 和 0x004B 是用电量,同样规格。这两个是月度结算的关键量,拼错字序会得到 42949672.95 kWh 这种一眼假的数字。
0x004C 到 0x004F 是 EPS 输出:电压(0.1V)、电流(0.1A)、功率(1VA)、频率(0.01Hz)。0x0050 是今日发电量(0.1kWh,16 位),0x0052 和 0x0053 是总发电量(0.001kWh,32 位)。注意这两个的单位不一样,一个 0.1 一个 0.001,差 100 倍,代码里搞混了总量会多两个零。
0x0054 是 Unlock 寄存器,文档只给了地址和「--------」的描述,实际用途是写某些受保护参数前的解锁标志。我的做法是先读原值,写参数失败再考虑写它,别一上来就动。
0x0055 是电池温度(0.1℃),跟 0x0008 的机内温度(1℃)单位不同,一个是电芯一个是机箱。0x0056 开始是继电器状态区,三路继电器结构一致:状态(0 关 1 开)、负载功率(1W)、负载发电量(0.01kWh,32 位)、信号值、应答值。做负载控制的时候,先写保持寄存器里的触发条件,再读输入寄存器里的状态确认动作,别只看写成功就以为继电器动了。
5. 避坑与排查:五个让我返工的细节
这份协议文档写得不算差,但版本迭代多,V1.0 到 V2.50 改了十几次,中间有笔误、有单位调整、有格式从 uint16 改 int16 的历史包袱。下面五条是我实际踩过、并且每次接新机器都会先在脑子里过一遍的。
5.1 单位列里的笔误:GridCurrent 写成了 0.1V
现象:电网电流读出来跟钳形表对不上,差 10 倍或者干脆是负的一大坨。
原因:0x0001 的单位列写的是 0.1V,明显是复制上一行电压时没改。按 0.1V 换算,电流值就全错了。
解决:以量纲为准,电流按 0.1A 解,再用功率交叉验证——电网功率约等于电压乘电流再乘功率因数。三个数对不上就先怀疑单位,别怀疑硬件。碰到这类笔误,我在寄存器定义表里会加一行注释标记「单位按物理量推断」,避免下次接手的人再踩。
5.2 轮询太密:读数不变和随机超时
现象:连续两次
本文还有配套的精品资源,点击获取