10. 노드 간 인터페이스 — 신호 목록과 프레임 설계 초안
⚠️ SR에 없는 내용이다. §2가 "네트워크 프레임의 상세 Byte/Bit 배치 … 는 별도의 네트워크 인터페이스 명세서에서 정의한다"고 미뤄 둔 부분을, 요구사항에서 역산해 만든 초안이다. TBD-001·002·003이 확정되면 여기부터 고친다. 09편의
interface/디렉터리에 들어갈 내용이다.
1. 왜 인터페이스를 먼저 정하는가
노드가 6개다. 각자 만들다가 나중에 붙이면 반드시 어긋난다. 인터페이스는 코드보다 먼저 합의돼야 하는 계약이다.
그리고 이 시스템에서는 인터페이스가 곧 요구사항이다. 예를 들어 §6.5.3의 "센서 오류, 통신 오류 및 기능 자체 오류를 구분하여 표시"는 — 프레임에 오류 종류 필드가 없으면 애초에 불가능하다. 신호 목록을 짜면서 요구사항을 하나씩 대조하면 빠진 게 드러난다.
2. 물리 계층 — 무엇이 무엇에 붙나
| 링크 | 방식 | 상태 | 근거 |
|---|---|---|---|
| Central ↔ Power Window | CAN (FD 여부 미정) | TBD-002 | §4.1 그림 1 |
| Central ↔ Function | CAN | TBD-002 | 〃 |
| Central ↔ Sensing | CAN | TBD-002 | 〃 |
| Central ↔ ESP32 | 미정 — UART / CAN / Wi-Fi | TBD-001 | §4.1 "물리 통신 TBD" |
| Sensing ↔ Raspberry Pi | 미정 — UART / SPI | TBD-003 | §4.1 "UART/SPI TBD" |
| 엔진룸 카메라 ↔ ? | 미정 — Sensing 경유 or 별도 Vision 경로 | TBD-033 | §8 |
2-1. TBD-001을 CAN으로 정할 때의 이점과 제약
이점
- 프로토콜이 하나로 통일된다. 신호 정의·E2E·heartbeat 코드를 그대로 재사용.
- 브로드캐스트가 공짜다. Central이 상태를 한 번 쏘면 ESP32도 같이 받는다. 07편 §6-2의 "이벤트 + 주기 재전송"이 자연스럽다.
- 노이즈 내성이 UART보다 훨씬 낫다(차동 신호).
제약
- 07편 §6-1에서 본 대로 채택할 ESP32 변종의 TWAI가 CAN FD를 지원하는지 먼저 확인해야 한다. 지원하지 않는데 TBD-002에서 FD를 택하면 붙일 수 없다.
- CAN 트랜시버 칩이 ESP32 보드에 추가로 필요하다.
Classic CAN으로 통일하는 안이 가장 단순하다. 이 프로토타입에서 FD가 필요할 만큼 데이터가 많지 않다 — 아래 신호 목록을 보면 전 노드 합산 부하가 낮다.
2-2. TBD-003 — UART vs SPI
| UART | SPI | |
|---|---|---|
| 배선 | 2선 (TX/RX) | 4선 (SCK/MOSI/MISO/CS) |
| 속도 | ~1 Mbps 실용 | 수~수십 Mbps |
| 마스터/슬레이브 | 대칭 — RPi가 자발적으로 보낼 수 있다 | 마스터(S32K)가 물어봐야 슬레이브(RPi)가 답한다 |
| 프레이밍 | 직접 구현 (SOF·길이·CRC) | 직접 구현 |
| RPi 지원 | 안정적 | 안정적 (SPI 슬레이브는 까다로움) |
비전 결과는 4~8바이트에 불과하다(03편 §4-3). 속도가 전혀 문제되지 않으므로 UART가 낫다. 결정적 이유는 방향성이다 — 엔진룸 동물 진입은 RPi가 판정하는 즉시 올려보내야 하는 이벤트인데, SPI 슬레이브는 마스터가 물어볼 때까지 기다려야 한다. 폴링 주기만큼 지연이 붙는다.
3. 신호 목록 — 요구사항에서 역산
09편 §7의 상태 소유권 표를 신호로 펼친 것이다. 송신 노드 = 원본 노드다.
3-1. Sensing → Central (그리고 Function·ESP32가 함께 수신)
| 신호 | 타입 | 범위 · 단위 | 주기 | 근거 |
|---|---|---|---|---|
lux | u17 | 0~100000 lux | 100 ms | §6.3.1 |
lux_trust | u3 | trust_t | 〃 | §6.3.4 |
temp_c10 | s12 | −400~850 (0.1 °C) | 100 ms | §6.3.1 |
temp_trust | u3 | 〃 | ||
humid_p10 | u10 | 0~1000 (0.1 %RH) | 100 ms | §6.3.1 |
humid_trust | u3 | 〃 | ||
dist_mm | u13 | 20~5000 mm | 50 ms | §6.3.1 |
dist_trust | u3 | 〃 | ||
prox_level | u3 | 0~5 단계 | 〃 | §6.3.3 · TBD-013 |
prox_risk | u1 | 근접 위험 상태 | 〃 | §6.3.3 |
occupant_present | u1 | 200 ms | §6.3.1 | |
occupant_count | u4 | 0~15 | 〃 | §6.3.1 |
occupant_trust | u3 | 〃 | §6.3.4 | |
over_capacity | u1 | 정원 초과 | 〃 | §6.3.2 |
animal_present | u1 | 엔진룸 대상 동물 | 200 ms | §6.3.5 |
animal_conf | u8 | 0~255 신뢰도 | 〃 | §6.3.5 · TBD-030 |
animal_trust | u3 | 〃 | §6.3.5 | |
sensing_fault | u8 | 센서별 오류 비트맵 | 200 ms | §6.6 |
*_trust 필드가 값마다 따라붙는 게 핵심이다. 09편 규칙 2 — 사본은 항상 trusted_t로 감싼다 — 를 프레임 레벨에서 보장한다. 값만 보내고 유효성을 안 보내면 수신 측이 §6.5.3을 만족할 방법이 없다.
trust_t를 3비트로 보내는 이유:TRUST_OK / OUT_OF_RANGE / STALE / RECOVERING / NO_DATA5값이라 3비트면 충분하고, 수신 측이 §6.5.3의 "센서 오류 vs 통신 오류" 구분을 할 수 있다.bool valid1비트로 줄이면 왜 무효인지가 사라진다.
3-2. Power Window → Central
| 신호 | 타입 | 값 | 주기 | 근거 |
|---|---|---|---|---|
win_state | u4 | 7상태 (완전열림/완전닫힘/열림중/닫힘중/중간정지/Anti-pinch/오류) | 이벤트 + 200 ms | §6.2.6 |
win_pos_p10 | u10 | 0~1000 (0.1 % 개도) | 〃 | §6.2.3 |
win_trust | u3 | 위치 신뢰도 | 〃 | §6.2.6 |
pinch_event | u1 | 보호 동작 발생 | 이벤트 즉시 | §6.2.4 |
pinch_done | u1 | 보호 동작 완료 | 이벤트 즉시 | §6.2.4 |
pinch_latched | u1 | 재개 금지 래치 | 200 ms | §6.2.4 |
antipinch_avail | u1 | Anti-pinch 사용 가능 | 200 ms | §6.2.2 |
req_id / req_state / req_reject | u16/u3/u4 | 요청 생애주기 | 이벤트 | §6.2.6 · §6.5.1 |
pw_fault | u8 | 오류 종류 · 세부 | 200 ms | §6.6 |
pinch_event는 주기 전송을 기다리지 않는다. 01편 §5 시퀀스에서 봤듯 경고음이 최대한 빨리 나야 하므로 이벤트 즉시 송신하고, CAN ID도 낮게(높은 우선순위) 배정한다.
antipinch_avail이 별도 신호인 이유: §6.2.2의 "Anti-pinch 보호 기능을 사용할 수 없는 경우 닫힘 동작을 시작하지 않아야 한다"를 Central·HMI가 미리 알아야 한다. 사용자가 닫힘 버튼을 눌렀을 때 비로소 거부하는 것보다, 버튼을 비활성화해 보여주는 게 낫다.
3-3. Function → Central
| 신호 | 타입 | 값 | 주기 | 근거 |
|---|---|---|---|---|
door_lock | u2 | LOCKED / UNLOCKED / UNKNOWN | 이벤트 + 200 ms | §6.4.1 |
door_ajar | u2 | CLOSED / OPEN / UNKNOWN | 〃 | §6.4.1 |
door_report | u3 | 5상태 (정상3 + INCONSISTENT + UNTRUSTED) | 〃 | §6.4.1 |
fan_owner | u3 | NONE / AUTO / MANUAL / PRECOND | 200 ms | §6.4.2 |
fan_req | u2 | OFF/LOW/MED/HIGH (요구) | 〃 | §6.4.2 |
fan_act | u2 | OFF/LOW/MED/HIGH (실제) | 〃 | §6.4.2 |
precond_state | u2 | IDLE / RUNNING / ENDED | 〃 | §6.4.2 |
vent_state | u3 | 자동 환기 동작 상태 | 〃 | §6.4.2 |
vent_complete | u2 | 정상완료 / 미완료(닫힘 거부) | 이벤트 | §6.4.2 ★ |
amb_active | u3 | 현재 표시 종류 6종 | 200 ms | §6.4.3 |
amb_duty | u7 | 0~100 % | 〃 | §6.4.3 |
fn_fault | u16 | 기능별 × 오류종류 | 200 ms | §6.6 |
★ vent_complete가 05편 §5-3의 요구사항을 신호로 만든 것이다. "닫힘이 거부되어 정상 완료가 아님"을 상위로 전달할 통로가 없으면 그 요구사항은 구현될 수 없다. 신호 목록을 짜면서 발견되는 전형적인 누락이다.
3-4. Central → 각 실행 노드
| 신호 | 수신 | 값 | 주기 | 근거 |
|---|---|---|---|---|
veh_power | 전 노드 | OFF/ACC/ON/START | 100 ms | §5 · TBD-027 |
win_cmd | Power Window | OPEN/CLOSE/STOP + 출처 + one_touch | 이벤트 | §6.2.1 |
win_cmd_seq | 〃 | u8 순환 카운터 | 〃 | §6.2.5 유령 명령 방지 |
lock_cmd | Function | LOCK/UNLOCK + 출처 | 이벤트 | §6.4.1 |
climate_set | Function | enable + target_c10 | 이벤트 + 1 s | §6.4.2 |
fan_cmd | Function | 수동 출력 수준 | 이벤트 | §6.4.2 |
vent_enable | Function | 자동 환기 사용 설정 | 이벤트 + 1 s | §6.4.2 |
amb_set | Function | enable + brightness | 이벤트 + 1 s | §6.4.3 |
amb_safety | Function | Warning/Fault/Emergency 요구 | 이벤트 즉시 | §6.4.3 |
vision_trigger | Sensing → RPi | 엔진룸 판정 트리거 | 이벤트 | §6.3.5 · TBD-036 |
설정성 신호(climate_set, vent_enable, amb_set)에 1초 주기 재전송을 붙인 이유: 이벤트만 보내면 그 프레임이 유실됐을 때 노드 간 설정이 영구히 어긋난다. 07편 §6-2의 "이벤트 + 주기"를 설정에도 적용한 것이고, 주기가 느린 건 자주 안 바뀌기 때문이다.
3-5. ESP32 ↔ Central
| 방향 | 신호 | 근거 |
|---|---|---|
| ESP32 → Central | user_req (기능 + 명령 + req_id) | §6.5.1 |
| ESP32 → Central | dk_authenticated, dk_prox_state, dk_departed | §6.4.1 · TBD-016 |
| ESP32 → Central | mobile_authorized (인증 + 차량별 권한) | §6.5.2 |
| ESP32 → Central | user_settings (무음, 야간, 목표온도, 밝기 …) | §6.1.3 · §6.4.2,3 |
| Central → ESP32 | 위 3-1~3-3의 모든 상태 신호 (중계 또는 브로드캐스트 수신) | §6.5.3 |
| Central → ESP32 | req_result (req_id + state + reject) | §6.5.1 |
| Central → ESP32 | fault_summary (기능 × 오류종류) | §6.5.3 · §6.6 |
| Central → ESP32 | alert_event (ROA, 동물 진입 등 중요 경고) | §6.3.5 · §6.5.4 |
alert_event가 상태와 별개인 이유: §6.5.4의 "사용자가 확인하지 않은 중요 경고가 존재하는 경우 해당 경고 상태를 확인할 수 있도록 해야 한다"는 이벤트 이력을 요구한다. 현재 상태만 보내면 "잠깐 발생했다 사라진 경고"가 사라진다.
4. CAN 프레임 배치 초안
4-1. ID 배정 원칙
CAN은 ID가 작을수록 아비트레이션에서 이긴다. 그래서 ID 배정이 곧 우선순위 설계다.
| ID 대역 | 용도 | 예 |
|---|---|---|
0x000~0x0FF | 안전 이벤트 — 즉시성이 요구되는 것 | 0x010 pinch_event, 0x020 amb_safety, 0x030 alert_event |
0x100~0x1FF | 제어 명령 | 0x110 win_cmd, 0x120 lock_cmd, 0x130 fan_cmd |
0x200~0x2FF | 고빈도 상태 (50~100 ms) | 0x210 sensing_fast, 0x220 window_state |
0x300~0x3FF | 저빈도 상태 (200 ms~1 s) | 0x310 sensing_slow, 0x320 function_state |
0x400~0x4FF | 설정 | 0x410 climate_set, 0x420 amb_set |
0x500~0x5FF | heartbeat · 진단 | 0x510~0x513 노드별 heartbeat |
pinch_event가 0x010인 게 요구사항 대응이다. §6.1.2의 "안티핀치 비상 경고음을 다른 음향보다 최우선"을 만족하려면, 그 이벤트가 CAN 버스에서도 최우선이어야 한다. 버스가 혼잡할 때 다른 프레임에 밀리면 경고음이 늦는다.
4-2. 프레임 예시 — 0x210 sensing_fast (8 byte, 50 ms)
byte bit7 bit6 bit5 bit4 bit3 bit2 bit1 bit0
0 [ dist_mm [12:5] ]
1 [ dist_mm [4:0] ][ dist_trust ]
2 [ prox_level ][prox_risk][ reserved ]
3 [ temp_c10 [11:4] ]
4 [ temp_c10[3:0] ][ temp_trust ]
5 [ seq_counter (0~255) ]
6 [ e2e_crc8 [7:0] ]
7 [ alive_counter[3:0] ][ data_id[3:0] ]
| 필드 | 크기 | 역할 |
|---|---|---|
seq_counter | 8b | 중복·역순 검출 (§6.2.5) |
e2e_crc8 | 8b | 애플리케이션 레벨 무결성 — CAN 자체 CRC와 별개 |
alive_counter | 4b | 송신 노드가 살아 있는지 (프레임은 오는데 값이 안 바뀌는 경우 검출) |
data_id | 4b | 프레임 종류 식별 — ID 오배선/오설정 검출 |
alive_counter와 seq_counter를 둘 다 두는 이유가 헷갈릴 수 있다.
seq_counter: 이 데이터가 새 것인가 — 같은 값이 재전송된 건지 새 측정인지alive_counter: 송신 태스크가 도는가 — 태스크가 멈춰 DMA가 옛 버퍼를 계속 쏘는 경우 검출
두 번째가 실제로 잡기 어려운 고장이다. 송신 노드의 애플리케이션이 hang 됐는데 CAN 컨트롤러가 마지막 프레임을 자동 재전송하면, 수신 측은 정상 프레임을 계속 받는다. alive_counter가 안 변하는 것으로만 알 수 있다.
4-3. E2E 보호를 왜 또 하는가
CAN은 이미 15비트 CRC를 갖고 있다. 그런데 왜 애플리케이션 CRC를 또 넣나?
CAN CRC는 버스 구간만 보호한다.
[송신 앱] → [송신 버퍼] → [CAN 컨트롤러] ══CRC 보호══ [CAN 컨트롤러] → [수신 버퍼] → [수신 앱]
↑ ↑ ↑ ↑
여기서 여기서 여기서 여기서
깨지면 깨지면 깨지면 깨지면
CAN CRC 가 못 잡는다 ─────────────────────────────────────────────────────────┘
메모리 손상, DMA 오설정, 버퍼 인덱스 버그, 신호 인코딩 실수 — 전부 CAN CRC 범위 밖이다. E2E는 앱에서 앱까지를 보호한다. ISO 26262가 요구하는 이유이고, §6.2.5의 "의도하지 않은 윈도우 구동이 발생하지 않아야 한다"를 진지하게 구현하려면 필요하다.
/* com/e2e.c — 송신 */
void e2e_protect(uint8_t *data, uint8_t len, uint8_t data_id, e2e_ctx_t *ctx)
{
data[len - 3] = ctx->seq++; /* seq_counter */
data[len - 1] = (uint8_t)((ctx->alive++ & 0x0F) | (data_id << 4));
data[len - 2] = crc8_sae_j1850(data, len - 2); /* CRC 자리 제외하고 계산 */
}
/* com/e2e.c — 수신 */
e2e_result_t e2e_check(const uint8_t *d, uint8_t len, uint8_t data_id, e2e_ctx_t *ctx)
{
if (d[len - 2] != crc8_sae_j1850(d, len - 2)) return E2E_CRC_ERR;
if (((d[len - 1] >> 4) & 0x0F) != data_id) return E2E_WRONG_ID;
uint8_t alive = d[len - 1] & 0x0F;
if (alive == ctx->last_alive) return E2E_STALE_ALIVE; /* 태스크 정지 */
ctx->last_alive = alive;
uint8_t delta = (uint8_t)(d[len - 3] - ctx->last_seq);
if (delta == 0) return E2E_REPEATED;
if (delta > E2E_MAX_SEQ_JUMP) return E2E_LOST; /* 프레임 유실 */
ctx->last_seq = d[len - 3];
return E2E_OK;
}
반환값이 bool이 아니라 열거형인 게 §6.5.3 대응이다. E2E_CRC_ERR(전기적 문제)와 E2E_STALE_ALIVE(송신 노드 hang)는 사용자에게 다른 오류로 보여야 한다.
5. Heartbeat와 타임아웃 — TBD-020
5-1. 무엇을 감시하나
| 감시 대상 | 방법 | 실패 시 |
|---|---|---|
| 노드가 살아 있는가 | 전용 heartbeat 프레임 (0x510~) | 해당 노드 관련 전 신호를 ERR_COMM |
| 특정 신호가 갱신되는가 | 신호별 max_age (svc/trust) | 그 신호만 TRUST_STALE |
| 송신 태스크가 도는가 | alive_counter | ERR_COMM (원인 세부: 태스크 정지) |
세 층이 다른 고장을 잡는다. heartbeat만 있으면 "노드는 살아 있는데 센서 태스크만 죽은" 경우를 못 잡고, max_age만 있으면 "값이 안 변할 뿐 정상인" 신호(도어 상태 등)를 오탐한다.
5-2. 주기와 타임아웃 결정
heartbeat 주기 = 100 ms (제안)
heartbeat 타임아웃 = 350 ms (3회 연속 유실 + 여유)
신호별 max_age = 생산 주기 × 2~3 (09편 §8 검산)
복구 판정 = 연속 N회 정상 (TBD-038)
타임아웃 = 주기 × 3 + α가 관행이다. 2배면 한 번 유실 + 지터로 오탐이 나고, 5배면 검출이 너무 늦다.
/* com/heartbeat.c */
#define HB_PERIOD_MS 100u
#define HB_TIMEOUT_MS 350u
#define HB_RECOVER_N 3u /* TBD-038 — 진입은 1회, 복구는 3회 */
typedef struct {
uint32_t last_rx_ms;
uint8_t ok_streak;
bool alive;
} hb_node_t;
static hb_node_t g_hb[NODE_COUNT];
void hb_on_rx(node_id_t n) {
g_hb[n].last_rx_ms = hal_now_ms();
if (g_hb[n].ok_streak < 255) g_hb[n].ok_streak++;
if (!g_hb[n].alive && g_hb[n].ok_streak >= HB_RECOVER_N) {
g_hb[n].alive = true;
fault_clear(node_to_func(n), ERR_COMM);
/* §6.4.4 — 복구했다고 자동 재개하지 않는다. 재평가만 요청 */
arbiter_request_reevaluate(node_to_func(n));
}
}
void hb_tick(void) { /* 10 ms 주기 */
for (node_id_t n = 0; n < NODE_COUNT; n++) {
if (g_hb[n].alive && (hal_now_ms() - g_hb[n].last_rx_ms) > HB_TIMEOUT_MS) {
g_hb[n].alive = false; /* 진입은 즉시 */
g_hb[n].ok_streak = 0;
fault_set(node_to_func(n), ERR_COMM, ERRD_HEARTBEAT_LOST);
}
}
}
arbiter_request_reevaluate()가 §6.4.4의 구현이다. 복구 시 바로 동작을 재개하지 않고 "다시 판단해라"만 요청한다. 05편 §8-1의 흐름도가 이 한 줄로 표현된다.
6. Sensing ↔ Raspberry Pi UART 프레임 — TBD-003
6-1. 프레임 포맷
+------+------+------+----------+------+------+
| 0xAA | 0x55 | LEN | PAYLOAD | CRC16 (LE) |
+------+------+------+----------+------+------+
SOF1 SOF2 1B LEN B 2B
2바이트 SOF를 쓰는 이유: UART는 프레임 경계가 없다. 1바이트 SOF면 페이로드 안의 우연한 0xAA에 동기가 잘못 걸린다. 2바이트면 확률이 크게 준다. 그래도 완전하지 않으므로 CRC 실패 시 1바이트 밀고 재동기해야 한다.
6-2. 페이로드 — 비전 결과 (RPi → Sensing)
/* interface/uart_vision.h — 양쪽이 같은 헤더를 본다 */
#define VIS_MSG_RESULT 0x01
typedef struct __attribute__((packed)) {
uint8_t msg_type; /* VIS_MSG_RESULT */
uint8_t flags; /* bit0 occupant_present
bit1 animal_present
bit2 occupancy_valid
bit3 animal_valid
bit4 over_capacity
bit5~7 reserved */
uint8_t occupant_count; /* 0~15 */
uint8_t occupancy_conf; /* 0~255 */
uint8_t animal_conf; /* 0~255 — TBD-030 */
uint8_t quality_code; /* 0=ok, 1=too_dark, 2=too_bright, 3=blurred, 4=saturated */
uint16_t age_ms; /* 이 판정의 나이 — TBD-022 */
uint8_t alive; /* 추론 루프가 도는지 */
} vis_result_t; /* 9 byte — 영상 대비 수십만 분의 1 */
이 구조체에 이미지 데이터가 없다는 것이 §6.3.4 개인정보 요구사항 충족의 증거다. 코드 리뷰에서 확인 가능한 형태로 요구사항이 구현에 박혀 있다.
quality_code를 따로 보내는 이유: *_valid 비트만 보내면 "왜 무효인지"를 모른다. §6.5.3이 오류 종류 구분을 요구하므로, 렌즈 오염(blurred)과 야간 저조도(too_dark)를 사용자에게 다르게 보여줄 수 있어야 한다.
6-3. 트리거 (Sensing → RPi)
#define VIS_MSG_TRIGGER 0x02
typedef struct __attribute__((packed)) {
uint8_t msg_type; /* VIS_MSG_TRIGGER */
uint8_t target; /* bit0 occupancy, bit1 engine_bay */
uint8_t reason; /* 0=periodic, 1=ignition_attempt, 2=door_open — TBD-036 */
} vis_trigger_t;
reason을 보내는 이유는 RPi가 추론 파라미터를 다르게 할 수 있게 하기 위해서다. 시동 시도 트리거면 지연이 급하므로 낮은 해상도로 빨리, 주기 트리거면 배터리를 아끼되 정확하게.
7. interface/signals.yaml — 단일 정의 파일
신호를 코드에 직접 쓰면 노드마다 어긋난다. 하나의 정의 파일에서 각 노드용 헤더를 생성하는 게 정석이다.
# interface/signals.yaml (발췌)
frames:
- name: sensing_fast
id: 0x210
dlc: 8
period_ms: 50
tx: sensing
rx: [central, function, hmi]
signals:
- { name: dist_mm, start: 0, size: 13, type: u, min: 20, max: 5000, unit: mm }
- { name: dist_trust, start: 13, size: 3, type: enum, values: [OK, RANGE, STALE, RECOVER, NODATA] }
- { name: prox_level, start: 16, size: 3, type: u, min: 0, max: 5 }
- { name: prox_risk, start: 19, size: 1, type: bool }
- { name: temp_c10, start: 24, size: 12, type: s, min: -400, max: 850, unit: "0.1degC" }
- { name: temp_trust, start: 36, size: 3, type: enum }
e2e: { seq: 40, crc: 48, alive: 56, data_id: 0x1 }
- name: window_state
id: 0x220
dlc: 8
period_ms: 200
event: true
tx: power_window
rx: [central, hmi]
signals:
- { name: win_state, start: 0, size: 4, type: enum,
values: [FULL_OPEN, FULL_CLOSED, OPENING, CLOSING, MID_STOPPED, ANTIPINCH, ERROR] }
- { name: win_pos_p10, start: 4, size: 10, type: u, min: 0, max: 1000 }
- { name: win_trust, start: 14, size: 3, type: enum }
- { name: pinch_latched, start: 17, size: 1, type: bool }
- { name: antipinch_avail, start: 18, size: 1, type: bool }
빌드 시 이 파일에서 signals.h(인코딩·디코딩 함수)를 생성하면:
- 노드 간 배치 불일치가 원천 차단된다
data_id가 자동 부여되어 오배선 검출이 공짜로 따라온다period_ms를 읽어max_age검산 스크립트를 만들 수 있다 (09편 §8 경고)
# tools/check_age.py — max_age 가 생산 주기보다 충분히 큰지 검산
import yaml
CFG = yaml.safe_load(open("interface/signals.yaml"))
TRUST = yaml.safe_load(open("interface/trust_config.yaml"))
for f in CFG["frames"]:
for s in f["signals"]:
age = TRUST.get(s["name"], {}).get("max_age_ms")
if age is None:
continue
if age < f["period_ms"] * 2:
print(f"경고 {s['name']}: max_age {age}ms < 주기 {f['period_ms']}ms × 2 — 항상 stale 위험")
이 스크립트를 CI에 넣으면 09편에서 지적한 모순이 자동으로 잡힌다.
8. 버스 부하 개산
Classic CAN 500 kbps 기준, 표준 ID 8바이트 프레임은 스터핑 포함 대략 130 비트 ≈ 0.26 ms다.
| 프레임 | 주기 | 초당 프레임 | 초당 비트 |
|---|---|---|---|
| sensing_fast (0x210) | 50 ms | 20 | 2,600 |
| sensing_slow (0x310) | 200 ms | 5 | 650 |
| window_state (0x220) | 200 ms | 5 | 650 |
| function_state (0x320) | 200 ms | 5 | 650 |
| veh_power (0x2xx) | 100 ms | 10 | 1,300 |
| heartbeat × 4 | 100 ms | 40 | 5,200 |
| 설정 3종 | 1 s | 3 | 390 |
| 이벤트 (여유) | — | ~20 | 2,600 |
| 합계 | ~108 | ~14,000 bps |
500 kbps 대비 약 2.8 %. 대단히 여유롭다.
이 계산의 함의:
- CAN FD가 전혀 필요 없다. TBD-002에서 Classic CAN을 택해도 부하 문제가 없고, 그러면 TBD-001에서 ESP32를 CAN에 붙이는 선택지가 살아난다(07편 §6-1).
- 250 kbps로 낮춰도 5.6 %다. 배선 길이 여유를 벌 수 있다 — 비트레이트가 낮을수록 버스를 길게 뽑을 수 있다.
- 이벤트 프레임에 큰 여유가 있으므로,
pinch_event같은 안전 이벤트가 지연될 걱정이 없다.
다만 이건 정상 상태 계산이다. 고장 시 오류 프레임이 폭주하거나, 버스 오프 복구 반복으로 부하가 치솟는 시나리오는 별도 검토가 필요하다. 그래서 TBD-002가 "트랜시버, 종단 및 메시지 정책 확정"까지 포함한다.
9. 한 줄 요약
| 항목 | 한 줄 |
|---|---|
| 물리 계층 | Classic CAN 통일이 가장 단순. Vision 링크는 이벤트 방향성 때문에 UART가 SPI보다 낫다 |
| 신호 목록 | 모든 값에 *_trust가 따라붙는다. 유효성 없는 값은 §6.5.3을 만족할 수 없다 |
| 누락 발견 | vent_complete, antipinch_avail 같은 신호는 요구사항 역산으로만 나온다 |
| CAN ID | ID가 곧 우선순위. pinch_event를 0x010에 둬야 §6.1.2가 버스에서도 성립 |
| E2E | CAN CRC는 버스 구간만 보호. 앱-앱 보호는 별도. alive_counter가 태스크 정지를 잡는다 |
| Heartbeat | 노드·신호·태스크 3층 감시. 타임아웃은 주기 × 3 + α. 복구는 재평가 요청까지만 |
| 부하 | 500 kbps에서 약 2.8 % — FD 불필요. 이게 TBD-002를 Classic으로 밀 근거 |
| 관리 | signals.yaml 단일 정의 → 헤더 생성 + max_age 검산 스크립트를 CI에 |
설계 판단 체크포인트
구현에 들어가기 전에 팀이 답을 갖고 있어야 하는 것들이다. 답이 안 나오면 그 자리가 곧 설계 문의 또는 TBD 후보다.
-
버스 부하가 2.8 %면 CAN FD를 안 쓸 근거가 충분한가? 충분하다. 다만 확인할 것은 향후 확장이다. 카메라 메타데이터를 더 보내거나 진단 로그를 CAN으로 올릴 계획이 있으면 다시 계산해야 한다. 현재 SR 범위(§7.2에서 AUTOSAR·양산 제외)에서는 Classic으로 충분하다.
-
*_trust필드를 매 신호마다 붙이면 대역폭이 아깝지 않은가? 3비트 × 신호 수다. 위 부하 계산에 이미 포함돼 있고 2.8 %다. 반대로 이걸 빼면 §6.3.4·§6.5.3·§6.4.4를 구현할 방법이 없어져 요구사항 미충족이 된다. 비용 대비 명백히 남는 거래다. -
alive_counter와 heartbeat 중 하나만 두면 안 되나? 안 된다. heartbeat는 "노드가 프레임을 보내는가",alive_counter는 "그 데이터를 만드는 태스크가 도는가"를 본다. 센서 태스크만 hang 되고 통신 태스크는 도는 고장에서 heartbeat는 정상으로 보인다. 실제로 흔한 고장 모드다. -
signals.yaml코드 생성을 도입할지, 헤더를 손으로 관리할지? 노드가 6개고 신호가 50개 이상이면 손 관리는 반드시 어긋난다. 다만 생성기를 만드는 비용이 있으므로, 최소한interface/디렉터리의 헤더 하나를 전 노드가 공유하는 것이 하한선이다. 노드마다 복사본을 두는 것만은 피해야 한다. -
prox_level(단계)과dist_mm(원값)을 둘 다 보내는 게 중복 아닌가? 중복이지만 의도적이다. 03편 §3-4에서 봤듯 VSS는 2단계, HMI는 5단계, 그리고 거리 비례 경고음 간격에는 원값이 필요하다. 단계만 보내면 Central이 VSS 매핑을 독자적으로 못 하고, 원값만 보내면 단계 히스테리시스를 두 곳에서 각각 구현하게 된다. -
Vision UART에서 CRC 실패 시 1바이트 밀고 재동기하는 방식의 한계는? 페이로드 안에 우연히
0xAA 0x55가 들어 있으면 잘못된 경계에서 반복 실패할 수 있다. 근본 해법은 **바이트 스터핑(COBS 등)**으로 페이로드에서 SOF 바이트를 제거하는 것이다. 프레임이 9바이트로 작으므로 스터핑 오버헤드도 미미하다. 재동기 실패가 관측되면 이쪽으로 가야 한다. -
veh_power의 송신 노드가 Central인데, TBD-027에서 ESP32 입력으로 정해지면? 신호 방향이 뒤집힌다(ESP32 → Central → 전 노드 중계, 또는 ESP32가 직접 브로드캐스트). 09편 체크포인트 5번에서 봤듯 통신 의존이 늘어나므로, Central을 원본으로 유지하고 ESP32는 입력만 전달하는 형태를 권한다. 이렇게 하면 TBD-027이 어느 쪽으로 정해져도 이 프레임 정의는 안 바뀐다.