R&D · Thiết bị chấm điểm · Protocol · 14/10/2026 · bản thảo v0.1

Protocol tín hiệu xe ↔ máy chủ chấm điểm MỚI

Định nghĩa (1) bộ tín hiệu trên xe (IO thân xe + OBD) cần để chấm các bài sa hình và (2) giao thức giữa bo thu trên xe và máy chủ chấm điểm. Phạm vi đợt này chỉ là tín hiệu xe. RTK/vị trí làm sau, schema đã giữ chỗ để cắm vào mà không đổi protocol. Đây là việc "format sự kiện chuẩn nội bộ" mà Thu tín hiệu xe §10 và Kiến trúc thiết bị §7 đánh dấu 🔴 chốt sớm, vì nếu phải sửa sau khi đã giao thiết bị thì phải cập nhật firmware từng xe ngoài sân. Phần cứng bo thu: Thu tín hiệu xe. Bản test nhanh JSON/HTTP: API test tín hiệu xe.

§

Mục lục

0

Kết luận nhanh

✅ Nguyên tắc nền

  • Thiết bị gửi số đo, không gửi phán quyết. Chết máy, số đang gài, xi-nhan đủ lâu chưa, quá giờ… đều do engine tính, nên sửa ngưỡng không phải OTA firmware
  • Nghĩa logic chuẩn hoá ở bo thu: bit = 1 luôn là "đang tác động", engine không cần biết dây đấu high/low-side
  • Mọi bản ghi mang giờ lấy mẫu (µs), không bao giờ dùng giờ nhận

🔗 Giao thức

  • Protobuf, dùng chung một schema cho firmware (nanopb), gateway và server
  • Trong xe: UART + COBS + CRC-32. Xe ↔ máy chủ: MQTT 5 / TLS 1.3 mTLS, broker đặt tại sân
  • IO gửi theo cạnh (edge) chính xác ms; OBD 10–20Hz; snapshot 1Hz; gom lô 100ms

🛡️ Bằng chứng

  • seq liên tục + journal + backfill: mất mạng chỉ làm dữ liệu về muộn, không mất
  • Hash chain + checkpoint ký 1Hz bằng khoá trong chip bảo mật; Seal cuối phiên
  • Phiên mang cfg_hash: chứng minh xe chạy đúng cấu hình đã kiểm chuẩn
⏸️ Để sau — RTK/vị trí. Các phần cần vị trí (đè vạch, dừng đúng vạch, vào chuồng, geofence vào/ra bài) chưa chấm được ở đợt này. Schema đã giữ sẵn số field cho Pose (§10) và Batch đã có chỗ chứa, nên khi thêm RTK chỉ là bổ sung, không đổi protocol. Trong lúc chưa có RTK, tốc độ tạm lấy từ OBD (PID 0x0D, §3.3) và việc vào/ra bài được đánh dấu bằng tay từ phòng điều hành (§7).
1

Topology

