☰
OCPP 1.6 JSON消息与WebSocket网关:充电桩事件解析实战指南
2026/10/8 14:38:34 网站建设 项目流程

简介:OCPP 1.6是欧洲充电桩广泛使用的开放充电协议,这份资源将协议原文按消息事件整理成JSON格式定义,涵盖启动通知、鉴权、开始交易、计量值、远程启动交易等核心事件的请求与响应结构,并逐条标注必要字段与可选字段,便于开发者在联调中对照报文格式。资源包共78个文件,全部为JSON文本,压缩后仅28KB,按功能对消息分类存放,目录结构清晰,可用于模拟器直接发送自定义JSON包,验证充电桩的应答逻辑。借助这套定义,无需翻阅原始协议文档,即可按一问一答模式模拟启停充电、鉴权、结算计费、远程升级等完整业务流程;若返回报文与预期不符,能迅速定位缺失字段或类型错误,显著缩短排错时间。资源还覆盖证书签名、日志上传、安全事件通知等扩展消息,适合需要处理固件升级和安全接入管理的集成场景。目前已有5459人学习下载,适合嵌入式开发、协议测试、充电桩运维及售前技术支持人员作为常备参考手册使用。

1. 欧标充电桩的 OCPP 1.6 JSON 消息:先看懂一条 Heartbeat 就成功了一半

欧标充电桩与运营后台之间的通信,几乎都绕不开 OCPP 1.6。这个版本最常用的传输方式是 OCPP-J,也就是把充电桩上报的事件和后台下发的指令统一编码成 JSON 数组,通过 WebSocket 长连接一条条传。你拿到的这份资源,正是围绕这件事整理的:OCPP 1.6 消息事件的 JSON 格式说明、常用配置键参数表,以及一套在 Linux 上可以直接跑起来的解析代码包。它解决的是现场最烦的几类问题:桩明明在线却不上报、计费电量落库后对不上、远程启动指令发出去没反应。适合刚接手充电桩接入的后端开发、调试桩固件的工程师,以及被现场桩逼到要抓包定位的运维——把消息事件捋顺,充电桩对你就不再是黑匣子。

2. 消息事件的三层结构:CALL、CALLRESULT 与 CALLERROR 的 JSON 骨架

2.1 为什么选 OCPP-J:和 SOAP 绑定比出来的结论

OCPP 1.6 的规范文本里同时定义了两种传输绑定。OCPP-S 走 SOAP over HTTP,消息被包在 XML Envelope 里,光是一条 BootNotification 请求,去掉换行也有近千字节;调试时要么在后台打印 DOM 树,要么借助专门的 SOAP 工具才能看清字段。OCPP-J 则是 JSON over WebSocket,一条消息就是一段紧凑的 JSON 数组,wscat、tcpdump 抓包后直接文本过滤就能读。欧标充电桩这两年的出货设备默认就开 OCPP-J,SOAP 通道基本只在老项目维护阶段还有存在感。

选型上还有一个现实考量。OCPP-J 的 WebSocket 长连接天然适合推送型业务:桩随时可以上报事件,后台随时可以下发指令,不需要反复握手;SOAP 绑定则要后台开放公网端口或做主动轮询,桩数量一上来,连接管理成本会明显增加。所以新接入的项目,包括资源里这套代码包,都是按 OCPP-J 写的。记住一点,OCPP 1.6 的 JSON 绑定只支持 WebSocket,不支持裸 HTTP 轮询,桩和后台之间必须维护一条长连接。

1.6 的 JSON 消息固定在四层数组结构上,这和 2.0.1 的嵌套 JSON 对象完全不同,别拿新版本的记忆去套老协议:

数组位置含义说明
[0]messageType2 表示 CALL,3 表示 CALLRESULT,4 表示 CALLERROR
[1]uniqueId请求发起方生成,响应必须原样带回
[2]action / payloadCALL 时是消息名,响应时是结果 payload
[3]payload / errorDetailCALL 时是消息体,CALLERROR 时是错误描述对象

