タクシーの運転席を数えてみよう。メーター、配車端末、カード決済機、ドライブレコーダー。四つとも自分の画面と自分のボタンを持ち、四つとも互いを知らない。
だからその間を人が繋ぐ。客が乗ればメーターを押し、目的地に着けばメーターを止め、運賃を読んで決済機に打ち、配車アプリで運行終了を知らせる。運転中に手を伸ばす先が多すぎる。
この記事はその手を無くしてみた記録だ。ドアが開けばメーターが動き、車が送る速度フレームが運賃になり、到着すれば乗客の画面に決済が出る。一度の乗車で人が押したボタンは、乗客の「支払う」ひとつだけだった。
まず結果 — 一度の乗車
客が乗る前。メーターは待機状態だ。

ドアが開いて閉じた。運転手は何も押していないのにメーターが動き出した。

走行中。運賃が上がっている。

信号に掛かった。距離は止まっているのに 待機時間が運賃を上げる。

乗客の画面は走行中は運賃だけを見せる。

到着。乗客の画面に決済がひとりでに出た。 運転手が振り返って言う必要が無い。

Pay 6,600 won by card乗客が一度押す。この乗車で人が押した唯一のボタンだ。

運転手の画面にも承認が上がる。

全体像
vehicle_bus (C) dashboard_server (Dart · mcp_server) screens
pushes speed and door frames ──CAN──▶ runs the meter from frames ──MCP──▶ driver ui://driver
answers payment requests raises payment when the fare ──MCP──▶ passenger ui://passenger
closes
① 一本の線に二種類が流れる
この記事が前の店舗の記事と違う地点はここだ。CAN バスは 問い合わせる物ではない。 フレームは誰も要求しなくても車が送りたいときに届き、答える相手もいない。
printf("{\"frame\":\"speed\",\"kph\":%.1f,\"t\":%.3f}\n", kph, t);
int door = scripted_door(t);
if (door != g_door_open) {
g_door_open = door;
printf("{\"frame\":\"door\",\"open\":%s,\"t\":%.3f}\n",
door ? "true" : "false", t);
}
}
/* --- requests, answered the ordinary way --- */
ところがダッシュボードには応答を待たねばならない物も一緒に繋がっている。カード端末は問えば答える。二つが一本の線に混じる。 これを区別しなければ古典的な壊れ方をする — 承認の応答を待つコードが、ちょうどそのとき届いた速度フレームを承認結果として受け取ってしまう。
だからバイトが意味になるその地点でちょうど一度だけ分ける。
_pending.remove(msg['id'] as int?)?.complete(msg);
}
Future<Map<String, dynamic>> call(
String tool, [
Map<String, dynamic> args = const {},
]) async {
final id = _nextId++;
final completer = Completer<Map<String, dynamic>>();
_pending[id] = completer;
final request = jsonEncode({'id': id, 'tool': tool, 'args': args});
transcript.add('=> $request');
_proc.stdin.writeln(request);
final reply = await completer.future.timeout(
requestTimeout,
onTimeout: () {
壊れた行を捨てて聞き続ける部分も好みではない。車両バスには雑音が流れ、運賃を数えている最中にパースエラーで死ぬダッシュボードは商品ではない。
② 速度から距離を得る仕事 — 二行の違いが運賃を変える
バスは距離を送らない。瞬間速度だけを送る。 距離は作らねばならない。距離 += 速度 × 間隔 で良さそうに見えるが、二箇所がそう単純ではない。
/// Fold one speed frame into the trip.
///
/// Two reasons this is not `distance += speed × interval`.
///
/// First, the interval is not a constant. Frames arrive when the bus sends
/// them, and arrive late under load. So use the timestamp the frame carries
/// itself, never the nominal period — assume 100 ms and receive 140 ms, and
/// 40% is quietly added to every fare.
///
/// Second, speed changes across that interval. Multiplying by the latest
/// value alone overcounts on every acceleration and undercounts on every
/// brake. Averaging the two ends (trapezoid) is one more line and is right
/// on both sides.
void onSpeedFrame(double kph, double t) {
if (!running) {
lastKph = kph;
_lastT = t;
return;
}
final dt = t - _lastT;
if (dt <= 0) {
// Frames can arrive out of order on a busy bus. A negative interval
// would subtract distance from the fare, so drop it — and say so.
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% が乗る。」 これがこのファイルでいちばん重要な一行だ。名目周期を信じるメーターは、バスが暇なときは正しく、忙しいときは客に上乗せする。そしてその種の誤差は誰も申告しないので、非常に長く生き残る。
順序が逆転したフレームを 捨てつつ、捨てたと数える のも同じ理由だ。黙って吸収すれば運賃が間違ったまま出て行き、後でなぜ間違ったのかを知る術が無い。
そして待機時間。信号に掛かったタクシーは遊んでいるのではない。
/// is still working, and a meter that only counted distance would bill the
/// driver for the city's traffic.
③ ドアのフレームがメーターを動かす
運転手の手を置き換える部分だ。フレームを聞きながら、ドアが開けば判断する。
_notice = 'Meter started by door frame at t=${t.toStringAsFixed(2)}s';
stderr.writeln('[trip] hired at t=$t');
} else if (meter.lastKph == 0) {
// Door opened while stopped: arrival. Stop the meter and put the
// fare in front of the passenger.
meter.stop();
_hired = false;
_payDue = true;
_status = 'Arrived — payment due';
_notice = 'Fare closed at ${meter.distanceKm.toStringAsFixed(2)} km';
stderr.writeln('[trip] arrived, fare=${meter.fare}');
}
break;
}
});
}
void _registerScreens() {
meter.lastKph == 0 の条件に目を留めてほしい。ドアが開いたという事実だけでは到着かどうか分からない。走行中にドアが開くのは到着ではなく事故であり、そのときメーターを止めて決済を出してはならない。停止状態という条件が付いて初めて「降りた」になる。
乗客の画面はその状態を条件付きでレンダーする。決済ボタンは到着前には画面に無い。
{
'type': 'conditional',
'condition': '{{payDue}}',
'then': { /* Arrived — tap to pay + the pay button */ },
'else': { 'type': 'text', 'content': 'Enjoy the ride' },
}