┌─ XE ─────────────────────────────────────────────────────┐ │ dây xe (phanh, côn, phanh tay, xi-nhan, dây an toàn, │ │ cửa, số lùi, khoá điện) ──opto──┐ │ │ cổng OBD-II (RPM, tốc độ) ──CAN/K-line──┤ │ │ ▼ │ │ [BO THU · STM32F401] debounce · đảo cực · lọc xi-nhan │ │ gắn t_us · seq · hash chain · ký (ATECC608B) │ │ │ L0: UART 921600 · COBS + CRC-32 │ │ ▼ │ │ [GATEWAY] journal ≥24h · gom lô 100ms · MQTT client │ │ loa trên xe · (sau này: cổng rover RTK) │ └─────────────┬────────────────────────────────────────────┘ │ L1: MQTT 5 / TLS 1.3 mTLS · Wi-Fi sân, 4G dự phòng ┌─ TRUNG TÂM (on-prem) ──▼──────────────────────────────────┐ │ [Broker] ──┬──▶ [Recorder] ghi thô, KHÔNG parse │ │ ├──▶ [Engine chấm] ──▶ lỗi/điểm ──▶ sh-yard │ │ └──▶ [Phòng điều hành] xem trạng thái xe │ └──────────────────────────────────────────────────────────┘
Thành phầnTrách nhiệm trong protocol
Bo thuĐiều hoà tín hiệu, gắn giờ tại nguồn, đánh seq, nối hash, ký checkpoint. Là nơi duy nhất giữ khoá ký, nên chữ ký chứng minh dữ liệu ra từ đúng con bo đó
GatewaySBC trong cabin. Không sửa nội dung bản ghi từ bo thu, chỉ ghi journal, gom lô, chuyển tiếp, phát loa. Sau này nhận thêm luồng RTK
Máy chủ (SS)On-prem tại trung tâm, kèm broker. Mở/đóng phiên, preflight, chấm, yêu cầu backfill, lưu journal bất biến
✅ Recorder tách khỏi engine: tiến trình ghi chỉ nhận byte rồi ghi file, không bao giờ chết. Engine có bug cũng không làm mất dữ liệu thí sinh, và luôn chạy lại được từ file.
2

Trục thời gian — không phụ thuộc RTK

Thiết kế cũ lấy PPS từ rover RTK. Vì RTK để sau, bo thu cần nguồn giờ riêng, và khi RTK lắp vào thì cả hai cùng dựa trên giờ GNSS nên tự khớp nhau.

Nguồn giờSai sốĐánh giá
Module GNSS timing rẻ trên bo (u-blox MAX-M10 / NEO-M9, ~15–25 USD) → PPS + giờ UTC< 1 µsKhuyến nghị. Độc lập mạng; cùng chuẩn giờ GNSS với RTK sau này. Thêm 1 ăng-ten nhỏ
NTP/PTP từ gateway ↔ máy chủ1–10 msDự phòng khi trong mái che mất GNSS. Đủ cho tín hiệu xe, không đủ khi ghép với vị trí RTK
RTC DS3231 (holdover)±2 ppmGiữ giờ khi mất cả hai nguồn: lệch ~0,1 ms/phút, chấp nhận được trong một lượt thi
3

Từ điển tín hiệu xe

Tên khoá trùng file config xe (Thu tín hiệu xe §9). Config lo phần dây đấu (pin, high/low-side, active); protocol chỉ mang nghĩa.

3.1 IO thân xe — bitmap io_bits (uint32)

BitKhoá1 nghĩa làGhi chú
0ignitionKhoá điện ONBài 1. Bo thu sống bằng nguồn thường trực, nên vẫn ghi được cạnh tắt máy
1seatbeltĐã cài dây an toànChú ý: dây công tắc gốc thường báo "chưa cài", bo phải đảo. Lỗi xuyên suốt khi 1→0
2doorCó cửa đang mởLow-side (tiếp mass) ở phần cứng, đã đảo về nghĩa chuẩn
3handbrakePhanh tay đang kéoBài 1, 3
4brakeĐang đạp phanh chânBài 2, 5, 8, tình huống nguy hiểm
5clutchĐang đạp cônXe số tự động: luôn 0 và clutch_present=false trong hồ sơ xe
6reverseĐang gài số lùiTừ công tắc đèn lùi. Bài 7, 10
7left_indXi-nhan trái đang nháyKênh xung, bo thu lọc: lên 1 ở xung đầu tiên, về 0 sau 1,0 s không có xung. Bo cũng ghi cạnh thô vào kênh phụ để đối chất
8right_indXi-nhan phải đang nháyNhư trên. Hazard = bit 7 & 8, engine suy ra
9–15dự phòng—Còi, pha, cốt, sương mù, đèn vị trí: để dành cho chấm đường trường. Đặt chỗ, không bao giờ đổi nghĩa bit cũ
16tamperHộp bo đã bị mởReed switch, chốt cứng tới khi kỹ thuật viên xác nhận
17obd_okECU đang trả lời OBD—
18–31dự phòng—Trạng thái thiết bị
✅ Lọc ở bo thu, phán quyết ở engine. Bo thu chỉ làm điều hoà tín hiệu: chống rung (20 ms, chỉnh trong config), đảo cực, nhận ra xi-nhan đang nháy. Mọi thứ có ngưỡng sát hạch đều nằm ở engine.