最容易记混的是位置 [2]:CALL 的第三位是动作名,CALLRESULT 的第三位却是真正的结果字段。写解析器的时候要是拿 CALL 的逻辑去套响应,字段会全部错位,这类问题在自有协议转换的代码里尤其常见。

2.2 CALL 消息:桩上报事件的 JSON 骨架

充电桩主动上报的事件统一走 CALL。先看最简单的 Heartbeat,也就是"我还活着"的心跳:

[2, "hb-20250114-083000", "Heartbeat", {}]

后台收到后必须这样回:

[3, "hb-20250114-083000", {"currentTime": "2025-01-14T08:30:05Z"}]

解析逻辑就是把 [1] 的 UniqueId 当作键,把这条请求存进待办表;等收到 [1] 相同的 CALLRESULT,再取出来配对。Heartbeat 的 payload 一般是空对象,但响应里的 currentTime 是强制字段,桩收到后会用它校准内部时钟——后面讲时钟漂移问题时还要再提到它。

再看信息量最大的 MeterValues,也就是计费和电能数据上报。资源里给出的典型报文是这样的:

[2, "mv-20250114-083300", "MeterValues", { "connectorId": 1, "transactionId": 10, "meterValue": [{ "timestamp": "2025-01-14T08:33:00Z", "sampledValue": [{ "value": "12500", "measurand": "Energy.Active.Import.Register", "unit": "Wh", "context": "Sample.Periodic" }] }] }]

meterValue 是数组,表示一次 CALL 可以批量携带多组数据;sampledValue 又是数组,允许在同一时间戳下上报多个测点。value 永远是字符串,哪怕内容是数字,落库前必须显式转换。measurand 缺省值是 Energy.Active.Import.Register,unit 缺省是 Wh——很多实现把这俩当必填字段解析,遇到省略的桩直接抛异常,这就是没读规范细节导致的。资源里把 StartTransaction、StopTransaction、Authorize 这几个高频事件的消息样例、必填字段和响应模板都列成了对照表,相当于一张调 OCPP 消息时随手翻的金手指速查表。

2.3 CALLRESULT 与 CALLERROR:后台处理结果的两种归宿

后台处理完 CALL 后回什么,决定了桩后续的行为。正常情况回 CALLRESULT,把动作对应的字段填全;处理失败就回 CALLERROR:

[4, "mv-20250114-083300", "InternalError", "Failed to store meter values", {}]

CALLERROR 的第三位是错误码,第四位是错误描述,第五位是附加的错误详情对象。OCPP 1.6 预定义的错误码不多,现场最常见的是 InternalError、ProtocolError、TypeConstraintViolation、OccurenceConstraintViolation 这几个。ProtocolError 表示消息本身格式不合法,比如 JSON 数组元素个数不是四个;TypeConstraintViolation 表示字段类型不对,比如把字符串填进了要求 integer 的位置。排错的时候,错误描述只是给人看的,真正要落库记录的是错误码和 UniqueId——否则你只知道"错了",不知道是哪条请求错。

给桩下发指令时方向反过来:后台发 CALL(比如 RemoteStartTransaction),桩回 CALLRESULT。但这里有一个非常普遍的误解:CALLRESULT 只代表协议层收到了,不代表桩真的开始充电。远程启动是否生效,要以桩随后上报的 StartTransaction 事件为准。资源里的代码包把这两层做了区分,协议层回执和业务确认分开处理,避免后台显示"已下发"但现场桩根本没动作的尴尬。

2.4 一条充电会话里的事件时间线

把上面的消息串起来,一次正常充电的 OCPP 事件顺序大致是:桩上电后发 BootNotification,后台回 Accepted 并带 interval;随后桩按间隔发 Heartbeat,状态变化时发 StatusNotification;插枪后上报 Authorize(如果配置了本地免鉴权则不一定发);充电开始发 StartTransaction,后台回 transactionId;充电过程中按 MeterValueSampleInterval 周期上报 MeterValues;结束充电发 StopTransaction,带 meterStop 和 reason。

