ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

OCPP 1.6 JSON消息与WebSocket网关:充电桩事件解析实战指南

2026/10/8 14:38:37 拓冰建站 浏览量
OCPP 1.6 JSON消息与WebSocket网关:充电桩事件解析实战指南 简介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-JSOAP 通道基本只在老项目维护阶段还有存在感。选型上还有一个现实考量。OCPP-J 的 WebSocket 长连接天然适合推送型业务桩随时可以上报事件后台随时可以下发指令不需要反复握手SOAP 绑定则要后台开放公网端口或做主动轮询桩数量一上来连接管理成本会明显增加。所以新接入的项目包括资源里这套代码包都是按 OCPP-J 写的。记住一点OCPP 1.6 的 JSON 绑定只支持 WebSocket不支持裸 HTTP 轮询桩和后台之间必须维护一条长连接。1.6 的 JSON 消息固定在四层数组结构上这和 2.0.1 的嵌套 JSON 对象完全不同别拿新版本的记忆去套老协议数组位置含义说明[0]messageType2 表示 CALL3 表示 CALLRESULT4 表示 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.Registerunit 缺省是 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 frs-{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, timeout45) # 第二段等 StartTransaction 事件30 秒超时 tx_event await wait_for_action(cp_id, StartTransaction, timeout30) return tx_payload_to_summary(tx_event)这里把协议回执和业务事件拆成两次等待是刻意为之。CALLRESULT 到了只能说明桩收下了指令不能说明枪已经吸合只有 StartTransaction 推上来了才能确认充电会话真的建立。wait_for_action 的实现也不复杂就是在事件入口处按 action 建 asyncio.QueueStartTransaction 进来时往队列里塞一份等待方从队列取。要注意这个 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 或桩号加递增序号后台侧不要只依赖 UniqueIdMeterValues 和 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, encodingutf-8) as f: schema json.load(f) jsonschema.validate(payload, schema)这里有三个细节值得说。第一validate 抛异常时错误 message 会给出具体字段路径排错效率比看日志高很多第二Action 名和 Schema 文件名一一对应写路由时可以直接用 action 拼文件名不用维护映射表第三校验档位放在协议层回执之后、业务处理之前这样即使校验失败也不耽误给桩回 CALLRESULT桩该干嘛干嘛。最后一个建议新接入的桩型不要直接上生产先在本地起一个模拟 CSMS把资源里的示例事件一条条回放验证后台的解析、入库、回执都正常了再接真桩。我当时处理过一批固件版本很老的桩就是靠回放脚本找到它在 StopTransaction 里漏传 transactionId 的问题省掉了现场反复插拔枪的折腾。从那以后每次新接入一批桩我都强制走一遍Schema 校验加示例事件回放的组合拳再让桩上电联调。希望帮到你。本文还有配套的精品资源点击获取