3.2 Động cơ & chuyển động — từ OBD-II

KhoáNguồn · đơn vịGhi chú
rpmPID 0x0C · vòng/phútPoll 10–20Hz. Chết máy, tính gear
speed_kmhPID 0x0D · km/hTạm thời, thay cho tốc độ RTK chưa có. Độ phân giải 1 km/h; đủ cho "dừng hẳn" và gear, không đủ đo tụt dốc cm (việc của RTK)
validboolfalse khi ECU không trả lời. Không được hiểu thành RPM = 0, để tránh phạt chết máy oan
⚠️ Giờ của mẫu OBD: OBD là hỏi-đáp, mất 20–50 ms. Bo ghi t_req_us (lúc hỏi) và t_us (lúc nhận); engine lấy điểm giữa. Không tap CKP/ABS (lý do ở Thu tín hiệu xe §5).

3.3 Engine tự suy ra (không có trên dây)

KhoáCách tính (tham số nằm ở hồ sơ xe / rules, phía server)
gearargmin |rpm/speed − k_i|; clutch=1 hoặc speed≈0 thì N/không xác định; reverse=1 thì R
stallignition=1 ∧ valid ∧ rpm < ngưỡng trong ≥ X ms (ngưỡng chờ QCVN)
hazardleft_ind ∧ right_ind, cạnh lên lệch nhau ≤ 300 ms
stoppedspeed_kmh = 0 liên tục ≥ X ms (sau này ưu tiên tốc độ RTK)
4

Map bài → tín hiệu xe

Chỉ phần chấm bằng tín hiệu xe. Cột cuối là phần chờ RTK, đợt này chưa làm.

#BàiTín hiệu xe — chấm được ngayChờ RTK
1Xuất phátignition · seatbelt · left_ind · handbrake · rpm · thời gian—
2Dừng nhường người đi bộbrake · stoppedDừng đúng vạch
3Đề-pa lên dốcstall · handbrake · clutch · brake · thời gianĐúng vạch, tụt dốc ≤ 50 cm
4Vệt bánh xe + vuông gócleft_ind / right_indBánh đúng vệt, đè vạch
5Ngã tư đèn tín hiệubrake · stopped (+ trạng thái đèn từ sân)Vạch dừng
6Chữ SstallĐè vạch
7Ghép xe dọcreverse · stall · thời gianVào chuồng, đè vạch
8Tạm dừng đường sắtbrake · stoppedĐúng vạch
9Thay đổi sốrpm · speed_kmh · clutch → gearĐúng đoạn đường
10Ghép xe ngangreverse · stallVào ô, đè vạch
11Kết thúcright_indQua vạch kết thúc
+Tình huống nguy hiểmt_exec của lệnh · brake · hazard · stopped—
↺Lỗi xuyên suốtstall · door · seatbelt · speed_kmh · thời gianĐè vạch
5

Mô hình lấy mẫu

Bản ghiKhi nàoLý do
IoEdgeMỗi lần một bit logic đổiThời điểm chính xác tới ms (bật xi-nhan trước khi xuất phát bao lâu, phanh sau lệnh bao lâu). Lấy mẫu định kỳ 20Hz sẽ sai tới 50 ms
ObdMỗi phản hồi, 10–20Hzrpm + speed trong cùng một bản ghi
State1HzToàn bộ io_bits + điện áp + clock. Để engine tự đồng bộ lại khi bắt đầu giữa chừng, và phát hiện bit kẹt
✅ Gateway gom thành một Batch mỗi 100ms, tức 10 bản tin/giây/xe: đủ nhanh để loa báo lỗi tức thời, đủ ít để chi phí header không đáng kể. Khi có RTK, Pose 20Hz vào cùng Batch này.
6

