RaceMatrix UDP Telemetry Protocol and Live Telemetry System
라이브 텔레메트리 수집 시스템
차량 텔레메트리 데이터를 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 큐에 저장하는 컴포넌트.
동작 방식
0.0.0.0:8677에 UDP 소켓 바인드- 별도 스레드에서
IO.select(1초 타임아웃) +recvfrom_nonblock으로 패킷 수신 - 수신된 패킷을
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_token을 session_id와 함께 전송한다.
TelemetrySession.upload_token_valid?로 토큰을 검증한다.- 세션에 연결된
TelemetrySource와TelemetryRun을 찾는다. TelemetryLogImporter로 업로드 파일을telemetry_logs와 해당 run에 반영한다.TelemetryLiveStore.clear_run!으로 같은 run의 휘발 live samples를 제거한다.- 이후 samples API는 같은 run의
telemetry_logsfallback을 반환한다.
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를 저장하는 워커.
동작 방식
- 별도 스레드에서
UdpListener.pop_packet_nonblock(1.0)으로 패킷 폴링 - 크기 검사 → CRC8 검증 → Magic bytes 검증 (저비용 검사 우선)
- Header 언팩 → session_id 추출
- 세션 조회 (L1 캐시 → L2 Redis/메모리) → user/source/run/circuit IDs, public UIDs, session_key 획득
- HMAC-SHA256 검증 (세션 키 기반, 타이밍 공격 방지 비교)
- AES-128-CTR 복호화 (세션 키 + Nonce로 페이로드 복원)
- Sample(s) 바이너리 언팩 (스케일링 복원)
- 세션에 circuit_id가 없고 sample 좌표가 서킷 지오펜스 안이면 circuit을 늦게 매칭해 run/source/session cache에 반영
TelemetryLiveStore에 최신 상태와 run별 ring buffer 저장TelemetryRun/TelemetrySource활동 시간과 sample count 갱신- 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.write는 source_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에서 온 휘발성 samplepersisted_logs: 업로드/import 등으로 DB에 저장된telemetry_logsreplay: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_mode가 live 또는 replay인 최근 로그만 대상으로 하므로, upload 완료 후 uploaded_log로 전환된 row는 live snapshot 목록에서 빠질 수 있다.
Live 화면과 review panel
/live_telemetry는 app/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.js는 window.TelemetrySessionViewer.create와 buildSessionModel을 공개한다. create가 반환하는 viewer instance는 setAnalysis, setLivePlayback, seek, draw, destroy를 제공한다.
차량 선택 review 흐름:
- dashboard의 Review 버튼 또는 map/list 선택으로
openSnapshotReviewForSelectedVehicle를 호출한다. - 선택 차량이 없거나 private telemetry 권한이 없거나 run이 없으면 review panel을 닫는다.
- 선택 sample의
gps_time또는received_at을reviewSnapshotAt으로 잡는다. /api/v1/live_telemetry/runs/:run_public_uid/samples?limit=5000&to=<reviewSnapshotAt>&query_mode=time_machine_snapshot을 한 번 호출한다.- 응답 samples를 시간순으로 정렬하고, synthetic lap
Snapshot하나를 가진 viewer analysis로 변환한다. TelemetrySessionViewer.create(...).setAnalysis(...)경로로 track, graph, timeline을 렌더링한다.- review panel에는
sample_source,truncated,highres_upload_status를 상태 텍스트로 표시한다. - run id가 있으면 “전체 리뷰” 링크는
/driving_sessions/:id/review를 가리킨다.
현재 구현은 선택 시점까지의 정적 snapshot review다. state.samplePollTimer는 선언되어 있지만 선택 차량 samples를 지속 polling하지 않는다. 선택 차량의 incremental live append, latest-edge playback, paused review/live playing 전환은 아직 구현되어 있지 않다.
업로드 완료 후 fallback
고해상도 업로드가 성공하면 TelemetryUploadsController가 TelemetryLiveStore.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 서버 모드에서만 UdpListener와 TelemetryWorker를 자동 시작.
서버 모드 판별
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++;
}