这段时间线很重要,因为大多数字段之间的联动关系都在流程里体现。transactionId 是 StartTransaction 响应里产生的,后面所有 MeterValues 和 StopTransaction 都得引用它;connectorId 是枪口编号,从 1 开始。解析代码如果不对 transactionId 做状态管理,等并发充电一上来,数据串线几乎必然发生,后面避坑章节会专门展开。

3. Linux 侧消息事件链路:WebSocket 连接、心跳与消息分发

3.1 角色关系与连接参数

在 OCPP-J 模型里,充电桩是 WebSocket 客户端,后台(central system,简称 CSMS)是服务端。桩会主动连到后台配置的 URL,形如 wss://csms.example.com/ocpp/CP001,最后一段 CP001 是充电桩身份标识。连接时通常带 HTTP Basic Auth,用户名就是这个身份标识,密码由后台侧下发并预置在桩里。这意味着 Linux 侧的网关要同时做好两件事:按路径解析出桩 ID,以及在握手阶段校验 Authorization 头。资源里的代码包在 serve 回调里先从 path 取桩号,再校验 Basic Auth,校验失败直接回 401,让桩进入重试退避——这个行为在避坑章节还会展开。

开发环境可以用 ws:// 明文,生产环境必须 wss://,否则 RFID 卡号、计量数据明文暴露在网络里,合规上过不去。我一般会在网关前面再挂一层反向代理终结 TLS,应用进程只监听本地回环地址,这样证书轮换完全不打扰业务代码。

3.2 事件网关的服务端骨架

资源里这套代码基于 Python 3.9 以上版本,只依赖 websockets 库,核心是一个事件循环:

import asyncio import json import websockets # 在线连接表:charge_point_id -> websocket ONLINE_CP = {} async def handler(websocket, path): # path 形如 /ocpp/CP001,截出充电桩编号 cp_id = path.rstrip("/").split("/")[-1] ONLINE_CP[cp_id] = websocket try: async for raw in websocket: event = json.loads(raw) await route_event(cp_id, event) except websockets.exceptions.ConnectionClosed: pass finally: # 断线清理,避免往死连接里写数据 ONLINE_CP.pop(cp_id, None) async def main(): async with websockets.serve(handler, "0.0.0.0", 9000): await asyncio.Future() # 常驻运行 if __name__ == "__main__": asyncio.run(main())

这里 ONLINE_CP 是以桩号为键的字典,维护"谁在线"。route_event 是统一入口,所有消息都从这一条路径进业务,方便打日志和做统计。asyncio.Future() 那一行让进程一直挂着,等价于 while True 但更干净。开发时端口写成 9000,生产环境建议放到反向代理后面,由 443 对外。

提示:如果应用进程只绑定 127.0.0.1,把 wss 终结放在 Nginx 或 Caddy 上,桩侧证书校验和网关本身的报错排查会简单很多。

3.3 路由分发:按消息类型和动作名走不同 handler

路由逻辑要同时处理两个方向、三种类型。桩发上来的 [2,...] 按 action 分发;[3,...] 和 [4,...] 要去匹配之前后台下发指令时挂起的 pending 请求。代码大致是:

# pending 表:unique_id -> asyncio.Future PENDING = {} async def route_event(cp_id, msg): msg_type = msg[0] unique_id = msg[1] if msg_type == 2: action, payload = msg[2], msg[3] if action == "BootNotification": await handle_boot(cp_id, unique_id, payload) elif action == "MeterValues": await handle_meter(cp_id, unique_id, payload) elif action == "StartTransaction": await handle_start_tx(cp_id, unique_id, payload) # 其他 action 继续往下加 elif msg_type == 3: fut = PENDING.pop(unique_id, None) if fut: fut.set_result(msg[2]) elif msg_type == 4: fut = PENDING.pop(unique_id, None) if fut: fut.set_exception(OCPPError(msg[2], msg[3]))

PENDING 表和 asyncio.Future 是配套的:后台给桩发指令前先建一个 Future,把 unique_id 存进去,然后等桩回 CALLRESULT。这样业务代码可以像写同步调用一样等待桩的应答,不用维护复杂的回调状态。注意超时控制——Future 不能无限等,一般用 asyncio.wait_for 包一层,45 秒没回来就抛超时。另外 msg[3] 在 CALLERROR 里是错误描述字符串,msg[4] 才是错误详情对象,取错位会把日志打花。

