OCPP 1.6J充电桩联调指南:消息时序、事务对账与智能充电避坑

发布时间:2026/11/3 20:32:47
OCPP 1.6J充电桩联调指南:消息时序、事务对账与智能充电避坑

简介:面向充电桩通信开发与运维人员的 OCPP 1.6J 协议学习资料,聚焦充电点与中央管理系统之间的标准化通信规范,适合刚接触开放充电点协议、需要系统理解消息交互与功能配置的初、中级开发者查漏补缺。压缩包仅含 1 个 PDF 文件,约 3.84MB,对应 2017-09-28 发布的协议第 2 版最终版文档,内容集中于正式规范条文,并未采用零散笔记的拼凑形式。正文按范围、术语与约定、缩略语、参考文献、导言、功能配置文件、操作的一般视图、本地授权与离线行为、与事务相关的消息、连接器编号、ID Tokens 等章节组织,完整覆盖远程启停、状态上报、计费与授权、加密通信、远程软件更新等核心场景的报文定义与流程说明,可作为实现与联调阶段随查随用的案头手册。已有 879 人学习。

1. 桩端连不上后台的那个凌晨,我翻的是这份 2017 年的文档

去年冬天帮一个做交流桩的朋友看联调问题:桩子插上电,WebSocket 握手成功,后台日志里干干净净,一条业务消息都没有。抓包一看,桩发出去的是一段 JSON 数组,后台的解析器却在等 SOAP 信封——两边说的都是「OCPP 1.6」,一个走 JSON,一个走 SOAP,谁也没错,就是不通。

OCPP 1.6J 里的那个 J,就是 JSON 的意思。这份开放充电点协议 1.6 第二版最终版(2017-09-28 定稿)把充电桩(Charge Point)和中央系统(CSMS)之间该说什么话、字段叫什么、什么时候说,一条条钉死在纸上:4 章是桩主动发起的 10 个操作,5 章是后台下发的 19 个操作,6 章把每条消息的 req/conf 字段列成表,3.13 节还专门讲了智能充电那套三层配置文件。它解决的不是「怎么写代码」,而是「两个人吵架时以谁为准」。

适合三类人:桩端嵌入式工程师、后台 CSMS 开发、以及要写对接测试用例的测试。手里有一份能查的原文,比在群里问十遍「这个字段必填吗」快得多。

2. 把目录翻成任务清单:这份文档该在什么时候查哪一节

拿到一份标准文档,最常见的错误是从第一页读到最后一页。一百多页的 PDF,读到第 3 章就开始走神,真正联调时又忘了字段名。我的做法是先做一张「问题 → 章节」的索引,把它当字典用,而不是当教材读。

2.1 章节与真实开发任务的对应关系

这份文档的骨架很清楚:前 3 章是共识层,4、5 章是操作层,6 章是字段层。同一件事往往要跨三章看——比如「远程启动充电」,5.11 节讲业务流程和参数含义,6.x 里才有 RemoteStartTransaction.req 的字段表,而它依赖的 idTag 语义又在 3.9 节和 4.1 节。翻的时候别只盯一处。

文档位置内容性质我一般在什么时候翻它
2 术语和约定定义、缩略语字段名对不上、看不懂 CSO/CPO 这类缩写时
3.3 功能配置文件能力声明项目启动,决定第一版做哪些功能
3.5 本地授权和离线行为断网策略现场网络差、要设计离线刷卡时
3.7 与事务相关的消息时序图事务链路对不齐、状态机画不出来时
3.13 智能充电负载均衡模型要做功率限制、有序充电时
3.15 / 3.16 时间与计量格式约束对账差电量、时间戳错乱时
4.x 桩发起的操作业务语义桩端实现主动上报
5.x 后台发起的操作业务语义后台实现远程控制
6.x 消息定义字段级定义写序列化代码、写测试用例时

