Skip to content

Commit db8a899

Browse files
committed
add offline synthetic StatusNotification and extra security checks
1 parent 511f3c8 commit db8a899

2 files changed

Lines changed: 90 additions & 11 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,26 @@ The communication flow is straightforward:
7373
5. Queues can be consumed by multiple identical, stateless workers written in any programming language. Monitor queues using built-in tools (e.g., Grafana) and configure auto-scaling based on message latency or queue depth.
7474
6. If a worker throws an exception before sending a valid OCPP response, standard AMQP ACK/NACK principles apply: unconfirmed messages return to the queue for processing by another worker. Handle failure scenarios (e.g., database outages) gracefully to avoid infinite retry loops.
7575

76+
## Offline Detection
77+
78+
Whenever an established charge point connection terminates — clean WebSocket close, TCP drop, crash or broker shutdown — the plugin publishes one final synthetic `StatusNotification` CALL on behalf of the charge point, so backend workers learn about the disconnect through the same channel as any other OCPP traffic. The payload marks the whole charge point (`connectorId` 0) unavailable, shaped for the protocol version the charge point was connected with:
79+
80+
OCPP 1.x:
81+
82+
```json
83+
[2,"40a2216a-4c22-37f8-28f2-92b7e6ba205e","StatusNotification",{"connectorId":0,"errorCode":"NoError","status":"Unavailable","timestamp":"2026-07-17T13:31:19Z","vendorErrorCode":"Offline","vendorId":"rabbitmq"}]
84+
```
85+
86+
OCPP 2.x:
87+
88+
```json
89+
[2,"83c2c788-712b-18a4-7456-29a4586ddb4a","StatusNotification",{"connectorId":0,"connectorStatus":"Unavailable","customData":{"vendorErrorCode":"Offline","vendorId":"rabbitmq"},"evseId":0,"timestamp":"2026-07-17T13:10:27Z"}]
90+
```
91+
92+
Workers can recognize the synthetic frame by `vendorErrorCode` or `vendorId` — e.g. to skip sending the CALLRESULT, which would otherwise sit in the disconnected charge point's queue until it reconnects and be discarded because of an unknown `messageId`.
93+
94+
Alternatively (or additionally — e.g. to also detect chargers coming *online* - if you don't do this by StatusNotification), enable the [`rabbitmq_event_exchange`](https://www.rabbitmq.com/docs/event-exchange) plugin and bind a queue to the internal `amq.rabbitmq.event` topic exchange for the `connection.created` and `connection.closed` routing keys. Connections handled by this plugin carry a `protocol` header of `{'WS OCPP', ...}` and a `client_id` header with the EVSE ID, so consumers can filter out non-OCPP connections (management UI, shovels, backend workers) and map events back to charge points.
95+
7696
## Documentation
7797

7898
For all configuration options, please refer to the nearly identical plugin, [RabbitMQ Web MQTT guide](https://www.rabbitmq.com/web-mqtt.html).

src/rabbit_web_ocpp_processor.erl

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
-define(CONSUMER_TAG_PREFIX, <<"ocpp.consumer.">>).
3636
-define(QUEUE_KIND, ocpp). % Used for queue naming convention
3737
-define(PREFETCH_COUNT, 1). % Default prefetch for the OCPP queue
38+
-define(MAX_ACTION_BYTES, 239). %% Room left for the Action string segment of the routing key
3839

3940
%% --- Types ---
4041

@@ -356,9 +357,12 @@ drain_frames(State = #state{pending_frames = Frames}) ->
356357

357358
%% Terminate the processor
358359
-spec terminate(any(), rabbit_event:event_props(), state()) -> ok.
359-
terminate(Reason, Infos, _State = #state{queue_states = QStates,
360+
terminate(Reason, Infos, State = #state{queue_states = QStates,
360361
cfg = #cfg{client_id = ClientId}}) ->
361362
?LOG_INFO("OCPP processor terminating. ClientId: ~ts, Reason: ~p", [ClientId, Reason]),
363+
%% Whatever the reason for the disconnect, tell the backends the charge
364+
%% point went offline with one final synthetic StatusNotification.
365+
publish_offline_status(State),
362366
ok = rabbit_queue_type:close(QStates), % Stop consuming
363367
rabbit_core_metrics:connection_closed(self()),
364368
rabbit_event:notify(connection_closed, Infos),
@@ -367,6 +371,50 @@ terminate(Reason, Infos, _State = #state{queue_states = QStates,
367371
%% It should persist messages while the CP is offline.
368372
ok.
369373

374+
%% Publishes a synthetic StatusNotification CALL marking the whole
375+
%% charge point (connectorId 0) Unavailable/Offline, using the
376+
%% OCPP version schema the CP was originally connected with.
377+
-spec publish_offline_status(state()) -> ok.
378+
publish_offline_status(State = #state{cfg = #cfg{client_id = ClientId,
379+
proto_ver = ProtoVer}}) ->
380+
MsgId = list_to_binary(rabbit_guid:to_string(rabbit_guid:gen())),
381+
Timestamp = list_to_binary(
382+
calendar:system_time_to_rfc3339(os:system_time(second),
383+
[{offset, "Z"}])),
384+
Payload = case proto_version_tuple(ProtoVer) of
385+
{1, _} -> % OCPP 1.x StatusNotification.req
386+
#{<<"connectorId">> => 0,
387+
<<"status">> => <<"Unavailable">>,
388+
<<"errorCode">> => <<"NoError">>,
389+
<<"timestamp">> => Timestamp,
390+
<<"vendorId">> => <<"rabbitmq">>,
391+
<<"vendorErrorCode">> => <<"Offline">>};
392+
_ -> % OCPP 2.x StatusNotificationRequest
393+
#{<<"timestamp">> => Timestamp,
394+
<<"connectorStatus">> => <<"Unavailable">>,
395+
<<"evseId">> => 0,
396+
<<"connectorId">> => 0,
397+
<<"customData">> => #{<<"vendorId">> => <<"rabbitmq">>,
398+
<<"vendorErrorCode">> => <<"Offline">>}}
399+
end,
400+
Frame = iolist_to_binary(json:encode([?OCPP_MESSAGE_TYPE_CALL, MsgId,
401+
<<"StatusNotification">>, Payload])),
402+
McOcpp = #ocpp_msg{msg_type = ?OCPP_MESSAGE_TYPE_CALL,
403+
msg_id = MsgId,
404+
action = <<"StatusNotification">>,
405+
payload = Frame,
406+
client_id = ClientId},
407+
try process_incoming(McOcpp, State) of
408+
{ok, _} ->
409+
ok;
410+
{error, Err, _} ->
411+
?LOG_WARNING("OCPP offline StatusNotification for ClientId ~ts failed: ~p",
412+
[ClientId, Err])
413+
catch Class:Err ->
414+
?LOG_WARNING("OCPP offline StatusNotification for ClientId ~ts failed: ~p:~p",
415+
[ClientId, Class, Err])
416+
end.
417+
370418
%% --- Internal Functions ---
371419

