ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OCPP 1.6J 充电桩协议对接:消息帧、智能充电与联调避坑

OCPP 1.6J 充电桩协议对接:消息帧、智能充电与联调避坑

简介:面向充电桩通信开发、测试与运维人员的OCPP 1.6J协议学习资料,围绕开放充电点协议1.6第2版最终版(2017-09-28)规范原文展开,适合刚接触桩云通信、需要弄清报文格式、功能配置与互操作性要求的初、中级工程师对照查阅。压缩包内为1个PDF文件,约3.84MB,完整保留范围、术语与约定、缩略语、参考文献、导言与文档结构等章节,并展开功能配置文件、操作的一般视图、本地授权与离线行为、与事务相关的消息、连接器编号、ID Tokens等模块,可当作协议条款速查与实现对照手册。已有879人学习下载,读者可据此梳理远程控制、状态上报、计费与授权等消息流,理解TCP/IP连接初始化、认证与心跳保持机制,并为设备联调与故障排错提供可引用的规范依据。

1. OCPP 1.6J 学习资料:一份协议文档怎么变成能跑的对接代码

做充电桩后台的同行大概率都经历过这种场面:桩厂发来一份 PDF,说“我们严格遵循 OCPP 1.6J”,结果联调第一天 BootNotification 就卡住,对方回包少了interval,心跳定时器直接失效。问题不在协议难,而在于 OCPP 1.6J 的规范文档是英文长文档,几百页里散着 JSON 消息定义、功能配置文件、智能充电曲线三套东西,新人上手根本不知道该从哪一页读起。

这份《OCPP1.6J学习质料》就是冲着这个痛点来的:它是 OCPP 1.6 第 2 版最终版的完整中文整理稿,2017-09-28 定稿,目录从“范围”“术语和约定”一路排到 6.x 的逐条消息定义,Authorize.req、BootNotification.conf、MeterValues、SetChargingProfile 全都在册。适合三类人:刚接手充电桩后台的服务端开发、要写桩端固件的嵌入式工程师、以及做充电网络平台对接的实施人员。它不教你怎么写代码,但它决定了你写的代码回包字段对不对。

2. 先搞懂 1.6J 的 J 是什么:JSON over WebSocket 的通信底座

很多人拿到文档第一反应是翻消息定义,翻两页就晕,因为不知道消息是怎么从 A 走到 B 的。OCPP 1.6J 里的 J 指 JSON,整份协议在传输层走的是 WebSocket,充电点(Charge Point)作为客户端主动连中央系统(Central System)的 WebSocket 地址,连上之后双方用 JSON 数组收发帧。这一步不理解,后面所有.req/.conf都是空中楼阁。

2.1 为什么是 WebSocket 而不是 REST

常被问到的问题是:为什么不用 HTTP 接口一问一答?原因在“中央系统要主动下发指令”这个刚性需求上。远程启动、远程停止、改配置、推固件、设充电曲线,全是中央系统发起、充电点响应,纯 REST 得靠轮询,桩端在弱网下根本扛不住。WebSocket 长连接建立后双工通信,中央系统随时能推RemoteStartTransaction.req,桩端在同一连接上回.conf,这条链路才成立。

文档第 3.4 节“操作的一般视图”讲的就是这个模型,只是写得抽象。落到实现上,一条连接的生命周期是这样的:

充电点 中央系统 |------ WebSocket 握手 --------->| |<----- 101 Switching Protocols -| |------ BootNotification.req --->| |<----- BootNotification.conf ---| (含 heartbeatInterval) |------ Heartbeat.req ---------->| (每 interval 秒一次) |<----- Heartbeat.conf ----------| |------ Authorize.req ---------->| |<----- Authorize.conf ----------| (Accepted / Invalid / Blocked) |------ StartTransaction.req --->| |<----- StartTransaction.conf ---| (返回 transactionId) |------ MeterValues.req -------->| (周期上报电量) |------ StopTransaction.req ---->| |<----- StopTransaction.conf ----|

