現場

運転手は何も押さない — メーター・OBD・決済をひとつのダッシュボードに

著者: makemind · 2026年8月6日

タクシーの運転席を数えてみよう。メーター、配車端末、カード決済機、ドライブレコーダー。四つとも自分の画面と自分のボタンを持ち、四つとも互いを知らない。

だからその間を人が繋ぐ。客が乗ればメーターを押し、目的地に着けばメーターを止め、運賃を読んで決済機に打ち、配車アプリで運行終了を知らせる。運転中に手を伸ばす先が多すぎる。

この記事はその手を無くしてみた記録だ。ドアが開けばメーターが動き、車が送る速度フレームが運賃になり、到着すれば乗客の画面に決済が出る。一度の乗車で人が押したボタンは、乗客の「支払う」ひとつだけだった。

まず結果 — 一度の乗車

客が乗る前。メーターは待機状態だ。

運転席の画面 — ナンバーと状態(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