8 분 소요

지금까지는 SSH로 들어가 run-m7-bsp.sh를 실행해야 충전기가 동작했다. 제품은 그럴 수 없다. 전원만 넣으면 스스로 떠서 서버에 붙어야 한다. 그리고 사람이 없으니, 프로그램 하나가 죽거나 다시 시작해도 스스로 안전하게 수습해야 한다. 오늘(M7-6)은 이 두 가지를 했다.

일 결과
systemd 서비스로 자동 시작 ✅ 전원을 넣고 약 36초 뒤 서버에 Available
하나씩 죽여 보기 7가지 ✅ 모두 통과
그 과정에서 찾은 문제 안전 2개, 상태 불일치 3개 → sc6-bsp r0.6 → r0.11

1. 구조

1-1. 서비스 셋, 서로 묶지 않는다

mosquitto.service
   ├── sc6-bsp.service     (새로) CP·K1·비상정지. 죽으면 1초 뒤 다시, 끝날 때마다 K1 OFF + CP F
   ├── sc6-meter.service   (새로) 전력량계
   └── everest.service     (EVerest 패키지 것 + 덧붙이는 설정) sc6-bsp 다음에 시작

systemd에는 “이 서비스가 죽으면 저것도 같이 죽여라”(BindsTo=) 같은 묶음이 있다. 일부러 쓰지 않았다. EVerest가 다시 떠도 sc6-bsp는 그대로 둔다. 어제 넣은 heartbeat 감시 덕분에 그래도 안전하다. EVerest가 멈추면 sc6-bsp가 3초 안에 K1을 끈다. sc6-bsp는 EVerest 없이 혼자 떠 있어도 안전 상태다. 그래서 EVerest보다 먼저 띄운다.

1-2. 패키지 서비스에 덧붙이기

EVerest 패키지에는 everest.service가 이미 들어 있다. /etc/everest/config.yaml이 있어야만 시작하는 조건이 붙어 있다. 그 파일과, 서비스에 덧붙이는 설정 한 장을 새 레시피(sc6-everest-config)로 넣었다.

# /usr/lib/systemd/system/everest.service.d/10-sc6.conf
[Unit]
Wants=sc6-bsp.service sc6-meter.service network-online.target
After=sc6-bsp.service sc6-meter.service network-online.target

[Service]
ExecStartPre=/bin/rm -f /var/lib/everest/everest.db   # manager 는 이 DB 가 있으면 YAML 을 무시한다
ExecStartPre=/bin/mkdir -p /tmp/everest-logs
UMask=0077

첫 줄의 DB 삭제는 M8-2에서 겪은 일 때문이다. manager는 설정 DB가 이미 있으면 YAML 대신 DB 안의 옛 설정으로 뜬다. 패키지 서비스는 이 DB를 지우지 않는다. 그대로 두면 YAML을 고쳐도 반영되지 않는다. 거래 DB는 다른 파일이라 지워지지 않는다.

1-3. UMask=0077 — 600으로 맞춰도 644로 돌아온다

아침 환경 확인에서 서버 비밀번호가 든 ocpp-user-config.json이 644로 바뀌어 있었다. 어제 복원할 때는 분명히 600으로 맞췄다. libocpp 소스에 이유가 있었다.

// charge_point_configuration.cpp, setInUserConfig()
std::ofstream ofs(tmp_file);              // 새 파일 -> 기본 권한 644
ofs << user_config << std::endl;
fs::rename(tmp_file, user_config_path);   // 원래 파일(600)을 바꿔치기

파일이 깨지지 않게 임시 파일에 쓰고 바꿔치는 좋은 방식이다. 그런데 그 결과 권한이 umask를 따라간다. 파일 권한을 손으로 맞추는 대신 프로세스가 만드는 파일이 처음부터 600이 되게 UMask=0077을 넣었다. 비밀번호 파일은 /var/lib/everest/(700)로 옮겼고, 이미지에는 넣지 않는다.

1-4. 네트워크는 최대 30초만 기다린다

EVerest를 네트워크보다 먼저 띄우면 첫 BootNotification이 시간 동기화(NTP) 전에 나갈 수 있다. 그렇다고 네트워크를 끝까지 기다리면, WiFi가 없을 때 충전기가 아예 뜨지 않는다. 그래서 최대 30초로 정했다.

보드에서 확인해 보니 함정이 하나 있었다. 랜선이 없는 end0도 “온라인 판단 대상”(Required For Online: yes, ST 기본 설정)이었다. 기본 옵션 그대로면 WiFi가 붙어도 end0까지 기다리다 매번 시간을 다 쓴다.

# systemd-networkd-wait-online.service.d/10-sc6.conf
[Service]
ExecStart=
ExecStart=/usr/lib/systemd/systemd-networkd-wait-online --any --timeout=30