举个具体例子:后台同事问「ChangeConfiguration 能不能改心跳周期」,答案在 5.3 节——能,键名是 HeartbeatInterval,但真正生效要等下一次 BootNotification 或者桩重启后由后台在 conf 里下发新值。这个「改完不立刻生效」的细节,只看 5.3 的字段表是看不出来的,得结合 4.2 的 interval 语义一起读。

2.2 功能配置文件:先砍出一个能跑的最小集合

3.3 节列了六个功能配置文件:Core、Firmware Management、Local Auth List Management、Reservation、Remote Trigger、Smart Charging。除了 Core,其余都是可选的,桩通过配置键 SupportedFeatureProfiles 声明自己支持哪些,后台据此决定下发什么命令。

新手最容易犯的错是第一版就把六个全勾上。Smart Charging 那一套 ChargingProfile 的三层结构和 stackLevel 优先级,够啃两周;Reservation 要和本地授权列表、connectorId 状态机一起考虑,桩端状态机会直接翻倍。我的建议是分三期:

阶段打开的功能配置文件覆盖的操作交付标志
第一期CoreBootNotification、Heartbeat、Authorize、StartTransaction、MeterValues、StopTransaction、StatusNotification插枪能充、拔枪能停、后台能看到电量
第二期加 Firmware Management、Remote TriggerUpdateFirmware、GetDiagnostics、TriggerMessage能远程升级、能主动拉状态
第三期加 Local Auth List Management、Smart ChargingSendLocalList、SetChargingProfile、GetCompositeSchedule断网能刷卡、能限功率

判断某个操作属不属于 Core 有个笨办法但很准:翻到 6.x 对应消息,看它出现在哪个 profile 的分组里;文档在 5.x 每个操作的说明开头也会标注所属 profile。桩如果没声明 Smart Charging 却收到了 SetChargingProfile,正确响应是回 CALLERROR,错误码 NotSupported,而不是假装接受然后丢掉——这一点很多人做错,后台会以为限功率生效了,实际桩在全功率跑。

2.3 消息帧:所有业务都套在同一个三段式壳子里

JSON 版本的传输层很简单:一条 WebSocket 文本帧就是一个 JSON 数组,第一个元素是消息类型编号,后面跟 UniqueId、动作名、载荷。文档 6 章开头有明确定义,这套壳子只有三种形态:

// CALL:请求,类型 2,四元组 [2, "a1b2c3", "BootNotification", { "chargePointVendor": "VendorX", "chargePointModel": "AC7-22", "chargePointSerialNumber": "SN20240001", "firmwareVersion": "1.4.2" }] // CALLRESULT:成功响应,类型 3,UniqueId 必须与请求一致 [3, "a1b2c3", { "status": "Accepted", "currentTime": "2024-03-11T02:17:44.000Z", "interval": 300 }] // CALLERROR:失败响应,类型 4,五元组 [4, "a1b2c3", "NotSupported", "Action not in supported profiles", {}]

三个参数值得单独说。UniqueId 是字符串,文档没规定格式,只要求请求和响应配对,实际实现里用自增序号加前缀最省事,别用 UUID 全量——日志里一行一个 36 字符的 ID,排查时眼睛会花。动作名是大小写敏感的驼峰串,BootNotification写成bootnotification会直接被对端判成 ProtocolError。错误码文档给了一组固定值:NotImplemented、NotSupported、InternalError、ProtocolError、FormationViolation、PropertyConstraintViolation、OccurrenceConstraintViolation、TypeConstraintViolation、SecurityError、GenericError,桩端至少要能把 NotImplemented 和 NotSupported 分开——前者是「我还没做」,后者是「我这个 profile 不支持」,运维看日志时这两个词的含义完全不同。

还有一条容易被忽略:CALLERROR 的第五个元素是 ErrorDetails,类型是对象,没有内容也要给空对象{},不能省略成四个元素。某些后台的解析器对数组长度做严格校验,少一个元素整条消息就被丢弃,表现就是「桩明明回了错误,后台毫无反应」。

3. 打通第一条链路:BootNotification 到 StatusNotification 的握手细节