Vận chuyển

6.1 L0 — bo thu ↔ gateway

6.2 L1 — xe ↔ máy chủ (MQTT 5)

TopicQoSNội dung
ys/{yard}/v/{serial}/up/batch1Envelope{Batch}, 10/s
ys/{yard}/v/{serial}/up/ctl1SessionAck · CmdAck · Checkpoint · Seal
ys/{yard}/v/{serial}/up/health0Nhiệt độ, dung lượng journal, RSSI, firmware (10s)
ys/{yard}/v/{serial}/down1Down{SessionOpen · SessionClose · Backfill · Announce · HazardTrigger · LessonMark}
ys/{yard}/v/{serial}/status1 · retainonline / LWT offline

Vì sao MQTT

  • LWT báo xe mất kết nối, không phải tự viết heartbeat
  • Fan-out: recorder, engine, phòng điều hành cùng nhận; xe gửi 1 lần
  • Broker on-prem nhẹ (Mosquitto/EMQX): rớt internet vẫn thi được

Bảo mật

  • mTLS bằng chứng chỉ theo seri, do SUNS cấp qua CP (mô hình 3 tầng)
  • ACL: thiết bị chỉ chạm được nhánh v/{serial} của chính nó
  • Vẫn ký ở tầng ứng dụng (§9): TLS bảo vệ đường truyền, không tạo bằng chứng lưu trữ
7

Phiên thi

SS XE (bo thu qua gateway) │── SessionOpen{session_id, exam_id, ───────────────▶│ │ candidate_ref, license_class, │ preflight: clock≠FREE · tamper=0 │ vehicle_profile_ver, rules_ver} │ obd_ok · ignition đọc được │◀──────────── SessionAck{ok, fail[], fw_ver, ──────│ │ cfg_hash, first_seq} │ │ SS kiểm cfg_hash = bản đã kiểm chuẩn. │ │ Sai → không mở phiên │ │ │ │◀────────────────── Batch ×10/s · Checkpoint ×1/s ──│ │── LessonMark{lesson, enter|exit} ─────────────────▶│ tạm thời: phòng điều hành bấm │── Announce{text_key, lesson, points} ─────────────▶│ loa trên xe │── HazardTrigger{cmd_id} ──────────────────────────▶│ │◀──────────────────────── CmdAck{cmd_id, t_exec} ───│ t_exec = mốc tính phản ứng │ │ │── SessionClose ───────────────────────────────────▶│ │◀──── Seal{first_seq, last_seq, chain_head, sig} ───│ │ thiếu seq → Backfill{from,to} … đủ mới chốt │

LessonMark — tạm thay geofence

  • Chưa có RTK nên chưa tự biết xe đang ở bài nào. Vào/ra bài được đánh dấu từ phòng điều hành (hoặc nút trên xe khi chạy thử)
  • Bản tin đi xuống xe, được bo thu ACK và đưa vào hash chain, nên mốc bài nằm cùng trục giờ với tín hiệu
  • Khi có RTK, engine tự tính geofence và LessonMark chỉ còn dùng để chạy thử

Xe khởi động lại giữa bài

  • boot_id tăng, seq về 0; bo nhớ session_id đang mở trong flash
  • SS sắp theo (boot_id, seq), ghi khoảng trống là thiết bị gián đoạn, không lẫn với mất mạng
  • Xử lý thí sinh (thi lại bài/huỷ lượt) là quy tắc nghiệp vụ theo TT 108
8

Không mất dữ liệu

Cơ chế

  • seq uint32 tăng liên tục trong một boot_id, áp cho mọi Envelope
  • Gateway ghi journal trước, gửi sau (append-only, ≥ 24h; chỉ xoá vòng khi SS xác nhận phiên đã chốt)
  • SS thấy lỗ seq thì gửi Backfill{boot_id, from, to}, xe gửi lại với replay=true