上图的时序不是装饰,它是你排错时的第一参照物。桩端“连上了但后台收不到数据”,八成是 BootNotification 没被接受或者心跳断了;后台“下发远程启动没反应”,先确认这条 WebSocket 是不是已经断了但两边都没感知。

2.2 消息帧的固定结构:三个元素的 JSON 数组

1.6J 所有消息都是定长 JSON 数组,第一个元素是消息类型 ID,这是整个协议最不能记错的地方:

类型 ID名称方向结构
2CALL请求方到响应方[2, uniqueId, action, payload]
3CALLRESULT响应方回请求方[3, uniqueId, payload]
4CALLERROR响应方回请求方[4, uniqueId, errorCode, errorDescription, errorDetails]

uniqueId是请求方生成的唯一字符串,响应必须原样带回,否则请求方无法把响应和请求配对。这一点在并发场景下会翻车:同时下发RemoteStartTransaction和ChangeConfiguration,如果实现里用自增整数当 ID 且没有去重,两条响应对错了号,就会出现“改了配置但返回的是远程启动结果”的诡异现象。常见做法是用 UUID 或“时间戳+序号”拼接,保证全局唯一。

2.3 用 Python 起一个最小可跑的桩端骨架

光看文档不动手,永远记不住字段。下面这段用websockets库写一个最小桩端,只做 BootNotification 和 Heartbeat,能把连接建立起来就说明你理解了 1.6J 的握手逻辑:

import asyncio, json, uuid import websockets CP_ID = "CP001" CSMS_URL = "ws://127.0.0.1:9000/ocpp/CP001" def call(action, payload): # 类型 2 = CALL,uniqueId 保证请求响应可配对 return json.dumps([2, str(uuid.uuid4()), action, payload]) async def main(): async with websockets.connect(CSMS_URL, subprotocols=["ocpp1.6"]) as ws: # 上电第一件事:BootNotification await ws.send(call("BootNotification", { "chargePointVendor": "VendorX", "chargePointModel": "ModelA", "chargePointSerialNumber": "SN-0001", "firmwareVersion": "1.0.0" })) resp = json.loads(await ws.recv()) # resp[2] 是 payload,status=Accepted 才继续 interval = resp[2].get("interval", 300) print("boot:", resp[2]["status"], "heartbeat:", interval) while True: await ws.send(call("Heartbeat", {})) await ws.recv() await asyncio.sleep(interval) asyncio.run(main())

几个参数必须说清楚:subprotocols=["ocpp1.6"]是握手时声明的子协议,部分中央系统会校验,不带可能被直接拒绝;BootNotification的四个字段里,chargePointVendor和chargePointModel是必填,厂商和型号对不上后台的白名单,回包就是Rejected,桩端要按文档约定做退避重连而不是死循环猛刷;interval来自后台回包,不是你本地写死的,把它写死是新手第一个坑。这段代码跑通,再往下读第 4、5 章的消息定义,脑子里就有承接对象了。

3. 把目录当索引读:19 条中央系统指令与 10 条桩端指令怎么落地

这份资料最有价值的不是前面那些概念章节,而是第 4 章和第 5 章把指令按“谁发起”切成了两半。桩端发起的 10 条(Authorize、BootNotification、DataTransfer、DiagnosticsStatusNotification、FirmwareStatusNotification、Heartbeat、MeterValues、StartTransaction、StatusNotification、StopTransaction),中央系统发起的 19 条(从 CancelReservation 到 UpdateFirmware),这个分类方式直接对应你代码里的两个 handler 目录,照着文档目录建文件夹,比任何架构图都顺手。

3.1 桩端发起:交易链路的四个关键节点

一次完整充电在协议层面是四步:Authorize 验卡、StartTransaction 开单、MeterValues 记账、StopTransaction 关单。文档 3.6 节专门讲了 Transaction 与电能传输周期的关系,这句话在实现时的含义是:StartTransaction.req里的meterStart必须是开单那一刻的电表读数,StopTransaction.req里的meterStop是关单读数,后台用差值算电量,两个值取错位置,账就对不上。

