9 분 소요

M8-4까지 OCPP 1.6J로 카드 인증, 충전 거래, 원격 시작·정지, 비상정지 보고를 확인했다. 오늘(M8-5)은 네트워크가 끊길 때 충전기가 어떻게 움직이는지 보는 날이었다. 현장에서 통신은 언제든 끊긴다. 그때 과금 기록이 사라지면 안 된다.

결과부터 정리하면 이렇다.

시험 결과
대기 중 끊김 ✅ 즉시 감지, 자동 재접속
충전 중 끊김 ✅ 재접속 11 ms 만에 StopTransaction, 종료 시각은 실제 분리 시각 그대로
끊긴 상태에서 차 연결 ✅ 충전 안 함 (설정대로)
서버 재시작 ✅ 서버가 뜨자 스스로 재접속
덤: 처음 접속 때 멈춤 ❌ → libocpp 패치로 해결

1. 준비: 보드는 WiFi로만 붙어 있었다

LAN 케이블을 뽑을 생각이었는데, 먼저 경로를 봤다.

$ ip -br addr; ip route
end0    DOWN                                 ← LAN 케이블 없음
eth0    UP     fe80::...                     ← QCA7005 PLC (차 통신용)
wlan0   UP     192.168.45.70/24              ← 서버 통신은 이것뿐
default via 192.168.45.1 dev wlan0

LAN과 WiFi가 둘 다 살아 있으면, LAN을 뽑아도 WiFi로 통신이 이어져 시험이 되지 않는다. 그래서 끊김은 보드의 WiFi 연결을 끊는 것으로 만들기로 했다.

wpa_cli -i wlan0 disconnect    # 끊기
wpa_cli -i wlan0 reconnect     # 다시 연결

명령은 꼭 시리얼 콘솔에서 쳐야 한다. SSH 창에서 치면 WiFi가 끊기는 순간 그 창도 끊긴다. 또 EVerest를 콘솔에서 그냥 실행하면 창을 차지하므로, 백그라운드로 돌리고 출력은 파일로 받았다.

./run-m7-bsp.sh config-sc6-ocpp.yaml > /tmp/everest.log 2>&1 &

2. 시험 전에 생긴 문제: 접속은 했는데 등록이 안 된다

첫 번째 끊김 시험은 순서가 꼬여 무효로 했다. 보드를 재부팅하고 EVerest를 다시 띄웠는데, 서버 기록이 이상했다.

09:44:49.390  [SC6-0001] Connection is established
09:44:49.392  서버→보드  GetConfiguration   {"key":[]}
(그 뒤로 아무것도 없음)

웹소켓 연결은 됐다. 그런데 충전기가 연결하면 가장 먼저 보내야 하는 BootNotification이 나가지 않았다. 서버 입장에서는 접속만 해 놓고 자기소개를 하지 않는 충전기다. 등록이 안 되니 아무 거래도 할 수 없다.

실행 서버 요청 도착 (연결 후) BootNotification
09:19 (그날 첫 실행) 51 ms (보드 Boot 뒤) ✅
09:34 (재시작) 2 ms ❌
09:44 (재부팅) 2 ms ❌

정상이던 실행과 멈춘 실행은 순서가 달랐다. 서버 요청이 보드의 BootNotification보다 먼저 왔을 때만 멈췄다.

2-1. 보드 로그

09:34:20.0017 [INFO] ocpp:OCPP :: OCPP client successfully connected to server with version: ocpp1.6
09:34:20.0042 [ERRO] ocpp:OCPP void ocpp::v16::ChargePointImpl::message_callback(const std::string&)
                     :: Received a message in disconnected state, this cannot be correct

연결되고 2.5 ms 뒤에 서버 메시지가 도착했다. 그런데 라이브러리는 아직 자기가 “연결 안 됨” 상태라고 생각하고 있었다. 작성자도 “이럴 리 없다(this cannot be correct)”라는 로그만 남겨 둔 상황이었다.

2-2. 소스에서 원인 찾기

libocpp(EVerest의 OCPP 라이브러리)를 따라가 보니 세 가지가 맞물려 있었다.

