实战

司机一个按钮都不按 — 计价器、OBD、收款收进同一块仪表盘

作者: makemind · 2026年8月6日

数一数出租车驾驶位上有什么。计价器、派单终端、刷卡机、行车记录仪。四台都有自己的屏和自己的按钮,四台又互不相识。

于是中间靠人接。客人上车按计价器,到了目的地关计价器,读出车费敲进刷卡机,再在派单 App 上报运行结束。开车时要伸手的地方太多了。

这篇文章记录的是把那只手拿掉。车门一开计价器就走,车送出来的车速帧变成车费,到站后收款自己出现在乘客屏上。整趟车里人按下的按钮,只有乘客那一次「支付」。

先看结果 —— 一趟车

乘客上车之前。计价器在待机。

司机屏 —— 车牌与状态(Waiting for fare),车费为 0;底部写着计价规则与 "no button pressed"。真实渲染截图
司机屏 —— 车牌与状态(Waiting for fare),车费为 0;底部写着计价规则与 "no button pressed"。真实渲染截图

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

车门一开就变成 Hired —— 屏幕说明启动计价的是车辆帧,不是人
车门一开就变成 Hired —— 屏幕说明启动计价的是车辆帧,不是人

行驶中。车费在涨。

行驶中 —— 5,500 won、0.58 km、等待 9.5 s。右上的速度与底部的 "22 vehicle frames" 就是这些数字的来源
行驶中 —— 5,500 won、0.58 km、等待 9.5 s。右上的速度与底部的 "22 vehicle frames" 就是这些数字的来源

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

等红灯时 —— 里程不动,只有等待时间在涨;计价规则写明了这条界线
等红灯时 —— 里程不动,只有等待时间在涨;计价规则写明了这条界线

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

乘客屏 —— 同一个计价器,从后座看。车费下面分成起步价、里程、等待三行
乘客屏 —— 同一个计价器,从后座看。车费下面分成起步价、里程、等待三行

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

到达 —— 6,600 won、1.23 km,付款按钮上写着金额:`Pay 6,600 won by card`
到达 —— 6,600 won、1.23 km,付款按钮上写着金额:Pay 6,600 won by card

乘客按一下。这是整趟车里人按下的唯一一个按钮。

批准之后 —— 授权码 T8801 (7788) 留在屏幕上
批准之后 —— 授权码 T8801 (7788) 留在屏幕上

司机屏上也出现了授权。

同一次授权也出现在司机屏 —— 两块屏不是接在一起,而是在看同一个计价器
同一次授权也出现在司机屏 —— 两块屏不是接在一起,而是在看同一个计价器

全景

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

此内容需要开发者或更高等级

登录并升级您的方案即可继续阅读。

查看方案
Twitter