StartTransaction.conf返回的transactionId是后台生成的,桩端必须保存并在后续每条MeterValues.req里带上,否则后台认不出这条计量属于哪一单。见过不止一个项目在这里翻车:桩端把transactionId存内存里,设备重启丢了,后续上报的 MeterValues 全部变成孤儿数据,后台只能靠时间戳硬凑。正确做法是落本地持久化,哪怕写个 SQLite 也比内存靠谱。

StatusNotification.req值得单独提一句。它上报的是连接器状态(Available、Preparing、Charging、SuspendedEV、Finishing、Unavailable、Faulted),文档 4.9 节把状态机画得很细。实现时最容易漏的是errorCode字段,正常时填NoError,但很多桩端在 Faulted 状态下也填NoError,后台告警永远不会触发。这个字段是运维的眼睛,别糊弄。

3.2 中央系统发起:远程控制与配置管理的落地顺序

19 条指令不用一次全实现,按业务优先级排,我一般建议这个顺序:

优先级指令落地理由
P0RemoteStartTransaction / RemoteStopTransaction无此功能后台无法干预充电过程
P0ChangeAvailability / Reset运维基本盘,改可用性和重启
P1ChangeConfiguration / GetConfiguration参数远程调整,减少现场跑腿
P1TriggerMessage排错利器,强制桩端补发指定消息
P2SetChargingProfile / GetCompositeSchedule智能充电,涉及 3.13 节曲线模型
P2SendLocalList / GetLocalListVersion离线授权,弱网场景必需
P3UpdateFirmware / GetDiagnostics固件与诊断,依赖文件传输通道

ChangeConfiguration.req的key取值不是随便编的,文档附录里有标准键名清单(如HeartbeatInterval、MeterValueSampleInterval、ConnectionTimeOut),后台下发的键名和桩端认识的键名对不上,回包就是NotSupported。这类问题没有报错堆栈,只能靠对照文档逐字比对,属于典型的“文档读没读到位”。

TriggerMessage.req是排错时最值得先实现的。它能让桩端立刻补发 BootNotification、Heartbeat、MeterValues、StatusNotification 中的任意一条,联调时后台收不到状态,不用等桩端自己触发,直接下发一条 Trigger 就能验证链路通不通。文档 5.17 节对requestedMessage的枚举值写得很清楚,实现时注意它还有个可选的connectorId,不填默认针对整个充电点。

3.3 DataTransfer 这条“后门”通道的正确用法

文档 3.12 节讲 Vendor-specific data transfer,对应的就是DataTransfer.req/.conf,桩端和后台都能发起。它的设计意图是给厂商自定义扩展留口子,vendorId标识厂商,messageId标识业务类型,data是自由字符串。实际项目里常见两种用法:一是传自定义诊断日志,二是做协议未覆盖的业务(比如本地支付的二维码状态同步)。

用这条通道有两个边界要守住。第一,别拿它替代标准消息,比如用 DataTransfer 传 MeterValues,后台换一家供应商就全废了,互操作性正是 OCPP 存在的意义。第二,data字段虽然自由,但长度要控,部分桩端固件对单帧大小有限制,塞几 MB 的日志进去会被截断或直接断连,日志类数据老老实实走 GetDiagnostics 的文件上传流程。文档 5.6 和 4.3 两节分别定义了双向的 DataTransfer,实现时注意data为空时的回包约定。

4. 智能充电与本地授权:文档里最容易被跳过、上线后最常出事的章节

如果只挑文档里两个“读的时候觉得抽象、上线后天天被问”的章节,我选 3.13 智能充电和 3.5 本地授权与离线行为。前者决定了多桩场景下功率怎么分,后者决定了断网时车还能不能充。这两块在很多项目里是二期才做,但协议层面一期就该留好字段,不然改起来伤筋动骨。

4.1 ChargingProfile 的三层结构与两个关键字段

文档 3.13 节定义的充电配置文件有三个作用层级:ChargePointMaxProfile(整桩总功率上限)、TxDefaultProfile(默认单笔交易曲线)、TxProfile(指定交易的曲线)。后台下发SetChargingProfile.req时用chargingProfilePurpose指定层级,用stackLevel决定同层级多条配置的优先级,数值大的覆盖数值小的。