① 상태가 늦게 바뀐다. 연결되면 on_websocket_connected()가 불린다. 그런데 상태를 “연결됨”으로 바꾸는 connected_callback()은 다른 처리를 먼저 하고 나서야 실행된다. 이 보드에서 그 틈이 약 50 ms였다.

void ChargePointImpl::on_websocket_connected(...) {
    this->connection_state_changed_callback(true, ...);   // 모듈 쪽 처리 (여기서 시간이 걸림)
    this->publish_default_price(false);
    ...
    this->connected_callback();                           // 여기서야 Disconnected -> Connected
    this->message_queue->resume(...);

② 메시지 큐는 서버 요청을 받으면 다른 메시지를 붙잡는다. OCPP-J에서 서버 요청(CALL)에는 답해야 한다. 그래서 큐는 받은 요청의 ID를 기억해 두고, 그 답이 나가기 전에는 다른 메시지를 보내지 않는다.

// message_queue.hpp, receive()
if (enhanced_message.messageTypeId == MessageTypeId::CALL) {
    // save the uid of the message we just received to ensure the next message we send is a response to
    // this message
    next_message_to_send.emplace(enhanced_message.uniqueId);
}

③ “연결 안 됨” 상태에서는 그 표시를 풀지 않는다. 상태가 “연결됨”이면, 부팅 전에 온 요청은 답하지 않고 표시만 풀어 준다. 그런데 “연결 안 됨”에서는 로그만 찍고 끝난다.

switch (this->connection_state.load()) {
case ChargePointConnectionState::Disconnected: {
    EVLOG_error << "Received a message in disconnected state, this cannot be correct";
    break;                                               // 표시가 그대로 남는다
}
case ChargePointConnectionState::Connected: {
    if (enhanced_message.messageType == MessageType::BootNotificationResponse) {
        this->handleBootNotificationResponse(json_message);
    } else if (enhanced_message.messageTypeId == MessageTypeId::CALL) {
        // we dont want to reply to this message
        this->message_queue->reset_next_message_to_send();   // 표시를 풀어 준다
    }
    break;
}

결국 이렇게 된다. 요청이 그 50 ms 틈에 들어오면 큐는 “이 요청의 답을 먼저 보내라”는 표시를 단다. 그 답은 영영 나가지 않는다. BootNotification은 큐에서 영원히 기다린다. 연결은 살아 있으니 재접속도 하지 않는다.

2-3. 그런데 서버는 왜 그렇게 빨리 요청을 보냈나

BootNotification도 받기 전에 서버가 먼저 GetConfiguration을 보낸 것부터가 이상했다. SteVe 소스에서 그 부분을 찾았다.

// ChargePointConfigUpdater.java
@EventListener
public void getAllOcppConfigs(OcppStationWebSocketConnected notification) {   // 웹소켓 연결 이벤트
    if (steveProperties.getOcpp().isOcttQuirksEnabled()) {
        // disable sending this after a successful WS connection, even though it is not illegal
        // according to OCPP. it throws OCTT off, because it does not expect it.
        return;
    }
    ...
    chargePointServiceClient.getConfiguration(params);
}

SteVe는 화면에 충전기 설정을 보여 주려고, 연결되는 순간 설정을 읽어 간다. 주석에는 규격상 불법은 아니지만, 공식 인증 시험 도구(OCTT)는 이런 순서를 예상하지 않으니 호환 설정을 켜면 보내지 않는다고 적혀 있다.

OCPP 1.6이 강하게 제약하는 것은 충전기 쪽이다. 충전기는 BootNotification을 먼저 보내야 하고, 승인 전에는 다른 요청을 보내면 안 된다. 서버는 응답이 Pending일 때 설정을 읽고 바꾸는 것이 허용된다. 하지만 BootNotification을 받기도 전에 보내는 것은 회색 지대다. 그날 첫 실행이 정상이었던 것은 빌드 PC와 SteVe가 막 켜져 서버 반응이 느렸기 때문으로 보인다. 서버가 한 번 데워지자 매번 보드보다 빨랐다.

그래도 고쳐야 하는 쪽은 충전기다.

  • 실제 운영 서버도 이렇게 할 수 있다.
  • 재접속 순간에 서버가 쌓아 둔 명령을 보내도 같은 순서가 될 수 있다.
  • 서버가 조금 이상하게 굴어도 충전기가 영구히 멈추면 안 된다.

2-4. 최신 버전에서는 고쳐졌을까

수정 전에 먼저 확인했다. GitHub 최신 main(852ff18, 11/05)에도 같은 코드가 그대로 있었다. 대신 가까운 수정이 하나 있었다.

  PR #3188 (10/20, 우리 버전에도 포함) 오늘 겪은 것
틈 연결 직후, 상태가 바뀌기 전 같음
그 틈에 온 메시지 서버의 BootNotification 응답 서버의 요청
결과 응답이 버려져 부팅 안 됨 큐가 막혀 BootNotification 안 나감

같은 틈에서 생기는 문제 중 응답 쪽만 막혀 있었다. 요청 쪽은 남아 있었다.


3. 패치

Connected일 때 이미 하는 처리를 Disconnected에도 똑같이 적용한다. 새 동작을 만들지 않는 4줄이다.

         case ChargePointConnectionState::Disconnected: {
-            EVLOG_error << "Received a message in disconnected state, this cannot be correct";
+            // SC6 patch: ... Handle it like in the Connected state: do not reply,
+            // but release the hold so the BootNotification.req can be sent.
+            EVLOG_warning << "Received a message before the connection state was updated, ignoring it";
+            if (enhanced_message.messageTypeId == MessageTypeId::CALL) {
+                this->message_queue->reset_next_message_to_send();
+            }
             break;
         }

상태 변경을 더 앞당기는 방법도 생각했다. 하지만 연결(.0017)과 요청 도착(.0042) 사이가 2.5 ms뿐이었다. 순서를 바꿔서는 틈이 줄어들 뿐 없어지지 않는다. 메시지를 받는 쪽에서 처리하는 것이 확실하다.

큐가 붙잡아 둔 메시지를 다시 보내라는 신호가 따로 필요한지도 확인했다. 큐 작업 스레드는 메시지를 붙잡고 있는 동안 계속 다시 확인한다. 그래서 표시가 풀리는 즉시 BootNotification이 나간다. 이 말은 멈춰 있던 동안 이 스레드가 쉬지 않고 돌고 있었다는 뜻이기도 하다.

3-1. 레이어에 넣기

빌드 PC는 EVerest 소스 폴더(~/stm32mp/EVerest)를 file://로 통째로 가져다 빌드한다. 그래서 meta-sc6에 패치 파일과 bbappend만 넣으면 된다.

meta-sc6/recipes-evse/everest-core/
├── everest-core_%.bbappend
└── files/0001-libocpp-v16-release-queue-hold-on-CALL-before-connected-state.patch
FILESEXTRAPATHS:prepend := "${THISDIR}/files:"
SRC_URI += "file://0001-libocpp-v16-release-queue-hold-on-CALL-before-connected-state.patch"

패치 머리말에는 원인과 재현 조건을 적었다. bbappend에는 “EVerest를 올릴 때 원본에 반영됐는지 보고 지울 것”이라고 적어 두었다. 빌드는 do_patch만 먼저 돌려 패치가 소스에 들어간 것을 확인한 뒤 전체를 돌렸다. 빌드 PC가 작업 폴더를 지우는 설정(rm_work)이라 everest-core를 처음부터 다시 컴파일했고, 15분 걸렸다.

EVerest는 Apache 2.0이라 수정해서 제품에 넣어도 된다. 수정한 파일에 바꿨다는 표시를 남기는 것이 조건이라, 패치 파일 머리말과 코드 주석으로 남겼다.

3-2. 보드에는 파일 하나만

libocpp는 별도 라이브러리 파일이 아니었다. OCPP 모듈 실행 파일 안에 정적 링크되어 있었다. 패키지에서 패치 문구가 들어간 파일을 찾으니 두 개였고, 우리가 쓰는 것은 하나다.

파일 사용
/usr/libexec/everest/modules/OCPP/OCPP 사용 (OCPP 1.6 모듈) → 교체
/usr/libexec/everest/modules/OCPPmulti/OCPPmulti 사용 안 함

첫 교체는 실패했다. md5가 그대로였다. 실행 스크립트만 끄고(pkill -f run-m7-bsp) manager와 모듈은 살아 있었기 때문이다. 실행 중인 파일은 덮어쓸 수 없다. 끄는 명령을 바꿨다.

pkill -f run-m7-bsp; pkill -x manager

3-3. 확인

실행 서버 요청 도착 BootNotification 결과
패치 전 09:34 2 ms 없음 ❌
패치 전 09:44 2 ms 없음 ❌
패치 후 10:31 2 ms 54 ms ✅ Accepted
패치 후 10:33 1 ms 48 ms ✅ Accepted
패치 후 10:34 1 ms 62 ms ✅ Accepted

버그 조건은 매번 그대로 생겼고, 패치 후에는 매번 통과했다. 보드 로그에는 새 경고가 찍혔다.

[WARN] ocpp:OCPP ... Received a message before the connection state was updated, ignoring it

패치 후 첫 실행에서는 부팅 알림(SecurityEventNotification)이 세 개 나갔다. 멈춰 있던 09:34와 09:44 실행의 알림이 보드 DB에 저장돼 있다가 그제야 전달된 것이다. 보내지 못한 메시지를 잃지 않는다는 것이 끊김 시험 전에 미리 확인됐다.


4. 끊김 시험

4-1. 대기 중 끊김

보드 로그로 본 재접속 시도는 이랬다. 서버 시각은 보드 시각에서 5초를 뺀 값이다.

10:37:23 Client closed, was not requested internally, attempting reconnection   ← 즉시 감지
10:37:23 Connection not successful, attempting internal reconnect in: 9000ms
10:37:32 ... in: 21000ms
10:37:53 ... in: 48000ms
10:38:41 ... in: 97000ms
10:40:18 OCPP client successfully connected to server with version: ocpp1.6

재접속하자 BootNotification은 다시 보내지 않고 StatusNotification만 보냈다. 규격대로다.

재접속 간격이 실패할 때마다 늘어나는 것은 의도된 동작이다. 서버가 죽었을 때 모든 충전기가 한꺼번에 몰리지 않게 하는 방식이다.

// websocket_base.cpp, get_reconnect_backoff_ms()
if (attempt_number > retry_backoff_repeat_times + 1) return previous_backoff_ms;   // 이후 고정
if (attempt_number <= 1) return (wait_minimum_s + random_s) * 1000;               // 첫 대기
return previous_backoff_ms * 2 + random_s * 1000;                                 // 두 배 + 무작위

기본값은 최소 3초, 무작위 0~10초, 두 배로 늘리기 3번이다. 이번에는 9 → 21 → 48 → 97초였고, 이후로는 97초 간격으로 고정된다. 무작위 값이 크게 나오면 최대 약 3분까지 늘어난다. 네트워크가 돌아와도 충전기가 서버에 늦게 보일 수 있다는 뜻이다. 얼마로 줄일지는 운영 정책이라 보류했다.

서버 쪽에서는 끊김이 보이지 않았다. WiFi만 끊기면 보드가 연결 종료 신호를 보낼 수 없다. 그래서 SteVe는 그 연결을 한동안 살아 있는 것으로 보고 있다가 늦게 정리했다. 충전기 문제는 아니지만 운영 서버를 고를 때 볼 점이다.

4-2. 충전 중 끊김 (가장 중요)

충전을 시작하고 WiFi를 끊은 뒤, 끊긴 상태에서 지그를 A로 돌려 충전을 끝냈다. 그다음 다시 연결했다.

10:45:49.126  보드→서버  StartTransaction   {... "transactionId":6}
10:45:49.741  보드→서버  StatusNotification {"status":"Charging"}
(끊김, 그 사이 C -> B -> A)
10:46:19.038  [SC6-0001] Connection is established
10:46:19.049  보드→서버  StopTransaction    {"meterStop":0,"reason":"EVDisconnected",
                                            "timestamp":"2026-11-06T01:46:11.617Z","transactionId":6}
10:46:19.150  보드→서버  StatusNotification {"connectorId":0,"status":"Available"}
  • 재접속 11 ms 만에 StopTransaction이 가장 먼저 나갔다.
  • 안의 시각(01:46:11.617, 보드 시계)에서 보드 시계 오차 5초를 빼면 서버 시각 10:46:06쯤이다. 재접속보다 13초 앞선 실제 분리 시각이다.
  • 끊긴 동안의 중간 상태(SuspendedEV, Finishing)는 보내지 않고 최종 상태만 보냈다. 거래 기록과 달리 상태 보고는 쌓아 두지 않는 기본 동작이다.

과금 기록이 누락되지 않고, 시각도 맞게 남는다. 다만 보드 시계 오차 5초도 그대로 들어간다. NTP를 맞춰야 할 이유가 하나 더 생겼다.

4-3. 끊긴 상태에서 차 연결

LocalAuthorizeOffline=false라서 서버에 카드를 확인할 수 없으면 충전하지 않아야 한다. 결과도 그랬다. K1이 붙지 않았고, 재접속 후에도 Authorize나 거래가 뒤늦게 나가지 않았다.

이번 재접속 때도 SteVe가 GetConfiguration을 먼저 보냈다. 보드는 이미 등록된 상태라 무시하지 않고 32 ms 만에 설정 목록 전체로 답했다. 처음 접속 때만 무시하고, 등록 뒤에는 정상 처리하는 것까지 확인됐다.

끊긴 동안 아무도 충전할 수 없는 것이 맞는지는 운영 정책이다. 전에 승인된 카드는 허용하는 방식도 있다. 확인 목록에 올렸다.

4-4. 서버 재시작

이번에는 빌드 PC에서 SteVe 서버만 껐다 켰다. DB 컨테이너는 그대로 두었다.

시각 일어난 일
10:54:04 SteVe 정지
10:55:16 시작
10:55:42 서버 준비
10:57:18 보드 재접속, 상태 보고, GetConfiguration 응답

서버가 준비되고 96초 뒤에 붙었다. 막 뜬 직후의 시도를 놓치고 다음 차례(97초)까지 기다린 것이다. 4-1의 대기 규칙 그대로다.


5. 정리

커밋

meta-sc6  9ba3bb1 M8-5: everest-core 에 libocpp OCPP 1.6 패치 (연결 직후 CSMS 요청 때문에 BootNotification 이 막히던 문제)
sc6-work  3c9e410 M8-5: 네트워크 끊김 시험, libocpp 패치 작성·빌드·배포 스크립트

오늘 배운 것

  1. “가끔 된다”는 순서 문제일 수 있다. 정상과 실패의 차이는 서버 요청이 보드 메시지보다 먼저 왔느냐 뿐이었다. 시각을 ms 단위로 나란히 놓고 보니 보였다.
  2. “검증된 라이브러리”도 소스를 읽자. 작성자가 “이럴 리 없다”고 적어 둔 곳에서 실제로 일이 났다. 최신 버전과 이전 수정(PR #3188)을 먼저 보고 나서 고쳤다.
  3. 시험 서버가 까다로우면 오히려 좋다. SteVe가 일찍 요청을 보내는 덕분에 이 버그가 드러났다. 인증 시험 도구는 이런 순서를 만들지 않는다.
  4. 실행 중인 파일은 덮어쓸 수 없다. 끄는 명령이 정말 모든 프로세스를 끄는지 ps로 보고, 바꾼 뒤에는 md5로 확인하자.
  5. 끊김 시험에서 볼 것은 과금 기록이다. 재접속 11 ms 만에, 실제 시각 그대로 StopTransaction이 갔다. 이것이 오늘 가장 중요한 결과였다.

남은 것

항목 내용
오프라인 충전 허용 운영 정책 (설정 두 개)
재접속 대기 시간 최대 약 3분 → 줄일지 (설정 세 줄)
보드 NTP 시계가 서버보다 5초 빠르고, 이 오차가 거래 시각에 들어감
이미지 반영 보드에는 패치한 OCPP 모듈을 직접 복사해 둠 (원본 백업 /home/root/m7/OCPP.orig). M7-6 때 이미지에

댓글남기기