数一数出租车驾驶位上有什么。计价器、派单终端、刷卡机、行车记录仪。四台都有自己的屏和自己的按钮,四台又互不相识。
于是中间靠人接。客人上车按计价器,到了目的地关计价器,读出车费敲进刷卡机,再在派单 App 上报运行结束。开车时要伸手的地方太多了。
这篇文章记录的是把那只手拿掉。车门一开计价器就走,车送出来的车速帧变成车费,到站后收款自己出现在乘客屏上。整趟车里人按下的按钮,只有乘客那一次「支付」。
先看结果 —— 一趟车
乘客上车之前。计价器在待机。

车门开了又关。司机什么都没按,计价器就走了起来。

行驶中。车费在涨。

停在了红灯前。距离不动了,而 等待时间在把车费往上推。

行驶中的乘客屏只显示车费。

到站。收款自己出现在乘客屏上。 司机不必回头说一声。

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' },
}