真正难的是曲线本身的表达。chargingSchedule里每个chargingSchedulePeriod有startPeriod(相对开单时刻的秒偏移)和limit(该时段的功率或电流上限),再配合chargingRateUnit(W 或 A)。举个实际配置:前 30 分钟限 7kW,之后限 3.5kW,写法是两段 period,startPeriod分别是 0 和 1800。这里最常见的错误是把startPeriod写成绝对时间戳,协议要的是相对偏移,写错了曲线要么立刻生效要么永远不生效,而且没有任何报错,全凭后台算出来的 composite schedule 才能发现。

GetCompositeSchedule.req是验证曲线是否生效的唯一手段。下发配置后立刻发一条,带上connectorId和duration,桩端回的chargingSchedule就是它当前理解的最终曲线。联调时我习惯把这个回包打印出来和预期逐段比对,比看日志猜快得多。文档 5.7 节对 composite schedule 的计算规则有完整定义,多层级叠加时的取小逻辑也在里面。

4.2 本地授权列表与离线充电的降级策略

文档 3.5 节讲本地授权和离线行为,核心是三件事:LocalAuthList存本地卡号白名单,SendLocalList.req由后台推送更新,GetLocalListVersion.req用来核对版本。SendLocalList.req的updateType有Differential和Full两个值,前者增量更新,后者全量覆盖,实现时必须都支持,否则后台第一次推全量、第二次推增量,桩端只认一种就会丢卡。

离线行为的约定在文档里写得比较克制,落到工程上要自己定策略。常见做法是:WebSocket 断开后,桩端进入离线模式,本地列表里的卡直接放行,交易记录缓存在本地,重连后补发StartTransaction.req和StopTransaction.req。这里有个坑——补发的StartTransaction.req里timestamp必须是实际开单时间,不能是重连时间,后台计费按这个字段算,写成重连时间会导致账单时段错乱。同理StopTransaction.req的reason字段要区分Local(本地停止)和Remote(远程停止),财务对账时这两个值影响退款判定。

GetLocalListVersion.conf返回的listVersion是版本号,后台推送新列表时递增。桩端上线后先查版本,不一致再请求全量,这套握手机制在文档 5.10 和 5.15 节有定义,别跳过。

5. 联调避坑:五条血泪经验,每条都能省你两天

协议读得再熟,联调照样翻车。下面这五条是实战里出现频率最高的,每条都按“现象—原因—解决”写清楚,遇到时直接对号入座。

5.1 现象:连接建立成功,但后台一条消息都收不到

原因多半出在子协议或路径上。部分中央系统在 WebSocket 握手时校验Sec-WebSocket-Protocol,桩端不带ocpp1.6会被接受连接但不处理业务帧;另一种情况是 URL 路径里的充电点编号和 BootNotification 里的chargePointSerialNumber不一致,后台把消息路由到了不存在的会话。

解决:抓包看握手请求头,确认子协议字段存在且拼写完全一致(是ocpp1.6不是ocpp16);URL 路径、BootNotification 里的编号、后台设备台账三者必须统一,改一处就要三处对齐。

5.2 现象:心跳正常,但远程指令下发后桩端无响应

原因通常是 CALL 消息的uniqueId冲突,或者桩端收到未知 action 直接丢弃没有回 CALLERROR。协议要求任何 CALL 都必须有响应,哪怕是错误响应,静默丢弃会让后台的请求超时机制误判设备离线。

解决:桩端 handler 加默认分支,未知 action 一律回[4, uniqueId, "NotSupported", "Unknown action", {}];uniqueId改用 UUID;后台侧对超时的请求记录 action 名和 ID,方便区分是网络问题还是桩端沉默。

5.3 现象:MeterValues 上报了,后台电量统计始终为 0

原因是sampledValue数组里缺measurand或unit字段,后台按默认规则解析失败。文档 3.16 节对仪表计量数据的字段有明确定义,measurand可取Energy.Active.Import.Register、Power.Active.Import、Current.Import等,unit对应Wh、W、A。

