RaceMatrix UDP Telemetry Protocol and Live Telemetry System

작성자: KiHyunKangKiHyunKang | 작성일: 2026-06-15 08:52
목록
문서타입: technical
보안수준: public
태그: udp telemetry protocol format logger live mf4 csv vbo xrz xrk hrz raw

라이브 텔레메트리 수집 시스템

차량 텔레메트리 데이터를 UDP 바이너리 프로토콜로 실시간 수집하여 live snapshot/ring buffer에 저장하는 시스템.

이 문서는 현재 구현 기준의 end-to-end 동작을 설명한다. 실시간 샘플 전송은 UDP 8677만 사용한다. HTTP(S)는 세션 발급, 고해상도 로그 업로드, live snapshot/samples 조회 API에 사용하며, raw TCP 소켓으로 동일 live telemetry를 이중 수집하는 경로는 없다.

UDP 데이터는 피트 화면의 빠른 갱신을 위한 휘발성 자료다. 정식 세션 히스토리와 리뷰 데이터는 이후 업로드되는 MF4/CSV/VBO/XRZ/XRK/HRZ/RAW 같은 고해상도 원본 로그를 authoritative source로 사용한다.

아키텍처 개요

┌──────────────┐  HTTPS session API   ┌──────────────────────┐
│  텔레메트리   │ ──────────────────► │ TelemetrySession      │
│  로거 장비    │ ◄────────────────── │ API Controller        │
└──────┬───────┘  session_id/key      │ source/run/upload 토큰 │
       │          upload_token        └──────────────────────┘
       │
       │  UDP 8677 (AES-128-CTR + HMAC + CRC)
       ▼
┌──────────────┐    Thread-safe    ┌──────────────────┐
│  UdpListener  │  ────Queue────►  │ TelemetryWorker  │
│  (소켓 스레드) │                  │  (워커 스레드)     │
└──────────────┘                  └────────┬─────────┘
                                           │
                                           │ CRC → HMAC → 파싱
                                           │
                            ┌──────────────┼──────────────────┐
                            │              │                  │
                            ▼              ▼                  ▼
                     ┌────────────┐ ┌─────────────┐ ┌─────────────────┐
                     │ Live Store │ │Redis/Session │ │TelemetryRun/Source│
                     │(휘발 저장) │ │  (키 조회)   │ │  활동 상태 갱신    │
                     └────────────┘ └─────────────┘ └─────────────────┘
                            │
                            │ HTTPS polling
                            ▼
                     ┌────────────┐
                     │ Live UI/API │
                     │ snapshot    │
                     │ samples     │
                     └────────────┘

세션 종료 또는 logger upload 시:

┌──────────────┐  HTTPS upload API  ┌──────────────────────┐
│  텔레메트리   │ ─────────────────► │ TelemetryUploads     │
│  로거 장비    │ upload_token/file │ Controller           │
└──────────────┘                    └──────────┬───────────┘
                                               │
                                               ▼
                                    ┌──────────────────────┐
                                    │ TelemetryLogImporter │
                                    │ TelemetryLog/Run DB  │
                                    └──────────┬───────────┘
                                               │
                                               ▼
                                    TelemetryLiveStore.clear_run!

구성 파일

파일 역할
lib/udp_listener.rb UDP 소켓으로 패킷 수신, 큐에 적재
lib/telemetry_worker.rb 큐에서 패킷을 꺼내 바이너리 파싱 + HMAC 검증 후 live store 갱신
app/services/telemetry_live_store.rb Redis/메모리 기반 최신 샘플과 짧은 ring buffer
app/services/telemetry_credential_authenticator.rb source credential HMAC 인증
lib/telemetry_session.rb 세션 키 생성/조회/삭제 (Redis + 메모리 폴백)
lib/active_logger_tracker.rb 활성 로거 실시간 추적
app/controllers/api/v1/telemetry_sessions_controller.rb 세션 발급 API
app/controllers/api/v1/telemetry_uploads_controller.rb 고해상도 원본 로그 업로드 API
app/controllers/api/v1/live_telemetry_controller.rb live snapshot/latest/samples 조회 API
app/assets/javascripts/live_telemetry.js live 화면 polling과 선택 차량 snapshot review
app/models/telemetry_source.rb 로거/차량/팀 소스 식별
app/models/telemetry_run.rb live session/run 상태와 authoritative source 관리
app/models/telemetry_log.rb 텔레메트리 데이터 모델 + 바이너리 패킷 명세
config/initializers/udp_listener_initializer.rb Rails 서버 시작 시 자동 기동

1. 바이너리 패킷 포맷

전송 사양

항목
프로토콜 UDP
포트 8677
바이트 오더 Little-Endian
Magic Bytes 0xBB 0x42
최대 패킷 크기 1200 바이트
권장 전송 주기 5Hz (200ms)
CRC CRC-8 (polynomial 0x07)
HMAC HMAC-SHA256 truncated 16B (세션 키 기반)
암호화 AES-128-CTR (세션 키 기반, Encrypt-then-MAC)

패킷 구조

[Header 10B][Session 4B][Encrypted Payload][HMAC 16B][CRC8 1B]
                        └─ AES-128-CTR ─┘
                        [Sample 48B × N][Opt1 8B × N][Opt2 8B × N]

암호화 범위:

  • 평문: Header (10B) + Session (4B) — 세션 조회에 필요
  • 암호화: Samples + Options (AES-128-CTR)
  • HMAC 대상: Header + Session + 암호화된 페이로드 (Encrypt-then-MAC)
  • CRC 대상: 전체 (HMAC 포함)

Nonce (16바이트, 패킷에 포함 안됨 — 기존 필드로 유도):

[session_id: 4B LE][sequence: 4B LE][version: 1B][packet_type: 1B][0x00 × 6]
  • session_id + uint32 sequence 조합으로 패킷당 고유 보장
  • 세션 내 sequence는 단조 증가해야 하며 중복/과거 sequence는 drop한다.