372420
%% @doc Generates a structured routing key in the format: protocolver.actionname.req/conf/error
@@ -377,13 +425,19 @@ generate_routing_key(#ocpp_msg{msg_type = MsgType, action = Action},
377425
%% Convert protocol version atom to binary string
378426
ProtoVerBin = atom_to_binary(ProtoVer, utf8),
379427

380-
%% Handle action name (may be undefined for responses)
428+
%% Handle action name (may be undefined for responses). The Action is client input
429+
%% with no restrictions in OCPP so we need to clean it up for routing key usage.
381430
ActionBin = case Action of
382431
undefined -> <<"response">>; % For CALLRESULT/CALLERROR without action
383-
ActionName when is_binary(ActionName) -> ActionName;
432+
ActionName when is_binary(ActionName) ->
433+
case re:replace(ActionName, "[^A-Za-z0-9]", "", [global, {return, binary}]) of
434+
<<>> -> <<"unknown">>;
435+
<<Trimmed:?MAX_ACTION_BYTES/binary, _/binary>> -> Trimmed;
436+
Clean -> Clean
437+
end;
384438
_ -> <<"unknown">>
385439
end,
386-
440+
387441
%% Determine message direction (req/conf/error)
388442
MsgTypeBin = case MsgType of
389443
?OCPP_MESSAGE_TYPE_CALL -> <<"req">>; % Request from charge point
@@ -393,8 +447,8 @@ generate_routing_key(#ocpp_msg{msg_type = MsgType, action = Action},
393447
?OCPP_MESSAGE_TYPE_CALLRESULTERROR -> <<"error">>; % Error in OCPP 2.1
394448
_ -> <<"unknown">>
395449
end,
396-
397-
%% Construct routing key: ocpp.protocolver.actionname.req|conf|error
450+
451+
%% Construct routing key: ocpp<protocolver>.<actionname>.req|conf|error
398452
<<ProtoVerBin/binary, ".", ActionBin/binary, ".", MsgTypeBin/binary>>.
399453

400454
%% @doc Extracts relevant metadata from a parsed OCPP message list.
@@ -536,7 +590,7 @@ bind_queue(State = #state{cfg = #cfg{queue_name = QName,
536590
RoutingKey = ClientId,
537591
Binding = #binding{source = ExchangeName, destination = QName,
538592
key = RoutingKey, args = BindingArgs},
539-
case check_binding_permitted(QName, ExchangeName, State) of
593+
case check_binding_permitted(QName, ExchangeName, RoutingKey, State) of
540594
ok ->
541595
case rabbit_binding:add(Binding, User#user.username) of
542596
ok ->
@@ -631,13 +685,18 @@ check_publish_permitted(Exchange, RoutingKey, State = #state{auth_state = AuthSt
631685
Err -> Err
632686
end.
633687

634-
%% Check permissions for binding queue to exchange
635-
check_binding_permitted(QName, ExchangeName, #state{auth_state = AuthState}) ->
688+
%% Check permissions for binding queue to exchange. Requires 'write' on the
689+
%% queue, 'read' on the exchange, and topic 'read' on the binding key
690+
check_binding_permitted(QName, ExchangeName, RoutingKey,
691+
State = #state{auth_state = AuthState}) ->
636692
User = AuthState#auth_state.user,
637693
Ctx = AuthState#auth_state.authz_ctx,
638-
%% Need 'write' on queue and 'read' on exchange for binding
639694
case check_resource_access(User, QName, write, Ctx) of
640-
ok -> check_resource_access(User, ExchangeName, read, Ctx);
695+
ok ->
696+
case check_resource_access(User, ExchangeName, read, Ctx) of
697+
ok -> check_topic_access(RoutingKey, read, State);
698+
Err -> Err
699+
end;
641700
Err -> Err
642701
end.
643702

0 commit comments

Comments
 (0)