解决:上报时每个sampledValue显式带measurand、unit、value三件套,别依赖后台的默认推断;context字段按场景填Sample.Periodic或Transaction.End,后者用于关单时的最终读数。

5.4 现象:SetChargingProfile 返回 Accepted,但功率限制不生效

原因是配置文件下发的connectorId和实际充电的连接器不匹配,或者stackLevel低于已有配置被覆盖。还有一种隐蔽情况:chargingProfileKind填了Absolute,配套的startSchedule用了本地时区时间,而协议要求 UTC。

解决:下发后用GetCompositeSchedule验证实际生效曲线;connectorId=0表示整桩,单连接器要填具体编号;所有时间字段统一 UTC 并带时区偏移,文档 3.14 和 3.15 节对时区和时间表示有专门约定,别用本地时间硬怼。

5.5 现象:断网重连后交易重复计费或订单丢失

原因是离线缓存的重发逻辑没有做幂等。桩端重连后把缓存的 StartTransaction 和 StopTransaction 全量重发,后台如果按消息逐条建单,就会重复;反过来只重发 Stop 不重发 Start,又会出现孤儿订单。

解决:后台用transactionId或“桩编号+开单时间戳”做幂等键,重复的 Start 返回原单号;桩端缓存记录加状态位,收到.conf才标记已同步,未确认的才重发。StopTransaction.req的transactionId必须引用原单,这是文档 4.10 节的硬性要求。

6. 用 TriggerMessage 做协议自检:一套五分钟跑完的验证流程

文档读到后面会发现,真正能救命的不是那些业务指令,而是TriggerMessage。它让桩端在后台命令下立刻补发指定消息,等于给了你一个远程的“单步调试”按钮。我现在的习惯是:任何新桩型接入,先不急着跑完整充电流程,用 Trigger 把六条核心消息逐条触发一遍,全通了再谈业务。

具体做法是后台依次下发TriggerMessage.req,requestedMessage分别取BootNotification、Heartbeat、StatusNotification、MeterValues、DiagnosticsStatusNotification、FirmwareStatusNotification,每条观察三件事:桩端是否回.conf(状态Accepted或Rejected)、是否真的补发了对应消息、补发消息的字段是否完整。下面是一个批量触发的脚本片段,跑在后台侧:

import asyncio, json, uuid TRIGGERS = [ "BootNotification", "Heartbeat", "StatusNotification", "MeterValues", "DiagnosticsStatusNotification", "FirmwareStatusNotification" ] async def trigger_all(ws, cp_id): for msg in TRIGGERS: # connectorId 可选,0 表示整桩 payload = {"requestedMessage": msg, "connectorId": 0} frame = [2, str(uuid.uuid4()), "TriggerMessage", payload] await ws.send(json.dumps(frame)) resp = json.loads(await ws.recv()) # resp[2]["status"] 为 Accepted 才说明桩端认这条触发 print(f"{msg:32s} -> {resp[2].get('status')}") await asyncio.sleep(1)

这段脚本的价值在于把“哪条消息链路不通”变成一张清单。status回NotImplemented说明桩端固件没实现该消息,得找桩厂;回Rejected通常是当前状态不允许(比如正在充电时触发 BootNotification),换个时机再试;无响应则是 5.2 节那个uniqueId或 handler 缺省分支的问题。

跑完这一轮,再对照文档第 6 章的逐条字段定义做字段级核对,重点看三类容易漏的:时间字段是否 UTC 带偏移、枚举值拼写是否与文档一致(Accepted不是Accept)、可空字段是省略还是传null(协议对这两者处理不同,部分后台严格校验)。这套流程走下来,一个新桩型的协议层问题基本能在半天内暴露干净,比等到现场充电出故障再回头翻文档强太多。

有个教训值得说:早期带项目时图快,跳过 Trigger 自检直接跑全流程,结果现场发现 MeterValues 缺measurand,账单全是零,返工改固件加重新部署花了两个礼拜。从那以后我每次接新桩,Trigger 六连发是强制项,一条不过就不往下走。希望这套流程能帮你在联调阶段就把问题摁住。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进