Phân biệt nguyên nhân

  • Mất mạng: thiếu seq, backfill lấp đủ
  • Thiết bị gián đoạn: nhảy boot_id, hoặc journal cũng thiếu
  • ECU không trả lời: seq đủ, Obd.valid=false
⚠️ Quy tắc chốt điểm: SS không phát kết quả cuối khi chưa đủ seq từ first_seq tới Seal.last_seq. Mất mạng giữa bài chỉ làm loa trên xe tạm im; dữ liệu vẫn đủ, kết quả vẫn đúng.
9

Toàn vẹn & ký số

hash_n     = SHA-256( hash_{n-1} ‖ serial ‖ boot_id ‖ seq_n ‖ body_bytes_n )   // mỗi Envelope, tính trên bo thu
Checkpoint (1 Hz) = { seq, hash_seq, sig = ECDSA-P256(device_key, serial‖boot_id‖seq‖hash_seq) }
Seal (cuối phiên) = { session_id, first_seq, last_seq, chain_head, n_io, n_obd, sig }

device_key sinh và nằm trong ATECC608B trên bo thu, không bao giờ rời chip.
Public key + seri đăng ký lúc sản xuất → CP cấp xuống WS sát hạch.

Vì sao ký 1Hz, không ký từng lô

  • ECDSA trên ATECC ~50 ms/lần, ký 10 lô/s sẽ ngốn ½ thời gian chip
  • Hash chain nối mọi lô, nên một chữ ký ở checkpoint đã phủ toàn bộ lô trước nó

Bằng chứng cấu hình

  • SessionAck mang fw_ver + cfg_hash (hash của file config xe)
  • QCVN cấm tự ý đổi sau kiểm chuẩn, nên hash lệch thì không mở phiên
  • Bit tamper trong mọi State: mở hộp giữa phiên thì gắn cờ phiên
10

Schema Protobuf v1

syntax = "proto3";
package suns.ys.v1;

// ===== Uplink: xe → máy chủ =====
message Envelope {
  uint32 proto_ver = 1;          // = 1
  string serial    = 2;          // seri toàn cục do SUNS cấp
  uint32 boot_id   = 3;
  uint32 seq       = 4;
  bool   replay    = 5;          // gửi lại từ journal
  bytes  prev_hash = 6;
  oneof body {
    Batch      batch       = 10;
    SessionAck session_ack = 11;
    CmdAck     cmd_ack     = 12;
    Checkpoint checkpoint  = 13;
    Seal       seal        = 14;
    Health     health      = 15;
  }
}

message Batch {
  string session_id = 1;         // rỗng khi ngoài phiên (vẫn ghi, vẫn gửi)
  repeated IoEdge io   = 2;
  repeated Obd    obd  = 3;
  State           state  = 4;    // 1 lần/giây
  reserved 5;                   // repeated Pose pose — RTK, làm sau
}

enum Clock { CLOCK_FREE = 0; HOLDOVER = 1; NTP = 2; LOCKED = 3; }

message IoEdge {
  sint64 t_us  = 1;
  uint32 bit   = 2;              // vị trí bit trong io_bits
  bool   value = 3;
  bool   raw   = 4;              // true = cạnh thô kênh xung (xi-nhan), để đối chất
}
message Obd {
  sint64 t_us = 1; sint64 t_req_us = 2;
  uint32 rpm  = 3; uint32 speed_kmh = 4; bool valid = 5;
}
message State {
  sint64 t_us = 1; uint32 io_bits = 2; uint32 vbat_mv = 3; Clock clock = 4;
}

message SessionAck {
  string session_id = 1; bool ok = 2;
  repeated string fail = 3;    // "CLOCK","TAMPER","OBD","CFG"…
  string fw_ver = 4; bytes cfg_hash = 5; uint32 first_seq = 6;
  reserved 7;                   // base_id — RTK, làm sau
}
message CmdAck     { uint32 cmd_id = 1; bool ok = 2; sint64 t_exec_us = 3; }
message Checkpoint { uint32 seq = 1; bytes hash = 2; bytes sig = 3; }
message Seal {
  string session_id = 1; uint32 first_seq = 2; uint32 last_seq = 3;
  bytes chain_head = 4; uint32 n_io = 5; uint32 n_obd = 6; bytes sig = 7;
}
message Health { sint32 temp_c = 1; uint32 journal_free_mb = 2; sint32 rssi = 3; uint32 uptime_s = 4; }