--any는 둘 중 하나만 붙으면 통과라는 뜻이다. 시간이 다 되면 이 서비스는 failed로 남는다. 하지만 EVerest는 약한 의존(Wants=)이라 그대로 시작한다.


2. 먼저 확인: 프로그램이 죽으면 릴레이는?

서비스를 만들기 전에 하나를 보드에서 먼저 확인했다. 손으로 띄운 sc6-bsp를 충전 중에 kill -9로 죽였다.

K1 상태는 gpioget으로 읽으면 안 된다. 핀을 입력으로 바꿔 버려서 상태 자체가 바뀐다. 커널 디버그 정보에서 읽기만 했다.

grep '^pin 24 ' /sys/kernel/debug/pinctrl/*50002000*/pinconf-pins    # PB8 = K1

결과는 걱정한 대로였다. 릴레이 LED가 켜진 채 남았다.

출력 제어 프로그램이 죽으면
K1 GPIO (libgpiod 요청) 커널이 사용권을 풀어도 핀은 마지막 값(High)
CP 하드웨어 PWM 마지막 듀티 그대로 계속 나감

EVerest는 5초 뒤 통신 끊김을 알아채고 서버에 Faulted를 보냈다. 하지만 K1을 끌 수단이 없다. 하드웨어를 만지는 프로그램은 sc6-bsp 하나뿐이다. 서버에는 “고장”으로 보이는데 릴레이는 붙어 있는 상태다. AC를 건 상태였다면 차로 전기가 계속 갔을 것이다.

systemd의 ExecStopPost=는 서비스가 어떻게 끝났든(정상 종료든 kill -9든 오류든) 실행된다. 여기서 sc6-bsp를 한 번 더 부르게 했다. 처음에는 셸 스크립트로 핀을 끄려고 했다. 그러면 핀 번호가 설정 파일과 스크립트 두 군데로 갈라진다. 그래서 sc6-bsp에 -s 옵션을 넣었다. 같은 설정 파일을 읽고, 출력만 안전 상태로 만든 뒤 끝낸다.

ExecStart=/usr/bin/sc6-bsp
ExecStopPost=/usr/bin/sc6-bsp -s
Restart=always

3. 하나씩 죽여 보기

모든 시험은 AC 없이 CP 저항 지그로 했다. 항목마다 한 번 실행하면 끝나는 스크립트(test-autostart.sh)로 1초마다 K1과 CP를 기록했다.

# 시험 결과
1 재부팅만 ✅ sc6-bsp 26.0초 → NTP 26.4초 → 네트워크 대기 27.4초 → EVerest 27.6초 → 서버 Available 35.9초
2 충전 중 EVerest 재시작 ✅ 3.0초 뒤 K1 OFF. 거래는 PowerLoss로 종료
3 충전 중 kill -9 sc6-bsp ✅ 31~56 ms 뒤 K1 OFF, 1.4초 뒤 sc6-bsp 다시 뜸
4 충전 중 계량기 15초 정지 ✅ 4.6초 뒤 일시정지, 다시 붙으면 같은 거래로 충전
5 충전 중 mosquitto 재시작 ✅ 셋 다 순서대로 함께 다시 시작, 그동안 K1 OFF
6 네트워크 없이 부팅 ✅ 30초 대기 뒤 EVerest 시작, WiFi가 돌아오자 약 40초 뒤 접속
7 네트워크 있는 부팅 ✅ 대기 1.4초

표만 보면 깔끔하다. 하지만 2번과 3번을 통과하기까지 sc6-bsp를 다섯 번 고쳤다.


4. 고친 다섯 가지

4-1. EVerest 재시작 직후 승인 없이 K1이 1초 붙었다 (r0.7)

시험 2의 첫 결과다.

29.680  heartbeat 3003 ms 없음 -> 안전 상태: K1 OFF, CP F       ← 설계대로
32.435  heartbeat 회복 -> 끊기기 전 출력 복원 (PWM 53%, allow=true)
32.495  K1 ON (CP C/D 안정)                                   ← 새 EVerest 는 아무것도 허락하지 않았다
33.506  K1 OFF (새 EVerest 가 allow=false)

어제 만든 복원 기능은 EVerest가 잠깐 멈췄다 풀리는 경우에 맞춘 것이었다. EVerest는 PWM과 전원 허락을 바뀔 때만 보내기 때문에, 회복할 때 옛 명령을 되살려야 했다. 그런데 서비스 재시작은 새 EVerest다. 새 EVerest는 옛 명령을 모른다. 손으로 시험할 때는 EVerest를 늘 처음부터 띄웠으니 드러나지 않았다.