后台和桩之间,第一条真正有业务含义的消息永远是 BootNotification。它之前的 WebSocket 握手只是把管子接通,管子通不通、对面认不认你,得看这条消息的响应。

3.1 WebSocket 子协议与 URL 约定

连接建立时有两个参数必须先对齐,文档没有强制规定 URL 长什么样,但它规定了子协议名:ocpp1.6。这是握手阶段 HTTP 头Sec-WebSocket-Protocol里要带的值,桩和后台必须一致,否则服务端会拒绝升级或者接受后按别的协议解析。

URL 路径各家实现不同,常见做法是ws://host/ocpp/{chargePointId},也有的把路径写成/steve/websocket/CentralSystemService/{chargePointId}。路径里的 chargePointId 就是这台桩在后台的唯一身份,桩端配置里必须能改,出厂固件写死会导致同型号的桩在现场互相顶号。测试阶段可以用 wscat 手工发一帧,验证服务端到底认不认这个路径:

# 带子协议连接,注意 -s 参数,漏了会被服务端以 400 拒绝 wscat -c "ws://127.0.0.1:8080/ocpp/CP0001" -s ocpp1.6 # 连上后手工粘贴一条 CALL,观察返回的 CALLRESULT [2,"t1","Heartbeat",{}]

这条命令的价值在于把「桩端代码问题」和「后台问题」快速切开。如果 wscat 能拿到正常的 CALLRESULT,说明后台和路径没问题,问题在桩的网络层或序列化;如果 wscat 也连不上,先查端口、路径和子协议,别急着看业务代码。血泪经验是:抓包看到 101 Switching Protocols 就以为通了,其实子协议协商失败时某些服务端仍会完成升级,然后在第一帧上把连接踢掉,日志里只留一行「connection closed」。

3.2 BootNotification:心跳周期到底谁说了算

BootNotification 是桩的自我介绍,请求里 chargePointVendor 和 chargePointModel 是必填,其余如序列号、固件版本、ICCID、IMSI 按需给。真正决定后续行为的是响应里的三个字段:

字段类型含义与取值影响
status枚举Accepted 正常工作;Pending 桩需按间隔重发;Rejected 桩不得再发,直到配置变更
currentTime日期时间后台当前时间,桩应据此校时
interval整数(秒)心跳间隔,桩必须以此为准,覆盖本地配置

interval这条最容易被桩端实现忽略。很多固件里心跳周期是本地写死的 60 秒,后台返回 300 也不理,结果就是现场几百台桩每分钟打一次后台,日志量爆炸。文档的语义很明确:以 conf 里的 interval 为准。同理,status 返回 Pending 时桩必须重新发 BootNotification,间隔就是 interval,直到拿到 Accepted——不是发一次就完事。我见过一个实现只发一次,后台因为数据同步慢返回了 Pending,桩就此安静,后台永远看不到这台设备上线。

时间同步也在这个环节做。currentTime 是带时区的 ISO 8601 串,桩拿到后应该校准本地 RTC,并把这个时区偏移记住,后面所有 timestamp 字段都要带同样的偏移量,这一点在 3.15 节有明确要求。

3.3 Heartbeat、StatusNotification、TriggerMessage 的时序配合

握完手之后,桩进入稳态,靠三类消息维持后台对它的认知:

  • Heartbeat:空载荷,响应只回 currentTime。它的作用不是「证明我活着」这么简单,还是桩校时的第二次机会,长时间运行后 RTC 漂移靠它纠回来。
  • StatusNotification:上报某个连接器的状态,字段包括 connectorId、errorCode、status、timestamp。status 的取值有 Available、Preparing、Charging、SuspendedEV、SuspendedEVSE、Finishing、Reserved、Unavailable、Faulted 九种,桩端状态机基本就是围着这九个词转。
  • TriggerMessage:后台主动要求桩补发某条消息,常见于后台重启丢了状态、或者要强制刷新一次 MeterValues。