Header (10 bytes)

Offset  Size  Type      Field               설명
  0      2    uint8[2]  magic               0xBB, 0x42 고정
  2      1    uint8     ver_type            상위4비트=프로토콜 버전, 하위4비트=패킷 타입
  3      1    uint8     flags               bit0=Opt1(휠스피드), bit1=Opt2(온도/압력)
  4      4    uint32    sequence            패킷 일련번호 (세션 내 단조 증가)
  8      1    uint8     sample_count        배칭 샘플 수 (보통 1)
  9      1    uint8     logger_type         장비 타입 (0=unknown, 1=Flutter, 2=ESP32)

ver_type 구조:

  • 상위 4비트 (버전): 현재 0x2 (프로토콜 v2)
  • 하위 4비트 (패킷 타입): 0x1=Telemetry

flags 비트맵:

Bit 의미
0 Option Group 1 (휠스피드) 포함
1 Option Group 2 (온도/압력) 포함
2~7 예약

Session (4 bytes)

Offset  Size  Type      Field               설명
 10      4    uint32    session_id          세션 발급 API에서 발급된 세션 ID (Redis/Memory에 매핑)

Sample (48 bytes × N)

Offset  Size  Type      Field                  스케일링             범위
─────────────────────────────────────────────────────────────────────────
 GPS
  +0     8    uint64    gps_time               μs since epoch       UTC
  +8     1    uint8     gps_signal_strength    원본                 0~15
  +9     1    uint8     gps_satellites         원본                 0~255

 Position
 +10     4    int32     gps_x (경도)            ×1e-7°               ±180°
 +14     4    int32     gps_y (위도)            ×1e-7°               ±90°
 +18     4    int32     gps_z (고도)            mm                   ±2,147km

 Motion
 +22     2    uint16    heading                ×0.01°               0~359.99°
 +24     2    uint16    speed                  ×0.01 km/h           0~655 km/h
 +26     2    int16     lateral_g              ×0.001 G             ±32.7 G
 +28     2    int16     longitudinal_g         ×0.001 G             ±32.7 G
 +30     2    int16     yaw_rate               ×0.01 °/s            ±327 °/s

 Vehicle
 +32     2    uint16    rpm                    1 RPM                0~65535
 +34     2    uint16    throttle_pedal         ×0.1% (페달)          0~100.0%
 +36     2    uint16    throttle_opening       ×0.1% (개도량)        0~100.0%
 +38     2    uint16    brake_pedal            ×0.1% (페달)          0~100.0%
 +40     2    uint16    brake_pressure         ×0.1 bar (압력)       0~6553 bar
 +42     2    int16     steering_angle         ×0.1°                ±3276°
 +44     1    int8      gear                   원본                 -1=R, 0=N, 1~10

 System
 +45     2    uint16    battery_voltage        ×0.01 V              0~655 V
 +47     1    uint8     vehicle_flags          비트맵               아래 참조
─────────────────────────────────────────────────────────────────────────

vehicle_flags 비트맵:

Bit 의미
0 ABS 작동 중
1 TCS 작동 중
2~7 예약 (ESP, 런치컨트롤, 핏리미터 등)

Logger는 ABS/TCS 신호를 확정적으로 제공하지 못하면 vehicle_flags=0으로 송신해야 한다. on-track 여부는 run/session/circuit 상태에서 해석하며 이 비트맵에 넣지 않는다.

Option 1 — Wheel Speeds (flags bit0, +8B per sample)

Offset  Size  Type      Field               스케일링             범위
 +0      2    uint16    wheel_speed_fl      ×0.01 km/h           0~655 km/h
 +2      2    uint16    wheel_speed_fr      ×0.01 km/h
 +4      2    uint16    wheel_speed_rl      ×0.01 km/h
 +6      2    uint16    wheel_speed_rr      ×0.01 km/h

Option 2 — Temps & Pressures (flags bit1, +8B per sample)

Offset  Size  Type      Field               스케일링             범위
 +0      2    int16     oil_temp            ×0.1 °C              ±3276 °C
 +2      2    uint16    oil_pressure        ×0.01 bar            0~655 bar
 +4      2    int16     water_temp          ×0.1 °C              ±3276 °C
 +6      2    int16     ambient_temp        ×0.1 °C              ±3276 °C

HMAC (16 bytes, 패킷 끝 -17..-2)

  • HMAC-SHA256 truncated to 16 bytes
  • 키: 세션 생성 시 발급된 16바이트 세션 키
  • 대상: HMAC, CRC를 제외한 전체 패킷 (Header + Session + 암호화된 Payload)
  • 보안 패턴: Encrypt-then-MAC (암호화 후 HMAC → 표준 보안 패턴)
  • 보안 강도: 2^128 수준의 truncated MAC

CRC8 (1 byte, 패킷 끝)

  • CRC-8, polynomial 0x07
  • 대상: CRC를 제외한 전체 패킷 (HMAC 포함)
  • 초기값: 0x00
  • 역할: HMAC 검증 전 쓰레기 패킷 빠르게 제거

패킷 크기 정리

구성 크기
코어 (헤더+세션+샘플1개+HMAC+CRC) 79B
+ 휠스피드 87B
+ 온도/압력 87B
전체 옵션 포함 95B

일반식: 10B header + 4B session + sample_count × (48B sample + 옵션 크기) + 16B HMAC + 1B CRC.


2. UdpListener (lib/udp_listener.rb)

UDP 소켓을 열고 패킷을 수신하여 thread-safe 큐에 저장하는 컴포넌트.

동작 방식

  1. 0.0.0.0:8677에 UDP 소켓 바인드
  2. 별도 스레드에서 IO.select (1초 타임아웃) + recvfrom_nonblock으로 패킷 수신
  3. 수신된 패킷을 Queue에 push (thread-safe)

패킷 큐 구조

{
  data: "\xBB\x42...",       # 원본 바이너리 데이터
  source_ip: "192.168.1.100",
  source_port: 12345,
  received_at: Time.now
}