3.4 心跳与死连接判定

桩侧按 BootNotification 响应里的 interval 周期发 Heartbeat,但后台的存活判定不能只等 Heartbeat。我的习惯是在每个连接的 on_message 里更新时间戳,再用一个后台协程每 30 秒扫一遍 ONLINE_CP,凡是最后活跃时间超过 2 个心跳周期还没动静的连接,主动 close 掉,让桩去重连。这样做的好处是能把"桩断电但 TCP 没关闭"的半死连接尽早清掉,否则 ONLINE_CP 里堆满僵尸连接,下发指令时数据全写到黑洞里。

Heartbeat 响应里的 currentTime 是桩校时的来源,如果后台返回的时间不准,桩的本地时间就会被带偏,进而影响 MeterValues 的 timestamp。所以网关服务器本身必须做 NTP 同步,这是很多人忽略的前置条件,跟桩侧的时钟问题叠加起来,现场电量曲线会错得毫无规律。

4. 写一个可复现的 JSON 事件处理器:代码包结构与参数调优

4.1 工程目录怎么摆

资源里的代码包是这么组织的:

ocpp-event-gateway/ ├── config.yaml # 监听端口、数据库连接、心跳阈值 ├── gateway.py # WebSocket 入口与路由 ├── handlers/ │ ├── __init__.py │ ├── boot.py # BootNotification / Heartbeat │ ├── transaction.py # Start / Stop Transaction │ └── meter.py # MeterValues 解析入库 ├── schemas/ │ └── ocpp1.6/ # 官方 JSON Schema 文件 └── requirements.txt

把每个 action 做成独立模块的好处是:现场新需求(比如加一个 DataTransfer 扩展消息)只动 handlers 目录,不动网关主体。schemas 目录放官方 JSON Schema,消息进来先校验再进业务,能挡掉九成格式类脏数据。config.yaml 里我一般会放监听端口、数据库连接串、心跳超时阈值、原始报文日志目录这四个必填项,其他配置全部走环境变量,方便容器化部署。

4.2 配置键:OCPP 1.6 里最常用的几个

桩和后台之间的很多行为参数,靠 ChangeConfiguration 指令下发,由桩存成配置键。资源里把和联调最相关的几个整理成了下表:

配置键类型常见值作用
HeartbeatIntervalinteger60心跳间隔,单位秒
MeterValueSampleIntervalinteger300计量数据上报周期,单位秒
ConnectionTimeOutinteger30桩判定连接超时的阈值
AuthorizeRemoteTxRequestsbooleanfalse远程启动前是否强制鉴权
StopTxnAlignedDatastring空StopTransaction 时额外上报的计量项

查桩当前配置用 GetConfiguration,改配置用 ChangeConfiguration。改完大多数键即时生效,但有些键(比如 NumberOfConnectors)需要重启桩才生效。现场改配置后没达到预期效果,第一反应应该是检查桩是否重启过,而不是怀疑配置没下发成功。资源里在 handlers/boot.py 里默认实现了 GetConfiguration 的批量查询,联调时先把桩的完整配置拉一遍,能少走很多弯路。

4.3 MeterValues 解析:按 measurand 做多测点入库

这可能是整条链路里最容易被写错的模块。合理做法是先把 sampledValue 按 measurand 拆开,再分别入库:

