택시 운전석을 한번 세어 보자. 미터기, 배차 단말, 카드 결제기, 블랙박스. 넷 다 자기 화면과 자기 버튼을 갖고 있고, 넷 다 서로 모른다.
그래서 그 사이를 사람이 잇는다. 손님이 타면 미터기를 누르고, 목적지에 도착하면 미터기를 끄고, 요금을 읽어서 결제기에 찍고, 배차 앱으로 운행 종료를 알린다. 운전 중에 손이 갈 곳이 너무 많다.
이 글은 그 손을 없애 본 기록이다. 문이 열리면 미터기가 켜지고, 차가 보내는 속도 프레임이 요금이 되고, 도착하면 승객 화면에 결제가 뜬다. 한 번의 주행에서 사람이 누른 버튼은 승객의 「결제」 한 번뿐이다.
결과부터 — 한 번의 주행
승객이 타기 전. 미터기는 대기 상태다.

문이 열렸다 닫혔다. 기사는 아무것도 누르지 않았는데 미터기가 켜졌다.

주행 중. 요금이 오르고 있다.

신호에 걸렸다. 거리는 멈췄는데 대기 시간이 요금을 올린다.

승객 화면은 주행 중엔 요금만 보여준다.

도착. 승객 화면에 결제가 저절로 떴다. 기사가 뒤돌아 말할 필요가 없다.

Pay 6,600 won by card승객이 한 번 누른다. 이 주행에서 사람이 누른 유일한 버튼이다.

기사 화면에도 승인이 올라온다.