// ===== Downlink: máy chủ → xe =====
message Down {
  uint32 proto_ver = 1; uint32 cmd_id = 2;
  oneof body {
    SessionOpen   open     = 10;
    SessionClose  close    = 11;
    Backfill      backfill = 12;
    Announce      announce = 13;
    HazardTrigger hazard   = 14;
    LessonMark    lesson   = 15;
  }
}
message SessionOpen {
  string session_id = 1; string exam_id = 2;
  string candidate_ref = 3;      // mã tham chiếu — KHÔNG gửi PII xuống xe
  string license_class = 4; string vehicle_profile_ver = 5; string rules_ver = 6;
  reserved 7, 8;                // datum_id, map_ver — RTK, làm sau
}
message SessionClose  { string session_id = 1; }
message Backfill      { uint32 boot_id = 1; uint32 from_seq = 2; uint32 to_seq = 3; }
message Announce      { string text_key = 1; uint32 lesson = 2; sint32 points = 3; }
message HazardTrigger { uint32 kind = 1; }
message LessonMark    { uint32 lesson = 1; bool enter = 2; }
Khi chốt, file này thành suns/ys/v1/*.proto, sinh code cho C (nanopb, bo thu), gateway và server từ cùng một nguồn. Các số reserved là chỗ cắm RTK.
11

Phiên bản & băng thông

Quy tắc tiến hoá

  • Trong v1: chỉ thêm field/bit; không đổi số field, không đổi nghĩa bit
  • Đổi không tương thích thì lên v2; SS đọc song song v1+v2 suốt vòng đời xe ngoài sân
  • Ngưỡng chấm, ratios gear, debounce: nằm ở rules_ver / vehicle_profile_ver, không phải OTA

Ước lượng (chưa có RTK)

  • Obd ~20 B × 20 = 400 B/s · edge + state ~80 B/s · header, hash, checkpoint ~400 B/s
  • ≈ 0,9 KB/s/xe; 12 xe ≈ 11 KB/s, Wi-Fi hay 4G đều thừa
  • Journal ≈ 32 MB/xe/ngày 10h. Thêm RTK sau thì khoảng gấp đôi
12

Đang chặn & việc mở

ViệcMứcGhi chú
Danh mục lỗi + ngưỡng (TT 108/2026): chết máy bao lâu, xi-nhan trước bao lâu, quá giờ🔴 ChặnẢnh hưởng rules, không ảnh hưởng protocol
OBD-II trên xe sát hạch VN (i10, Morning, Vios, Accent…) có trả PID 0x0C / 0x0D ở 10–20Hz không🔴 Đo sớmKhông có → dây tacho + 6N137; tốc độ phải chờ RTK
Chọn gateway (SBC) + module GNSS timing + chip ATECC608B⚠️Gateway cần UART, lưu trữ ≥ 32 GB, Wi-Fi/4G
Bộ text_key câu nói loa (theo câu chuẩn của trung tâm sát hạch)⚠️Lấy từ thiết bị hiện có ở sân
Quy tắc khi xe reboot giữa bài⚠️Theo TT 108
Bộ test tương thích: log mẫu → engine → kết quả kỳ vọng🟢Ghi log thật từ 1 xe, chạy thử với LessonMark
RTK: Pose, datum_id, geofence vào/ra bài, đè vạch⏸️ Để sauĐã giữ chỗ trong schema
Dựa trên: docs/RD/RTK/thu-tin-hieu-xe-thiet-ke.md · thiet-bi-rover-va-luong-du-lieu.md §8 · khung-phap-ly.md. Bản thảo v0.1, cần review cùng đội firmware trước khi chốt v1.