async def handle_meter(cp_id, unique_id, payload): connector_id = payload["connectorId"] tx_id = payload.get("transactionId") rows = [] for mv in payload["meterValue"]: ts = mv["timestamp"] for sv in mv["sampledValue"]: rows.append({ "cp_id": cp_id, "connector_id": connector_id, "transaction_id": tx_id, "ts": ts, "measurand": sv.get("measurand", "Energy.Active.Import.Register"), "value": float(sv["value"]), # value 在 JSON 里是字符串 "unit": sv.get("unit", "Wh"), "context": sv.get("context", "Sample.Periodic"), }) # 回执与入库分离:先回 CALLRESULT,再异步写库 await send_callresult(cp_id, unique_id, {}) await bulk_insert(rows)

两处细节值得注意。value 在协议里是字符串,即使内容是数字,也要显式 float() 转换,否则数据库里会出现脏类型,后续做聚合统计全是坑。send_callresult 和 bulk_insert 的顺序有讲究:耗时的批量写入放到回执之后,避免桩那边等超时把响应当失败重试。measurand 缺省值按规范补上,unit 缺省补 Wh,这样遇到精简上报的桩也不会挂。

4.4 远程指令下发:RemoteStart/Stop 的完整套路

后台要远程启动一把枪,流程是:先发 RemoteStartTransaction,等 CALLRESULT,然后真正确认启动要看 StartTransaction 事件。代码里我用一个 Future 串起两段等待:

async def remote_start(cp_id, connector_id, id_tag): unique_id = f"rs-{uuid4().hex[:12]}" call = [2, unique_id, "RemoteStartTransaction", {"connectorId": connector_id, "idTag": id_tag}] # 第一段:等协议层回执,45 秒超时 ack = await call_with_timeout(cp_id, unique_id, call, timeout=45) # 第二段:等 StartTransaction 事件,30 秒超时 tx_event = await wait_for_action(cp_id, "StartTransaction", timeout=30) return tx_payload_to_summary(tx_event)

这里把"协议回执"和"业务事件"拆成两次等待,是刻意为之。CALLRESULT 到了只能说明桩收下了指令,不能说明枪已经吸合;只有 StartTransaction 推上来了,才能确认充电会话真的建立。wait_for_action 的实现也不复杂,就是在事件入口处按 action 建 asyncio.Queue,StartTransaction 进来时往队列里塞一份,等待方从队列取。要注意这个 queue 是每个 cp_id 一份,否则多台桩同时启动,事件会互相抢。

4.5 给现场留一条后路:原始报文日志

联调阶段,我强烈建议把每个连接的原始报文按天落盘,按 cp_id 分文件。遇到过太多"后台说收到,桩说发了"的扯皮,原始日志一翻就清楚。日志里至少要带三个字段:时间戳(UTC)、cp_id、完整 JSON。量不大,一台网关撑几千台桩,日志每天也就几十 MB,磁盘完全扛得住。这份日志在追"桩是否重发"这类问题时是唯一证据,千万别只打业务日志不打原始报文。

5. 避坑记录:OCPP-J 联调中的五个高频翻车点

5.1 心跳间隔对不上:BootNotification 的 interval 被忽略

现象:现场桩的心跳一会儿 60 秒一会儿 120 秒,后台状态页面看着忽上忽下,过一会儿又有断线告警。

原因:BootNotification 的 Accepted 响应里带了 interval,桩之后按这个值发心跳;但后台在后续流程里又用 ChangeConfiguration 改了 HeartbeatInterval,两边配置打架。桩的行为取决于固件实现——有的用最后一次配置,有的只用 Boot 响应里的值,结果就是心跳间隔不稳定。

解决:以 Accepted 响应里的 interval 为基准,事后不要乱改 HeartbeatInterval;真要改,改完立刻用 GetConfiguration 确认桩侧实际值,并核对下一次心跳的实际间隔,两边对上再继续下一步。

5.2 UniqueId 重复:并发事务全部串线

现象:两辆车同时充电,A 车的 MeterValues 跑到了 B 车的事务下,后台结算账单金额错乱,现场投诉一片。

原因:桩端生成 UniqueId 太随意,很多固件用"毫秒时间戳加小随机数",毫秒级并发下就撞了。后台的 PENDING 表又按 UniqueId 匹配,一旦重复,先到的响应被后到的请求拿走,串线几乎必然发生。

解决:桩侧按协议建议改用 UUID 或"桩号加递增序号";后台侧不要只依赖 UniqueId,MeterValues 和 StopTransaction 必须再按 transactionId 做二次归属校验。资源里的事务模块就是这么写的:先按 UniqueId 配对,再按 transactionId 校验归属,双保险才能挡住脏数据。

5.3 时间戳时区混用:电量曲线整体漂移

现象:凌晨 0 点到 1 点的电量被记到前一天,曲线每天固定差一小时,运维和财务各执一词。

原因:桩上报的 timestamp 是本地时间,后台按 UTC 解析入库;或者反过来,桩上报 UTC,后台按本地时区存了。OCPP 规范里 timestamp 要求带时区后缀,但现场很多桩的固件直接填本地时间,规范归规范,现场归现场。

解决:入库前统一转 UTC,同时在 BootNotification 阶段记录桩的时钟偏移。资源里带的代码包按响应 currentTime 与桩上报时间的差值做校准,校准后再落库的曲线基本不会漂。如果桩本身没做 NTP,后台校完一轮后还会漂,那就得把桩的 NTP 配置纳入运维巡检。

5.4 后台处理超时:桩把调用当失败

现象:后台偶发收到大量 CALLERROR,错误码是 InternalError,但翻业务日志又找不到对应的异常。

原因:OCPP-J 没有强制规定响应时限,但桩侧实现普遍按 45 秒左右等 CALLRESULT,超时就认为请求失败并发 CALLERROR。后台要是在消息入口里直接做数据库写入或复杂的计量计算,很容易超时。尤其 MeterValues 处理里做了逐条 insert,桩多的时候积压会越拖越慢。

解决:入口只回执,业务处理丢进任务队列异步执行。对实时性要求高的动作(Reset、RemoteStop)单独走快路径,避免被积压任务拖慢。资源里 4.3 的代码就是先回 CALLRESULT 再批量写库,这个顺序不是风格问题,是超时问题。

5.5 断线重连风暴:一台网关被几十台桩同时砸

现象:某次机房抖动后,后台日志里瞬间涌入大量 WebSocket 握手请求,连接数秒内翻倍,数据库连接池耗尽,整个后台服务假死。

原因:桩侧重连退避策略写死成固定间隔(常见是 5 秒),网络恢复那一刻几十台桩同时重连,网关和数据库都扛不住。这不是偶发问题,是设计缺陷,迟早会踩。

解决:网关入口做连接数限流,握手阶段对超量连接直接回 401 或 503,让桩进入自己的退避流程;桩侧固件改成指数退避,初始 5 秒、上限 60 秒。现场已经出现风暴时,最有效的止血是先在网关层面拒掉一部分握手,而不是去拔线重启,否则刚启动又被打挂。

6. 用官方 JSON Schema 做本地校验:消息进业务前的最后一道保险

OCPP 官方仓库维护着 1.6 的 JSON Schema 文件,资源包里的 schemas/ocpp1.6 目录已经放了一份。我的习惯是:所有进入业务的消息先过一遍 jsonschema 校验,格式不对的当场拒绝并记录,不让脏数据污染业务表。这个习惯帮我挡掉过好几次因为桩固件升级导致的字段变化。

import json import jsonschema schema_dir = "schemas/ocpp1.6" def validate_event(action: str, payload: dict): with open(f"{schema_dir}/{action}.json", encoding="utf-8") as f: schema = json.load(f) jsonschema.validate(payload, schema)

这里有三个细节值得说。第一,validate 抛异常时,错误 message 会给出具体字段路径,排错效率比看日志高很多;第二,Action 名和 Schema 文件名一一对应,写路由时可以直接用 action 拼文件名,不用维护映射表;第三,校验档位放在"协议层回执之后、业务处理之前",这样即使校验失败,也不耽误给桩回 CALLRESULT,桩该干嘛干嘛。

最后一个建议:新接入的桩型,不要直接上生产,先在本地起一个模拟 CSMS,把资源里的示例事件一条条回放,验证后台的解析、入库、回执都正常了再接真桩。我当时处理过一批固件版本很老的桩,就是靠回放脚本找到它在 StopTransaction 里漏传 transactionId 的问题,省掉了现场反复插拔枪的折腾。从那以后,每次新接入一批桩,我都强制走一遍"Schema 校验加示例事件回放"的组合拳,再让桩上电联调。希望帮到你。

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

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

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

立即咨询