这里有三个参数必须记牢:connectorId 为 0 表示整个充电桩,1 及以上表示具体枪口;errorCode 无故障时必须是 NoError,不能填空串或 null;status 变化时必须发 StatusNotification,包括插枪(Preparing)、开始充电(Charging)、拔枪(Available)这些日常动作。后台的实时看板就是靠这一串状态变化驱动的,漏发一次,页面上的枪口就永远卡在「充电中」。

TriggerMessage 是排查利器。后台怀疑某台桩的状态不对,可以直接下发 TriggerMessage 要求它重发 StatusNotification 或 BootNotification,不用等下一次状态变化。桩端实现时要注意 requestedMessage 是必填,connectorId 可选,收到后先回 CALLRESULT 表示「收到了」,再发被触发的那条消息——顺序反了,后台会因为找不到对应的 UniqueId 而丢弃。

4. 事务链路怎么跑:从 Authorize 到 StopTransaction 的电量对账

充电这件事在协议里被拆成一条时间线:刷卡授权 → 启动事务 → 周期上报电量 → 停止事务。四个环节任何一个字段错位,最后账单就对不上。

4.1 Authorize 与 idTag 的五种返回

Authorize.req 只有一个字段 idTag,就是卡号或者 App 下发的虚拟卡标识。响应里的 idTagInfo.status 有五种:Accepted、Blocked、Expired、Invalid、ConcurrentTx。前四个好理解,ConcurrentTx 值得单独说——它表示这张卡已经有一个进行中的事务,用于防止同一张卡同时在两个枪口充电。

// 请求 [2,"t20","Authorize",{"idTag":"04A1B2C3D4E5F6"}] // 响应:被拒绝,但要说清原因 [3,"t20",{"idTagInfo":{"status":"ConcurrentTx"}}]

桩端的处理逻辑要区分「网络失败」和「明确拒绝」。网络超时、CALLERROR 这类情况属于「不知道」,此时如果 LocalPreAuthorize 打开且本地列表里有这张卡,可以放行;而收到 status 为 Blocked 或 Invalid 属于「明确拒绝」,必须拒绝启动,不能因为本地列表里有记录就放行——文档 3.5 节把这条边界写得很清楚,本地授权列表是「离线时的兜底」,不是「覆盖后台判断」。

另一个字段 parentIdTag 用于父子卡场景,比如一张主卡绑多张副卡,副卡刷卡时后台可以在 idTagInfo 里带回 parentIdTag,桩端据此做分组统计。不上这个功能可以完全忽略它,但解析时不能因为多了个字段就报错。

4.2 StartTransaction 到 StopTransaction:meterStart 与 meterStop 的口径

启动事务的请求里有四个关键字段:connectorId、idTag、meterStart、timestamp。meterStart 是电表当前读数,单位瓦时(Wh)。响应返回 transactionId 和 idTagInfo,桩端必须把这个 transactionId 记住,后续所有 MeterValues 和最终的 StopTransaction 都要带上它。

// 启动事务 [2,"t21","StartTransaction",{ "connectorId":1, "idTag":"04A1B2C3D4E5F6", "meterStart":125800, "timestamp":"2024-03-11T02:20:00.000+08:00" }] // 响应:拿到事务 ID [3,"t21",{"transactionId":88123,"idTagInfo":{"status":"Accepted"}}]

停止事务时 meterStop 同样是 Wh。对账公式很简单:本次充电量 = meterStop - meterStart。我在现场遇到过差 1000 倍的账单,原因是桩端电表寄存器单位是 0.1 kWh,代码直接当 Wh 塞进去了。文档对单位的规定是死的,落地时要在驱动层做一次换算并写死在单元测试里,别指望每次 review 都能看出来。

StopTransaction 的 reason 字段也常被忽略。取值包括 Local、Remote、EmergencyStop、EVDisconnected、HardReset、SoftReset、PowerLoss、Reboot、DeAuthorized、UnlockCommand、Other。后台靠它区分「用户正常拔枪」和「桩掉电导致事务中断」,前者正常计费,后者往往要走异常补单。全填 Other 的实现能跑,但运维每个月都要手工核对一堆不明原因的中断记录。