둘을 구분할 방법은 EVerest가 보내는 heartbeat 안에 있었다.

// ApiHelper.hpp
mqtt.publish(topic, serialize(hb_id++));

1씩 늘어나는 순번이다. 멈췄다 풀리면 이어지고(120 → 124), 새로 시작하면 0부터 다시 센다(120 → 0). 순번이 줄면 옛 명령을 버린다.

10:43:26.604 sc6-bsp: EVerest 새로 시작 (heartbeat 순번 13 -> 0) - 끊기기 전 출력은 복원하지 않음

4-2. sc6-bsp가 다시 뜨면 서버는 Charging, 실제는 CP F (r0.8 → r0.9)

kill -9 뒤 sc6-bsp는 1.4초 만에 다시 떴다. 그래서 EVerest의 5초 통신 감시가 걸리지 않았다. EVerest는 BSP가 재시작한 줄 모른다. 이미 보낸 명령을 다시 보내지 않는다. 새 sc6-bsp는 안전 상태로 시작해서 그대로 머문다. 안전하지만, 서버에는 거짓 Charging이 보인다.

첫 시도(r0.8)는 sc6-bsp가 스스로 통신 오류를 2초 올렸다 내리는 것이었다. EVerest가 고장 처리 중에 명령을 새로 보내 주기를 기대했다. 결과는 더 나빴다.

EVSE IEC Stop in Charging: [fatal error]
EVSE IEC Charger state: Charging->StoppingCharging
... (오류를 내린 뒤에도)
StoppingCharging: EV did not stop within timeout     ← 0.1초마다, 몇 분 동안

EvseManager가 “충전 멈추는 중”에 들어가서 나오지 않았다. 0.1초마다 allow_power_on=false를 다시 보냈다. 지그를 B로, A로 움직여도, 즉 차를 뽑아도 거래가 끝나지 않았다.

두 번째 시도(r0.9)는 오류 대신 “차가 분리됐다(CP A)”를 한 번 보고하는 것이었다. sc6-bsp는 정보를 잃었으니 세션을 처음부터 다시 하자는 뜻이다. 그런데 이것도 같은 상태로 들어가 맴돌았다.

4-3. 진짜 원인은 “릴레이가 떨어졌다”는 확인 (r0.10)

실마리는 다음 시험에서 나왔다. 계량기를 15초 멈추자 EVerest는 똑같이 “충전 멈추는 중”에 들어갔다. 그런데 17 ms 만에 빠져나왔다.

11:17:00.250  Stop in Charging: [fatal error]
              Charging -> StoppingCharging
11:17:00.267  StoppingCharging -> EVSE Paused        ← 17 ms

차이는 그 순간 K1을 끈 sc6-bsp가 살아 있었다는 것이다. K1에는 보조 접점(피드백)이 없다. 그래서 sc6-bsp는 K1을 끄라는 명령을 받으면 바로 “PowerOff” 확인 이벤트를 보낸다. 그런데 이 확인을 K1이 켜져 있다가 꺼질 때만 보내고 있었다.

/* relay_apply(): 상태가 바뀔 때만 */
if (want == relay_on)
	return;
...
pub_event(want ? "PowerOn" : "PowerOff");

다시 시작한 sc6-bsp는 K1이 꺼진 채로 시작한다. EVerest가 “꺼라”고 해도 이미 꺼져 있으니 아무것도 보내지 않는다. EvseManager는 “릴레이가 떨어졌다”는 확인을 기다리며 0.1초마다 다시 묻고 있었던 것이다. 화면을 가득 채운 allow_power_on = false가 바로 그 질문이었다.

if (!allow_power_on && !relay_on)
	pub_event("PowerOff");     /* 이미 꺼져 있어도 확인은 보낸다 */

r0.8에서 오류를 올렸을 때 맴돈 것도 같은 원인이었다. 결과는 이렇다.

11:19:42.476  Charging -> StoppingCharging
11:19:42.489  StoppingCharging -> ...                ← 13 ms
서버: StopTransaction (EVDisconnected) -> 새 세션 -> Authorize -> StartTransaction

4-4. 새 거래가 승인돼도 K1이 안 붙었다 (r0.11)

거래는 새로 시작됐는데 K1이 붙지 않았다. PWM은 53%로 나가고 있었다. sc6-bsp가 K1을 켜는 조건에는 enabled(커넥터 사용 가능)가 있다. 이 값은 false로 시작하고 EVerest의 enable 명령으로 켜진다. 그런데 EVerest는 enable을 자기가 시작할 때만 보낸다. sc6-bsp만 다시 시작하면 영영 오지 않는다.

EVerest가 이미 돌고 있었다면(첫 heartbeat 순번 3 이상) 사용 가능으로 보게 했다. 그래도 K1은 여전히 EVerest의 전원 허락, CP C, 비상정지·누전 없음이 모두 맞아야 켜진다.