주요 메서드

메서드 설명
start 리스너 스레드 시작 (이미 실행 중이면 무시)
stop 소켓 닫기 → 스레드 join(5초) → 실패 시 kill
pop_packet 큐에서 패킷 꺼내기 (블로킹)
pop_packet_nonblock(timeout) 큐에서 패킷 꺼내기 (논블로킹)
queue_size 큐에 대기 중인 패킷 수
queue_empty? 큐 비어있는지 확인
clear_queue 큐 초기화

3. 세션 기반 인증과 HTTP API

라이브 텔레메트리에서 TCP는 HTTP(S) API의 전송 계층으로만 쓰인다. UDP live packet을 TCP로 중복 전송하거나 TCP socket stream으로 수집하는 구현은 없다.

경로 전송 용도 인증
POST /api/v1/telemetry/session HTTPS/TCP live session 발급, source/run 연결 source credential HMAC 또는 legacy JWT
UDP 8677 UDP 실시간 샘플 전송 session_id + session_key 기반 HMAC
POST /api/v1/telemetry/uploads HTTPS/TCP 고해상도 원본 로그 업로드 session_id + upload_token
GET /api/v1/live_telemetry/* HTTPS/TCP live snapshot/latest/samples 조회 로그인/API 사용자

Source Credential 인증 흐름

Logger App                                      Server
    │                                             │
    ├── POST /api/v1/telemetry/session ─────────►│
    │    credential_uid, timestamp, nonce,        │
    │    signature, source_type, lat/lng 또는      │
    │    preferred_circuit_id                     │
    │                                             │
    │                                             │ credential 조회
    │                                             │ timestamp skew 5분 검사
    │                                             │ nonce 10분 replay 검사
    │                                             │ canonical JSON HMAC 검사
    │                                             │ TelemetryRun 생성/재사용
    │◄── session_id, session_key, upload_token ──┤
    │    source_public_uid, run_public_uid,       │
    │    circuit, expires_in                      │
    │                                             │
    ├── UDP 8677 encrypted samples ─────────────►│ HMAC 검증 → 복호화 → live store
    │                                             │
    ├── POST /api/v1/telemetry/uploads ─────────►│ upload_token 검증
    │    session_id, upload_token, file           │ TelemetryLogImporter
    │                                             │ live store clear

TelemetryCredentialAuthenticator는 요청 파라미터에서 signature를 제외한 값을 canonical JSON으로 정렬한 뒤, TelemetrySourceCredential#source_secret으로 HMAC-SHA256을 계산한다. logger가 보낸 signature는 Base64 encoded HMAC이어야 한다.

Legacy JWT fallback

credential_uid가 없는 요청은 기존 JWT flow로 처리한다. 이 경로는 Authorization: Bearer <JWT>를 요구하며 TelemetrySession.create(user)로 세션을 만든다. 신규 logger/source 연동은 source credential flow를 기준으로 한다.

POST /api/v1/telemetry/session

Source credential 응답:

{
  "success": true,
  "session_id": 3847291056,
  "session_key": "base64-encoded-16-byte-key",
  "upload_token": "urlsafe-token",
  "expires_in": 3600,
  "source_public_uid": "src_...",
  "run_public_uid": "run_...",
  "circuit": { "id": 12, "name": "Inje Speedium" }
}

Legacy JWT 응답은 source_public_uid/run_public_uid 대신 logger_uid를 포함할 수 있다.

항목
세션 TTL 1시간 (만료 전 재발급 권장)
세션 키 16바이트 랜덤 키, Base64 응답
업로드 토큰 session별 랜덤 토큰, 서버에는 HMAC digest로 저장
저장소 Redis 우선 / 메모리 폴백

POST /api/v1/telemetry/uploads

고해상도 원본 로그 업로드 경로다. logger는 session 발급 때 받은 upload_tokensession_id와 함께 전송한다.

  1. TelemetrySession.upload_token_valid?로 토큰을 검증한다.
  2. 세션에 연결된 TelemetrySourceTelemetryRun을 찾는다.
  3. TelemetryLogImporter로 업로드 파일을 telemetry_logs와 해당 run에 반영한다.
  4. TelemetryLiveStore.clear_run!으로 같은 run의 휘발 live samples를 제거한다.
  5. 이후 samples API는 같은 run의 telemetry_logs fallback을 반환한다.

TelemetrySession (lib/telemetry_session.rb)

세션 키와 업로드 토큰 생명주기를 관리하는 클래스.

메서드 설명
create_for_source(source, telemetry_run:, circuit:) source credential flow 세션 생성 → source/run/circuit과 연결
create(user) legacy JWT flow 세션 생성
find(session_id) 세션 조회 → user/source/run/circuit IDs, public UIDs, session_key
upload_token_valid?(session_id, upload_token) 업로드 토큰 검증
update_circuit(session_id, circuit:) UDP sample geofence로 뒤늦게 확정된 circuit 반영
destroy(session_id) 세션 삭제
touch(session_id) TTL 연장

4. TelemetryWorker (lib/telemetry_worker.rb)

큐에서 패킷을 꺼내 바이너리 파싱 + HMAC 검증 후 TelemetryLiveStore에 최신 상태와 짧은 ring buffer를 저장하는 워커.

동작 방식

  1. 별도 스레드에서 UdpListener.pop_packet_nonblock(1.0)으로 패킷 폴링
  2. 크기 검사 → CRC8 검증 → Magic bytes 검증 (저비용 검사 우선)
  3. Header 언팩 → session_id 추출
  4. 세션 조회 (L1 캐시 → L2 Redis/메모리) → user/source/run/circuit IDs, public UIDs, session_key 획득
  5. HMAC-SHA256 검증 (세션 키 기반, 타이밍 공격 방지 비교)
  6. AES-128-CTR 복호화 (세션 키 + Nonce로 페이로드 복원)
  7. Sample(s) 바이너리 언팩 (스케일링 복원)
  8. 세션에 circuit_id가 없고 sample 좌표가 서킷 지오펜스 안이면 circuit을 늦게 매칭해 run/source/session cache에 반영
  9. TelemetryLiveStore에 최신 상태와 run별 ring buffer 저장
  10. TelemetryRun/TelemetrySource 활동 시간과 sample count 갱신
  11. legacy tracker 호환을 위해 ActiveLoggerTracker에 활동 기록

검증 흐름 (비용 순)

Raw bytes 수신
    │
    ├─ 1. 크기 검사 (79B~1200B) → 실패 시 drop   [비용: 0]
    │
    ├─ 2. CRC8 검증 → 실패 시 drop               [비용: 낮음]
    │
    ├─ 3. Magic 검증 (0xBB 0x42) → 실패 시 drop  [비용: 0]
    │
    ├─ 4. Header 언팩 → session_id 추출
    │
    ├─ 5. 세션 조회 (캐시 → Redis) → 미등록 drop  [비용: 중간]
    │
    ├─ 6. HMAC-SHA256 검증 → 실패 시 drop         [비용: 높음]
    │
    ├─ 7. AES-128-CTR 복호화 (페이로드)            [비용: 낮음]
    │
    └─ 8. Sample × N 언팩 (각 48B + 옵션)
        ├─ GPS 데이터 (스케일링 복원)
        ├─ Motion 데이터
        ├─ Vehicle 데이터
        ├─ [Opt1] 휠스피드
        └─ [Opt2] 온도/압력

스케일링 복원 예시

gps_x = raw_int32 * 1e-7          # int32 → 경도 (도)
gps_z = raw_int32 * 0.001         # int32 mm → 미터
speed = raw_uint16 * 0.01         # uint16 → km/h
lateral_g = raw_int16 * 0.001     # int16 → G
throttle = raw_uint16 * 0.1       # uint16 → %
brake_pressure = raw_uint16 * 0.1 # uint16 → bar
battery = raw_uint16 * 0.01       # uint16 → V

세션 캐시 (2단계)

단계 저장소 TTL 용도
L1 인메모리 (워커 내) 30초 Redis 조회 최소화
L2 Redis / 메모리 스토어 1시간 세션 원본 저장

5Hz 수신 시 Redis 조회는 로거당 30초에 1회로 제한됩니다.

주요 메서드

메서드 설명
start 워커 스레드 시작
stop 워커 중지 (join 5초)
running? 실행 상태 확인
last_processed_at 마지막 패킷 처리 시각
total_processed 총 처리 패킷 수
stats 통계 해시 반환

세션 handshake 시점에 서킷이 매칭되지 않아도 UDP sample 좌표가 활성 서킷의 지오펜스 안으로 들어오면 TelemetryWorker가 circuit을 늦게 확정한다. 이 경우 같은 live run/source/session cache에 circuit_id를 반영한 뒤 해당 서킷 snapshot에 차량 위치를 표시한다. TelemetryLiveStore.writesource_public_uid, run_public_uid, circuit_id가 모두 있어야 저장하므로 circuit이 아직 없으면 live snapshot에는 표시하지 않는다.


5. TelemetryLiveStore와 live 조회 API

TelemetryLiveStore는 UDP live packet에서 파생된 휘발성 표시/리뷰 버퍼다. Redis를 우선 사용하고, Redis가 없거나 test 환경이면 메모리 store로 동작한다.

저장 정책

항목 기본값 환경 변수 설명
source latest TTL 2분 TELEMETRY_LIVE_TTL_SECONDS live 차량으로 간주하는 최신 샘플 유효 시간
run history TTL 1시간 TELEMETRY_LIVE_HISTORY_TTL_SECONDS 선택 run samples history 보관 시간
run 최대 샘플 수 18,000 TELEMETRY_LIVE_MAX_SAMPLES_PER_RUN 5Hz 기준 약 60분

Redis 사용 시 key는 telemetry_live: prefix 아래에 저장된다.

Key 용도
telemetry_live:source:<source_public_uid> source별 latest sample, TTL 2분
telemetry_live:run_samples:<run_public_uid> run별 ordered sample list, TTL 1시간
telemetry_live:circuit_runs:<circuit_id> circuit별 live run set, TTL 1시간

조회 API

Endpoint 동작
GET /api/v1/live_telemetry/snapshot circuit별 차량 latest point 조회. live store 우선, 같은 run은 DB fallback에서 제외. 활성 replay 차량도 합산
GET /api/v1/live_telemetry/sources/:source_public_uid/latest source latest sample 조회. live store가 없으면 최근 telemetry_logs fallback
GET /api/v1/live_telemetry/runs/:run_public_uid/samples 선택 run samples 조회. replay store → live store → persisted logs 순서

samples 응답은 데이터 출처를 sample_source로 명시한다.

  • live_store: UDP live packet에서 온 휘발성 sample
  • persisted_logs: 업로드/import 등으로 DB에 저장된 telemetry_logs
  • replay: TelemetryReplayStore가 만든 time-shift replay sample

samples 응답은 server_time, sample_source, query_mode, truncated, run, private_visible, samples를 반환한다. query_mode는 클라이언트 값이 있으면 echo하고, 없으면 to만 있는 요청을 time_machine_snapshot, 그 외를 live_append로 분류한다. 기본 sample limit은 1,000개이고 최대값은 5,000개다.

snapshot API는 live mode에서 기본 30초 window를 사용하고, time-machine 요청(at)에서는 기본 5초 window를 사용한다. live mode의 DB fallback은 source_modelive 또는 replay인 최근 로그만 대상으로 하므로, upload 완료 후 uploaded_log로 전환된 row는 live snapshot 목록에서 빠질 수 있다.

Live 화면과 review panel

/live_telemetryapp/views/live_telemetry/index.html.erb, app/assets/javascripts/live_telemetry.js, app/assets/stylesheets/live_telemetry.css로 구성된다.

  • page root는 data-snapshot-url, data-samples-url-template, data-circuit를 내려준다.
  • live map snapshot polling 주기는 SNAPSHOT_POLL_INTERVAL_MS = 200이다.
  • time-machine slider는 at=<ISO8601>window_seconds=5를 snapshot API에 붙인다. live mode는 window_seconds=30을 쓴다.
  • 차량을 선택하면 전체 circuit map에서 선택 차량 중심의 birdseye map으로 전환한다.
  • private telemetry 권한이 없는 source는 위치 중심 정보만 표시하고, dashboard/review 상세 telemetry는 제한한다.

live review panel은 정식 session review UI를 재사용한다.

  • 공통 partial: app/views/telemetry_runs/_session_viewer.html.erb
  • 정식 review page: app/views/telemetry_runs/review.html.erb
  • live review panel: app/views/live_telemetry/index.html.erb
  • 공통 JS API: window.TelemetrySessionViewer

session_review.jswindow.TelemetrySessionViewer.createbuildSessionModel을 공개한다. create가 반환하는 viewer instance는 setAnalysis, setLivePlayback, seek, draw, destroy를 제공한다.

차량 선택 review 흐름:

  1. dashboard의 Review 버튼 또는 map/list 선택으로 openSnapshotReviewForSelectedVehicle를 호출한다.
  2. 선택 차량이 없거나 private telemetry 권한이 없거나 run이 없으면 review panel을 닫는다.
  3. 선택 sample의 gps_time 또는 received_atreviewSnapshotAt으로 잡는다.
  4. /api/v1/live_telemetry/runs/:run_public_uid/samples?limit=5000&to=<reviewSnapshotAt>&query_mode=time_machine_snapshot을 한 번 호출한다.
  5. 응답 samples를 시간순으로 정렬하고, synthetic lap Snapshot 하나를 가진 viewer analysis로 변환한다.
  6. TelemetrySessionViewer.create(...).setAnalysis(...) 경로로 track, graph, timeline을 렌더링한다.
  7. review panel에는 sample_source, truncated, highres_upload_status를 상태 텍스트로 표시한다.
  8. run id가 있으면 “전체 리뷰” 링크는 /driving_sessions/:id/review를 가리킨다.

현재 구현은 선택 시점까지의 정적 snapshot review다. state.samplePollTimer는 선언되어 있지만 선택 차량 samples를 지속 polling하지 않는다. 선택 차량의 incremental live append, latest-edge playback, paused review/live playing 전환은 아직 구현되어 있지 않다.

업로드 완료 후 fallback

고해상도 업로드가 성공하면 TelemetryUploadsControllerTelemetryLiveStore.clear_run!을 호출한다. 이 시점부터 같은 run의 samples API는 live store 대신 telemetry_logs를 반환한다. 따라서 리뷰 데이터의 authoritative source는 업로드 로그로 전환되고, UDP live buffer는 정식 세션 히스토리로 취급하지 않는다.

snapshot refresh에서 선택 source가 목록에서 사라져도 state.reviewRunUid가 있으면 review panel은 즉시 닫지 않는다. 이 상태에서는 선택 run 기준 samples API를 계속 사용할 수 있으므로, upload 후 live store가 비워져도 persisted log fallback으로 같은 panel을 유지할 수 있다.


6. ActiveLoggerTracker (lib/active_logger_tracker.rb)

현재 데이터를 전송 중인 활성 로거를 실시간 추적하는 컴포넌트.

  • 활성 기준: 마지막 활동으로부터 60초 이내
  • Mutex로 스레드 안전성 보장
  • 조회 시 비활성 로거 자동 정리 (lazy cleanup)

주요 메서드

메서드 설명
update_logger_activity(uid) 로거 활동 시각 갱신
active_logger_count 활성 로거 수
active_loggers 활성 로거 UID 목록
logger_active?(uid) 특정 로거 활성 여부
all_loggers_info 전체 로거 상세 정보
stats 통계 해시
reset 전체 데이터 초기화

7. TelemetryLog 모델 (app/models/telemetry_log.rb)

TelemetryLog는 업로드된 MF4/CSV/VBO/XRZ/XRK/HRZ/RAW 같은 정식 세션 히스토리와 기존 관리 화면을 위한 영속 모델이다. UDP live packet은 기본적으로 이 테이블에 저장하지 않고 TelemetryLiveStore에만 보관한다.

관계

belongs_to :user

필드 목록

기본 필드 (상시):

필드 타입 설명
user_id integer 사용자 FK
logger_uid string 로거 문자열 ID
logger_type integer 장비 타입 (1~255)
gps_time datetime GPS 수신 시각 (UTC, μs)
gps_signal_strength integer GPS 신호 강도 (0~15)
gps_satellites integer 수신 위성 수
gps_x decimal 경도 (도)
gps_y decimal 위도 (도)
gps_z decimal 고도 (m)
heading decimal 방위각 (도)
speed decimal 속도 (km/h)
lateral_g decimal 횡방향 G-Force
longitudinal_g decimal 종방향 G-Force
yaw_rate decimal 요 각속도 (°/s)
rpm integer 엔진 회전수
throttle_pedal decimal 스로틀 페달 포지션 (%)
throttle_opening decimal 스로틀 실제 개도량 (%)
brake_pedal decimal 브레이크 페달 포지션 (%)
brake_pressure decimal 브레이크 실제 압력 (bar)
steering_angle decimal 조향각 (도)
gear integer 기어 단수 (-1=R, 0=N, 1~10)
battery_voltage decimal 차량 전압 (V)
vehicle_flags integer 차량 플래그 (ABS, TCS 등)
sequence_number integer 패킷 일련번호
source_ip string 송신 IP
received_at datetime 서버 수신 시각

옵션 필드 (선택적):

필드 타입 설명
wheel_speed_fl decimal 앞좌 휠스피드 (km/h)
wheel_speed_fr decimal 앞우 휠스피드 (km/h)
wheel_speed_rl decimal 뒤좌 휠스피드 (km/h)
wheel_speed_rr decimal 뒤우 휠스피드 (km/h)
oil_temp decimal 유온 (°C)
oil_pressure decimal 유압 (bar)
water_temp decimal 수온 (°C)
ambient_temp decimal 외기온 (°C)

스코프

스코프 설명
by_logger(uid) 특정 로거의 로그
by_user(user_id) 특정 사용자의 로그
recent(limit) 최근 로그 (기본 100건)
time_range(start, end) GPS 시간 범위
received_time_range(start, end) 수신 시간 범위
good_gps_signal(min) 신호 강도 min 이상 (기본 4)
weak_gps_signal(max) 신호 강도 max 이하 (기본 3)

헬퍼 메서드

메서드 반환 설명
gps_coordinates [x, y, z] GPS 좌표 배열
gps_signal_quality String 신호 품질 텍스트
gps_signal_good? Boolean 신호 강도 4 이상
abs_active? Boolean ABS 작동 여부
tcs_active? Boolean TCS 작동 여부
speed_ms Float m/s 변환
speed_mph Float mph 변환
transmission_delay Float GPS↔수신 지연 (초)
summary Hash 전체 데이터 요약

8. 서버 초기화 (config/initializers/udp_listener_initializer.rb)

Rails 서버 모드에서만 UdpListenerTelemetryWorker를 자동 시작.

서버 모드 판별

  • Rails::Server 정의됨
  • development + rails server 명령
  • production + rake/console/runner가 아닌 프로세스

시작/종료 순서

시작: Rails after_initialize → UdpListener.start → TelemetryWorker.start
종료: at_exit → TelemetryWorker.stop → UdpListener.stop

9. 데이터 흐름

0. [사전] 로거가 source credential로 세션 발급 (HTTPS)
   └─ POST /api/v1/telemetry/session
      → session_id + session_key + upload_token
      → source_public_uid + run_public_uid
       │
1. 로거 장비가 HMAC 서명된 바이너리 패킷 전송 (UDP 포트 8677)
       │
2. UdpListener 수신 → packet_queue에 push
       │
3. TelemetryWorker가 queue에서 pop (1초 타임아웃)
       │
4. 크기 검사 (79B~1200B)
   └─ 실패 → drop
       │
5. CRC8 검증
   └─ 실패 → drop (쓰레기 패킷 빠르게 제거)
       │
6. Magic bytes 검증 (0xBB 0x42)
   └─ 실패 → drop
       │
7. Header 언팩 → session_id 추출
       │
8. 세션 조회 (L1 캐시 → L2 Redis/메모리)
   └─ 미등록 session_id → drop
       │
9. HMAC-SHA256 검증 (세션 키 기반)
   └─ 실패 → drop (변조/스푸핑 차단)
       │
10. AES-128-CTR 복호화 (세션 키 + Nonce)
   └─ Nonce = [session_id 4B][sequence 4B][version 1B][type 1B][0x00×6]
       │
11. Sample × N 바이너리 언팩 (스케일링 복원)
       │
12. circuit_id가 없으면 run/circuit 또는 sample geofence로 늦게 매칭
       │
13. TelemetryLiveStore 최신 상태/ring buffer 갱신
       │
14. TelemetryRun/TelemetrySource 활동 시간과 샘플 카운트 갱신
       │
15. ActiveLoggerTracker에 활동 기록
       │
16. live UI가 /api/v1/live_telemetry/snapshot을 200ms마다 polling
       │
17. 차량 선택 시 /runs/:run_public_uid/samples로 선택 시점 snapshot review 로드
       │
18. logger가 POST /api/v1/telemetry/uploads로 고해상도 원본 로그 업로드
       │
19. TelemetryLogImporter가 DB에 저장하고 TelemetryLiveStore.clear_run! 호출
       │
20. 이후 같은 run의 samples API는 persisted_logs fallback 반환

10. 보안

다층 방어 체계

계층 메커니즘 방어 대상
1 CRC-8 전송 오류, 쓰레기 패킷
2 Magic bytes 비-텔레메트리 UDP 패킷
3 세션 인증 미인증 장비 (session_id 미등록)
4 AES-128-CTR 페이로드 스니핑 (기밀성)
5 HMAC-SHA256 패킷 변조, 스푸핑, 리플레이
6 Sequence 패킷 유실 감지

AES-128-CTR 페이로드 암호화

  • 알고리즘: AES-128-CTR (패딩 불필요, 패킷 크기 증가 0바이트)
  • : 세션 생성 시 발급된 16바이트 세션 키 (HMAC과 공용)
  • Nonce: [session_id 4B LE][sequence 4B LE][version 1B][packet_type 1B][0x00 × 6] (패킷에 포함 안됨)
  • 암호화 대상: Samples + Options (Header, Session은 평문 유지)
  • 보안 패턴: Encrypt-then-MAC (암호화 → HMAC 서명 → CRC)
  • ESP32 호환: 하드웨어 AES 가속 지원으로 부하 거의 없음

HMAC 기반 무결성 보장

  • 알고리즘: HMAC-SHA256, 16바이트 truncation
  • : 세션 생성 시 발급된 16바이트 랜덤 키 (세션당 고유)
  • 서명 대상: Header + Session + 암호화된 Payload (HMAC/CRC 제외)
  • 비교 방식: 타이밍 공격 방지 constant-time 비교 (secure_compare)

세션 키 생명주기

1. 로거가 source credential UID/secret을 보유
2. 세션 요청 payload를 canonical JSON으로 정렬하고 HMAC-SHA256 signature 생성
3. 서버가 credential, timestamp, nonce, signature를 검증
4. 서버가 16바이트 session_key와 upload_token 발급
5. session_key와 upload_token digest는 Redis/메모리에 1시간 TTL로 저장
6. 만료 전 로거가 새 세션 발급 요청
7. 키 유출 시 영향 범위: 해당 세션 1시간 이내

legacy JWT flow는 credential_uid가 없는 요청에만 사용한다.

스푸핑/스니핑 방어

  • 스푸핑: session_id를 알아도 session_key 없이는 유효한 HMAC 생성 불가
  • 스니핑: AES-128-CTR로 페이로드 암호화 → 평문 데이터 노출 불가
  • 키 교환: 세션 키는 HTTPS로만 전달, UDP 패킷에 포함되지 않음
  • 리플레이: 세션별 uint32 sequence 단조 증가 검증으로 중복/과거 패킷 drop
  • 키 탈취 시: 최대 1시간 TTL로 피해 범위 제한

11. 테스트용 패킷 전송 예시

1단계: 세션 발급

# Source credential 요청 예시.
# signature는 signature 필드를 제외한 payload의 canonical JSON을
# source_secret으로 HMAC-SHA256 서명한 뒤 Base64 인코딩한 값이다.

curl -X POST http://localhost:3000/api/v1/telemetry/session \
  -H "Content-Type: application/json" \
  -d '{
    "credential_uid": "cred_...",
    "timestamp": "2026-06-04T12:00:00Z",
    "nonce": "unique-nonce",
    "signature": "base64-hmac-signature",
    "source_type": "logger",
    "preferred_circuit_id": 12
  }'

# 응답 예시:
# {
#   "success": true,
#   "session_id": 3847291056,
#   "session_key": "dGVzdF9rZXlfMTZieXRlcw==",  (Base64)
#   "upload_token": "urlsafe-token",
#   "expires_in": 3600,
#   "source_public_uid": "src_...",
#   "run_public_uid": "run_...",
#   "circuit": { "id": 12, "name": "Inje Speedium" }
# }

legacy JWT 테스트가 필요한 경우 credential_uid 없이 Authorization: Bearer <JWT>를 보내면 기존 fallback flow로 세션을 만들 수 있다.

2단계: UDP 패킷 전송

Ruby

require 'socket'
require 'openssl'
require 'base64'

# --- 세션 정보 (API 응답에서 받은 값) ---
SESSION_ID = 3847291056
SESSION_KEY = Base64.decode64("dGVzdF9rZXlfMTZieXRlcw==")

# CRC-8 계산 (polynomial 0x07)
def crc8(data)
  crc = 0x00
  data.each_byte do |byte|
    crc ^= byte
    8.times do
      crc = (crc & 0x80) != 0 ? ((crc << 1) ^ 0x07) & 0xFF : (crc << 1) & 0xFF
    end
  end
  crc
end

# 패킷 조립
sequence = 1

header = [
  # Header (10B)
  0xBB, 0x42,       # magic
  0x21,             # ver_type (v2, type=telemetry)
  0x00,             # flags (옵션 없음)
  sequence,         # sequence (uint32)
  1,                # sample_count
  1,                # logger_type
].pack('CCCCVCC')

# Session (4B)
session = [SESSION_ID].pack('V')

# Sample (48B) — 평문 페이로드
gps_time_us = (Time.now.to_f * 1_000_000).to_i
payload = [
  gps_time_us,      # gps_time (uint64)
  8,                # gps_signal_strength
  12,               # gps_satellites
  (126.9779692 * 1e7).to_i,   # gps_x (int32)
  (37.5662952 * 1e7).to_i,    # gps_y (int32)
  (123.45 * 1000).to_i,       # gps_z in mm (int32)
  (45.12 * 100).to_i,         # heading (uint16)
  (87.25 * 100).to_i,         # speed (uint16)
  (-1.25 * 1000).to_i,        # lateral_g (int16)
  (0.85 * 1000).to_i,         # longitudinal_g (int16)
  (15.75 * 100).to_i,         # yaw_rate (int16)
  6500,             # rpm
  (75.0 * 10).to_i, # throttle_pedal (uint16)
  (72.5 * 10).to_i, # throttle_opening (uint16)
  (30.0 * 10).to_i, # brake_pedal (uint16)
  (45.5 * 10).to_i, # brake_pressure (uint16)
  (-15.5 * 10).to_i, # steering_angle (int16)
  3,                # gear (int8)
  (13.8 * 100).to_i, # battery_voltage (uint16)
  0x01,             # vehicle_flags (ABS active)
].pack('Q<CCl<l<l<vvs<s<s<vvvvvs<cvC')

# AES-128-CTR 암호화
nonce = [SESSION_ID, sequence, 2, 1].pack('VVCC') + ("\x00" * 6)  # 16B nonce
cipher = OpenSSL::Cipher::AES.new(128, :CTR)
cipher.encrypt
cipher.key = SESSION_KEY
cipher.iv = nonce
encrypted_payload = cipher.update(payload) + cipher.final

# 패킷 조립 (Header + Session + 암호화된 페이로드)
packet = header + session + encrypted_payload

# HMAC-SHA256 (16B truncated) — Encrypt-then-MAC
hmac = OpenSSL::HMAC.digest('SHA256', SESSION_KEY, packet)[0, 16]
packet += hmac

# CRC8 (HMAC 포함한 전체에 대해 계산)
packet += [crc8(packet)].pack('C')

# 전송
sock = UDPSocket.new
sock.send(packet, 0, 'localhost', 8677)
sock.close
puts "Sent #{packet.bytesize} bytes (79B = 10 header + 4 session + 48 sample + 16 hmac + 1 crc)"

Python

import socket
import struct
import time
import hmac
import hashlib
import base64
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

# --- 세션 정보 (API 응답에서 받은 값) ---
SESSION_ID = 3847291056
SESSION_KEY = base64.b64decode("dGVzdF9rZXlfMTZieXRlcw==")

def crc8(data):
    crc = 0x00
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 0x80:
                crc = ((crc << 1) ^ 0x07) & 0xFF
            else:
                crc = (crc << 1) & 0xFF
    return crc

sequence = 1

# Header (10B)
header = struct.pack('<BBBBIBB',
    0xBB, 0x42,    # magic
    0x21,          # ver_type (v2, telemetry)
    0x00,          # flags
    sequence,      # sequence uint32
    1,             # sample_count
    1,             # logger_type
)

# Session (4B)
session = struct.pack('<I', SESSION_ID)

# Sample (48B) — 평문 페이로드
gps_time_us = int(time.time() * 1_000_000)
payload = struct.pack('<QBBiiiHHhhhHHHHHhbHB',
    gps_time_us,                    # gps_time
    8,                              # gps_signal_strength
    12,                             # gps_satellites
    int(126.9779692 * 1e7),         # gps_x
    int(37.5662952 * 1e7),          # gps_y
    int(123.45 * 1000),             # gps_z (mm)
    int(45.12 * 100),               # heading
    int(87.25 * 100),               # speed
    int(-1.25 * 1000),              # lateral_g
    int(0.85 * 1000),               # longitudinal_g
    int(15.75 * 100),               # yaw_rate
    6500,                           # rpm
    int(75.0 * 10),                 # throttle_pedal
    int(72.5 * 10),                 # throttle_opening
    int(30.0 * 10),                 # brake_pedal
    int(45.5 * 10),                 # brake_pressure
    int(-15.5 * 10),                # steering_angle
    3,                              # gear
    int(13.8 * 100),                # battery_voltage
    0x01,                           # vehicle_flags (ABS)
)

# AES-128-CTR 암호화
nonce = struct.pack('<IIBB', SESSION_ID, sequence, 2, 1) + b'\x00' * 6  # 16B nonce
cipher = Cipher(algorithms.AES(SESSION_KEY), modes.CTR(nonce))
encryptor = cipher.encryptor()
encrypted_payload = encryptor.update(payload) + encryptor.finalize()

# 패킷 조립 (Header + Session + 암호화된 페이로드)
packet = header + session + encrypted_payload

# HMAC-SHA256 (16B truncated) — Encrypt-then-MAC
mac = hmac.new(SESSION_KEY, packet, hashlib.sha256).digest()[:16]
packet += mac

# CRC8 (HMAC 포함)
packet += bytes([crc8(packet)])

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(packet, ('localhost', 8677))
sock.close()
print(f'Sent {len(packet)} bytes (79B = 10 header + 4 session + 48 sample + 16 hmac + 1 crc)')

ESP32 (C/Arduino)

#include <WiFiUdp.h>
#include <mbedtls/md.h>
#include <mbedtls/aes.h>

// 세션 정보 (API 응답에서 받은 값)
uint32_t session_id = 3847291056;
uint32_t sequence = 0;
uint8_t session_key[16]; // Base64 디코딩된 키

uint8_t crc8(const uint8_t* data, size_t len) {
    uint8_t crc = 0x00;
    for (size_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (int j = 0; j < 8; j++) {
            crc = (crc & 0x80) ? ((crc << 1) ^ 0x07) : (crc << 1);
        }
    }
    return crc;
}

void hmac_sha256_truncated(const uint8_t* key, size_t key_len,
                           const uint8_t* data, size_t data_len,
                           uint8_t* out16) {
    uint8_t full_hmac[32];
    mbedtls_md_context_t ctx;
    mbedtls_md_init(&ctx);
    mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 1);
    mbedtls_md_hmac_starts(&ctx, key, key_len);
    mbedtls_md_hmac_update(&ctx, data, data_len);
    mbedtls_md_hmac_finish(&ctx, full_hmac);
    mbedtls_md_free(&ctx);
    memcpy(out16, full_hmac, 16);  // 16바이트 truncation
}

// AES-128-CTR 암호화 (ESP32 하드웨어 가속)
void aes128_ctr_encrypt(const uint8_t* key, const uint8_t* nonce,
                        const uint8_t* input, uint8_t* output, size_t len) {
    mbedtls_aes_context aes;
    mbedtls_aes_init(&aes);
    mbedtls_aes_setkey_enc(&aes, key, 128);

    uint8_t nonce_counter[16];
    uint8_t stream_block[16];
    size_t nc_off = 0;
    memcpy(nonce_counter, nonce, 16);
    memset(stream_block, 0, 16);

    mbedtls_aes_crypt_ctr(&aes, len, &nc_off, nonce_counter, stream_block, input, output);
    mbedtls_aes_free(&aes);
}

void send_telemetry(WiFiUDP& udp, const char* host, uint16_t port) {
    uint8_t packet[79];  // 코어 패킷 크기
    uint8_t payload[48]; // 평문 페이로드 (Sample 1개)
    size_t offset = 0;

    // Header (10B)
    packet[offset++] = 0xBB;
    packet[offset++] = 0x42;
    packet[offset++] = 0x21;  // ver_type
    packet[offset++] = 0x00;  // flags
    memcpy(&packet[4], &sequence, 4);  // sequence (LE)
    packet[8] = 1;  // sample_count
    packet[9] = 1;  // logger_type

    // Session (4B)
    memcpy(&packet[10], &session_id, 4);

    // Sample (48B) — GPS, motion, vehicle 데이터를 payload에 채우기
    // (구체적 필드 패킹은 실제 센서 데이터에 따라 구현)

    // Nonce 생성: [session_id 4B LE][sequence 4B LE][version 1B][type 1B][0x00 × 6]
    uint8_t nonce[16] = {0};
    memcpy(&nonce[0], &session_id, 4);
    memcpy(&nonce[4], &sequence, 4);
    nonce[8] = 2;
    nonce[9] = 1;

    // AES-128-CTR 암호화 (페이로드만)
    aes128_ctr_encrypt(session_key, nonce, payload, &packet[14], 48);

    size_t hmac_offset = 62;  // 10 + 4 + 48

    // HMAC (16B) — Encrypt-then-MAC
    hmac_sha256_truncated(session_key, 16, packet, hmac_offset, &packet[hmac_offset]);

    // CRC8 (1B)
    packet[78] = crc8(packet, 78);

    udp.beginPacket(host, port);
    udp.write(packet, 79);
    udp.endPacket();

    sequence++;
}