4.3 MeterValues 的 measurand、context、location 怎么组合

MeterValues 是这条链路上字段最多的消息。每个采样点包含 value、timestamp、sampledValue 数组,而 sampledValue 里又有 measurand、context、format、location、phase、unit 六个可选字段。全不填也能跑,但后台拿到一个孤零零的数字,根本不知道是电量还是功率。

我的建议是至少明确三件事:measurand 用 Energy.Active.Import.Register 表示累计电量,用 Power.Active.Import 表示瞬时功率;context 用 Sample.Periodic 表示周期采样,用 Transaction.End 表示事务结束时的最后一帧;unit 统一用 Wh 和 W。这样后台不用猜。

采样频率由后台通过配置键控制:MeterValueSampleInterval 决定带事务的采样周期,ClockAlignedDataInterval 决定对齐整点的采样周期,MeterValuesSampledData 则是一个逗号分隔的 measurand 白名单,桩只上报白名单里的量。这三个键是 ChangeConfiguration 最常改的对象,改完要注意桩端是否真的重建了定时器——很多实现只在启动时读一次配置,运行期改了不生效,表现就是「后台改了采样间隔,数据频率纹丝不动」。

4.4 离线行为与本地授权列表的边界

3.5 节讲的是断网时桩该怎么办,这段内容在实际项目里价值极高。核心是两个配置键:LocalAuthListEnabled 打开本地列表功能,LocalPreAuthorize 决定离线时是否允许凭本地列表直接启动。

本地列表通过 SendLocalList 下发,请求里带 listVersion、updateType(Differential 增量 / Full 全量)和 localAuthorizationList 数组。桩端必须维护这个 version,后台用 GetLocalListVersion 查询当前版本,版本对不上就重发。踩过的一个坑是:Full 更新时如果列表为空,语义是「清空全部」,桩端实现成了「空列表等于不更新」,结果离职员工的卡一直能用。

离线事务的处理也有讲究。断网期间启动的充电,桩要自己生成一个本地事务标识,把 StartTransaction 缓存起来,等网络恢复后按顺序补发。文档在 3.5 节提到缓存消息要有条数上限和重试策略,对应的配置键是 TransactionMessageAttempts 和 TransactionMessageRetryInterval。缓存无上限的实现迟早会把桩的内存吃满,尤其在现场网络时断时续的情况下。

5. 避坑与排查:现场最常见的 5 个翻车点

这一节按「现象 → 原因 → 解决」组织,都是我或者同行真实撞过的。

5.1 握手成功但后台一条命令都不下发

现象:WebSocket 连上了,桩也能收到自己发的 CALLRESULT,但后台的远程启动、配置查询全部超时,后台日志里没有任何入站记录。

原因:桩端把消息发成了纯业务 JSON,缺了外层[2, uniqueId, action, payload]这层壳;或者 UniqueId 用了数字类型,后台按字符串解析直接丢弃。

解决:抓包看第一帧的原始文本,确认是四元组数组、第一个元素是数字 2、UniqueId 是字符串。修完之后顺手把日志里打印原始帧的功能打开,这类问题看一眼原始报文就清楚了,比翻代码快十倍。

5.2 BootNotification 返回 Pending 之后桩就再也没动静

现象:后台因为数据同步未完成返回了 Pending,桩收到后既不重发也不报错,界面上一直显示「未注册」。

原因:桩端把 BootNotification 当成一次性动作,收到非 Accepted 就当失败处理。

解决:按 interval 周期重发,直到拿到 Accepted 或 Rejected。同时加一个重试上限和告警,避免后台故障时几百台桩同时高频重发把服务打挂。

5.3 事务电量差了整整 1000 倍

现象:后台账单显示充了 0.02 度,实际充了 20 度。

原因:电表寄存器单位是 0.1 kWh,桩端未换算直接填入 meterStart/meterStop,而协议要求 Wh。