11:26:40  거래 33 종료 (EVDisconnected)
11:26:49  새 세션 -> Authorize -> 거래 34
11:26:51  K1 ON (allow_power_on)

4-5. 지그는 규격을 지키지 않는 차다

시험 3의 새 세션은 지그를 B → C로 한 번 움직여야 시작됐다. 고정 저항 지그는 PWM이 꺼져도 C에 머문다. 규격을 지키는 차라면 PWM이 꺼지면 충전을 멈추고 B로 간다. EvseManager는 그 B → C 전환을 보고 전원을 허락한다. 지그로 시험할 때는 “차라면 이렇게 반응한다”를 손으로 해 줘야 하는 경우가 있다는 것을 기억해 두자.


5. 새 SD 복원도 바꿨다

어제는 새 카드로 부팅한 뒤 시리얼 콘솔에서 WiFi 비밀번호를 손으로 넣었다. 그러면 자동 시작을 첫 부팅부터 시험할 수 없다. 그래서 빌드 PC에서 카드에 바로 넣는 스크립트(sc6-restore-sd.sh)를 만들었다.

bash ~/sc6-tfa-sd.sh raw sdc          # 이미지 쓰기
sudo bash ~/sc6-restore-sd.sh sdc     # rootfs 에 백업 풀기 (WiFi, CA, 서버 비밀번호 -> /var/lib/everest)

USB 장치이고, rootfs 라벨 파티션이 있고, M7-6 이미지일 때만 쓴다. 비밀 파일 내용은 화면에 출력하지 않는다. 옛 거래 DB는 옮기지 않는다. 어제처럼 이미 닫힌 거래를 서버에 다시 보내지 않게 하려는 것이다.

이 덕에 오늘 아침 새 카드는 꽂자마자 WiFi와 서버에 붙었다. 다만 반대 방향의 부작용이 하나 있었다. kill -9 시험 때 열린 거래가 서버에만 남았다. 같은 시험 카드로 새 거래를 시작하자 서버가 “이미 충전 중”(ConcurrentTx)이라며 거절했다. 보드는 그 거래를 모르니 닫을 수도 없다. SteVe 화면에서 손으로 닫았다.


6. 정리

커밋

meta-sc6  84aaec8 M7-6: 부팅 자동 시작 (sc6-bsp/sc6-meter/everest 서비스), sc6-bsp r0.11
sc6-work  cb4895f M7-6: 자동 시작 시험 스크립트, SD 복원 스크립트, 설계 문서 결과

오늘 배운 것

  1. 프로그램이 죽어도 하드웨어는 그대로다. GPIO도 하드웨어 PWM도 마지막 값을 유지한다. 출력을 끄는 일은 “정상 종료 처리”가 아니라 프로세스 바깥(ExecStopPost)에 둬야 한다.
  2. “멈췄다 풀림”과 “새로 시작”은 다르다. 복원은 앞의 경우에만 맞다. 상대가 보내는 순번 하나로 구분할 수 있었다.
  3. 확인 이벤트는 상태가 바뀌지 않아도 보내자. 상대는 “바뀌었다”가 아니라 “지금 꺼져 있다”는 답을 기다릴 수 있다. 두 번의 엉뚱한 시도(r0.8, r0.9) 끝에, 다른 시험(계량기)에서 정상으로 빠져나오는 경우와 나란히 놓고 보니 보였다.
  4. 고치다 막히면 멈추고 다시 보자. 오늘 중간에 “지금 근본적으로 하는 이유가 뭐지?”라는 질문을 받았다. 안전 문제(1, 2)는 이미 해결됐고 남은 것은 상태 맞추기였다. 그 질문 덕에 한 번만 더 해 보기로 범위를 정했고, 바로 다음 시험에서 실마리가 나왔다.
  5. “하나씩 죽여 보기”는 꼭 하자. 손으로 실행할 때는 한 번도 드러나지 않은 문제 다섯 개가 나왔다.

남은 것

항목 내용
제품값 EM_ACTIVE 등은 설계자 확인(비상정지 해제 방식, 버튼 극성) 뒤
sc6-bsp 재시작 뒤 진행 중 거래는 끝나고 다시 인증해야 함. 이어서 충전하지 않는 것은 의도 (죽어 있던 동안의 명령을 놓쳤을 수 있음)
재시작 직후 카드 승인 EVerest가 서버보다 먼저 카드를 확인해 오프라인으로 거절됨. 오프라인 충전 정책이 정해지면 다시 볼 것
모니터 DWIN 화면·음성을 충전 상태에 맞춰 바꾸는 sc6-hmi 설계

댓글남기기