전체 그림
vehicle_bus (C) dashboard_server (Dart · mcp_server) 화면
속도·문 프레임을 밀어냄 ──CAN──▶ 프레임으로 미터기를 돌리고 ──MCP──▶ 기사 ui://driver
결제 요청엔 응답으로 답함 요금이 서면 결제를 띄운다 ──MCP──▶ 승객 ui://passenger
① 한 선에 두 종류가 흐른다
이 편이 앞의 매장 편과 다른 지점이 여기다. CAN 버스는 물어보는 물건이 아니다. 프레임은 아무도 요청하지 않아도 차가 보내고 싶을 때 도착하고, 답할 상대도 없다.
/* --- unsolicited frames, at the bus's own rate --- */
if (t - last_emit >= FRAME_INTERVAL_MS / 1000.0 * TIME_SCALE) {
last_emit = t;
double kph = scripted_speed(t);
printf("{\"frame\":\"speed\",\"kph\":%.1f,\"t\":%.3f}\n", kph, t);
/* ... */
}
/* --- requests, answered the ordinary way --- */
if (poll_request(line, sizeof(line))) {
/* ...{"id":N,...} 로 온 것에만 답한다... */
}
그런데 대시보드에는 응답을 기다려야 하는 물건도 같이 붙어 있다. 카드 단말은 물어보면 답한다. 둘이 한 선에 섞인다. 이걸 구분하지 않으면 고전적인 방식으로 망가진다 — 승인 응답을 기다리는 코드가, 마침 그때 도착한 속도 프레임을 승인 결과로 받아 버린다.
그래서 바이트가 의미가 되는 그 지점에서 딱 한 번 가른다.
void _onLine(String line) {
if (line.trim().isEmpty) return;
final Map<String, dynamic> msg;
try {
msg = jsonDecode(line) as Map<String, dynamic>;
} on FormatException {
// 버스에는 잡음이 흐른다. 깨진 프레임 하나 때문에 요금 계산 중인
// 대시보드가 죽으면 안 된다. 버리고 계속 듣는다.
return;
}
if (msg.containsKey('frame')) {
framesSeen++;
_frames.add(msg);
return;
}
transcript.add('<= $line');
_pending.remove(msg['id'] as int?)?.complete(msg);
}
깨진 줄을 버리고 계속 듣는 부분도 취향이 아니다. 차량 버스에는 잡음이 흐르고, 요금을 세는 중에 파싱 오류로 죽는 대시보드는 상품이 아니다.
② 속도에서 거리를 얻는 일 — 두 줄 차이가 요금을 바꾼다
버스는 거리를 보내지 않는다. 순간 속도만 보낸다. 거리는 만들어야 한다. 거리 += 속도 × 간격 이면 될 것 같지만, 두 군데가 그렇게 간단하지 않다.
/// 속도 프레임 하나를 주행에 접어 넣는다.
///
/// `거리 += 속도 × 간격` 이 아닌 이유가 둘이다.
///
/// 첫째, 간격은 상수가 아니다. 프레임은 버스가 보낼 때 오고, 부하가 걸리면
/// 늦게 온다. 그래서 프레임이 스스로 들고 온 시각을 쓰지, 명목 주기를 절대
/// 쓰지 않는다 — 100 ms 라고 가정했는데 140 ms 를 받으면, 모든 요금에
/// 조용히 40%가 얹힌다.
///
/// 둘째, 속도는 그 간격 사이에 변한다. 최신 값 하나로 곱하면 가속할 때마다
/// 과다 계상되고 제동할 때마다 과소 계상된다. 양 끝을 평균 내는 것(사다리꼴)이
/// 한 줄 더 쓰고 양쪽 다 맞다.
void onSpeedFrame(double kph, double t) {
if (!running) {
lastKph = kph;
_lastT = t;
return;
}
final dt = t - _lastT;
if (dt <= 0) {
// 바쁜 버스에서는 프레임 순서가 뒤집힐 수 있다. 음수 간격은 요금에서
// 거리를 빼 버리므로, 버리되 버렸다고 말한다.
framesDroppedOutOfOrder++;
return;
}
final averageKph = (lastKph + kph) / 2;
distanceKm += averageKph * dt / 3600.0;
if (averageKph < waitingBelowKph) waitingSeconds += dt;
lastKph = kph;
_lastT = t;
framesUsed++;
}
"100 ms 라고 가정했는데 140 ms 를 받으면 요금에 40%가 얹힌다." 이게 이 파일에서 가장 중요한 한 줄이다. 명목 주기를 믿는 미터기는 버스가 한가할 때는 맞고 바쁠 때는 손님에게 바가지를 씌운다. 그리고 그런 종류의 오차는 아무도 신고하지 않기 때문에 아주 오래 살아남는다.
순서가 뒤집힌 프레임을 버리되 버렸다고 세는 것도 같은 이유다. 조용히 흡수하면 요금이 틀린 채로 나가고, 나중에 왜 틀렸는지 알 방법이 없다.
그리고 대기 시간. 신호에 걸린 택시는 놀고 있는 게 아니다.
/// [waitingBelowKph] 아래로 머문 분당 요금 — 신호에 걸린 택시도 일하는 중이고,
/// 거리만 세는 미터기는 도시의 정체를 기사에게 청구하는 셈이 된다.
final int perWaitingMinute;
③ 문 프레임이 미터기를 켠다
기사의 손을 대체하는 부분이다. 프레임을 듣다가, 문이 열리면 판단한다.
case 'door':
final open = frame['open'] == true;
if (!open) break;
if (!_hired) {
// 승객이 탔다. 미터기는 여기서 시작한다 — 기사가 손을 뻗는 걸
// 기억해 냈을 때가 아니라.
_hired = true;
_payDue = false;
meter.start(t);
_notice = 'Meter started by door frame at t=${t.toStringAsFixed(2)}s';
} else if (meter.lastKph == 0) {
// 주행 중이 아닌데 문이 열렸다 = 도착. 미터기를 멈추고 요금을
// 승객 앞에 띄운다.
meter.stop();
_hired = false;
_payDue = true;
_status = 'Arrived — payment due';
}
break;
meter.lastKph == 0 조건을 눈여겨봐 주기 바란다. 문이 열렸다는 사실만으로는 도착인지 알 수 없다. 달리는 중에 문이 열리는 건 도착이 아니라 사고이고, 그때 미터기를 끄고 결제를 띄우면 안 된다. 정지 상태라는 조건이 붙어야 "내렸다"가 된다.
승객 화면은 그 상태를 조건부로 렌더한다. 결제 버튼은 도착하기 전에는 화면에 없다.
{
'type': 'conditional',
'condition': '{{payDue}}',
'then': { /* Arrived — tap to pay + 결제 버튼 */ },
'else': { 'type': 'text', 'content': 'Enjoy the ride' },
}