解决:在电表驱动层统一换算成 Wh,并写一条单元测试锁定「寄存器读数 1258(0.1 kWh)→ 协议值 125800」。单位换算这种错误靠 code review 抓不住,只能靠测试卡住。

5.4 connectorId 为 0 的状态被后台当成无效数据

现象:桩整体故障时发的 StatusNotification 被后台丢弃,看板上整桩还是「可用」。

原因:后台实现里只处理 connectorId ≥ 1 的记录,把 0 当成脏数据过滤了。

解决:明确 0 代表整桩,Faulted 状态下发 connectorId=0 的通知,后台要有一个独立的「整桩状态」字段承载它,不能塞进枪口列表。这一条在联调初期就要和后台对齐,越晚改代价越大。

5.5 时间戳不带时区,跨时区对账全乱

现象:同一笔订单在桩端日志里是 14:00,后台显示 06:00,账单时间段错位。

原因:timestamp 用了本地时间且不带偏移量,3.15 节要求的是带时区偏移的 ISO 8601。

解决:全链路统一用带偏移的格式,比如2024-03-11T14:00:00.000+08:00,服务端入库时转 UTC,展示时再转回本地。桩端如果有 RTC 漂移,靠 BootNotification 和 Heartbeat 的 currentTime 定期校准,别指望硬件时钟自己准。

6. 智能充电的验证技巧:用 GetCompositeSchedule 回读你设下去的曲线

SetChargingProfile 是这份文档里最难啃的一块,难的不是字段,是「设下去之后到底生效了没有」。三层配置文件按优先级叠加:ChargePointMaxProfile 限制整桩总功率,TxDefaultProfile 是新事务的默认曲线,TxProfile 针对某个具体事务,同层之间再按 stackLevel 比大小,数字大的优先。只看请求返回 Accepted 说明不了任何问题——桩完全可能接受了配置却算错了合成曲线。

我一般用回读验证:设完配置,立刻用 GetCompositeSchedule 让桩把未来一段时间的合成计划算出来给我看。

// 先设一条 16A 的单相限流曲线到 1 号枪 [2,"t30","SetChargingProfile",{ "connectorId":1, "csChargingProfiles":{ "chargingProfileId":101, "stackLevel":5, "chargingProfilePurpose":"TxDefaultProfile", "chargingProfileKind":"Absolute", "validFrom":"2024-03-11T00:00:00.000+08:00", "chargingSchedule":{ "startSchedule":"2024-03-11T00:00:00.000+08:00", "chargingRateUnit":"A", "chargingSchedulePeriod":[{"startPeriod":0,"limit":16}] } } }] // 再回读:duration 单位秒,这里要未来两小时的合成计划 [2,"t31","GetCompositeSchedule",{ "connectorId":1, "duration":7200, "chargingRateUnit":"A" }]

回读的响应里 chargingSchedule 的每个 period 就是桩自己算出来的结果。验证要点有三个:一是单位,请求用 A 回读也必须用 A,混用 W 时桩要做电压换算,不同桩的换算系数不一样,容易出偏差;二是 startPeriod 是相对 startSchedule 的秒数偏移,不是绝对时间,测试用例里要按这个口径断言;三是 duration 超长时桩可以截断,回读结果比请求短是合法的,别把它当失败。

还有一个容易漏的点:TxProfile 必须带 transactionId,且只在对应事务进行中有效,事务一结束就自动失效;而 TxDefaultProfile 不带 transactionId,对所有新事务生效。测试时如果发现限流不生效,先确认当前有没有进行中的事务、设置的是哪一层,再看 stackLevel 有没有被更高层的曲线压住。我吃过一次亏:现场调功率限制,设了 TxDefaultProfile,回读一切正常,车就是满功率充。查了两小时才发现桩端实现把「事务已启动」判断写反了,TxProfile 优先级压根没参与计算。

从那以后我每次调智能充电,都强制走一遍「设配置 → 回读合成曲线 → 实车观察功率」三步,任何一步对不上就不往下走。这套笨办法救过好几次现场,也让我对那三层配置的优先级再没记混过。希望帮到你。

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