Field

The Driver Presses Nothing — Meter, OBD and Payment on One Dashboard

By makemind · Aug 6, 2026

Count the things in a taxi's driver seat. The meter, the dispatch terminal, the card reader, the dashcam. All four have their own screen and their own buttons, and all four know nothing of each other.

So a person joins them. A passenger gets in and the driver presses the meter, arrives and stops the meter, reads the fare and enters it on the card reader, and marks the trip finished in the dispatch app. There are far too many places for a hand to go while driving.

This article is a record of removing that hand. The door opens and the meter starts, the speed frames the vehicle sends become a fare, and on arrival payment appears on the passenger screen. In one trip, the only button a human pressed was the passenger's "pay."

The result first — one trip

Before the passenger boards. The meter is idle.

The driver's screen — the plate, the state (Waiting for fare) and a fare of 0, with the meter rule and "no button pressed" along the bottom. Real render capture
The driver's screen — the plate, the state (Waiting for fare) and a fare of 0, with the meter rule and "no button pressed" along the bottom. Real render capture

The door opened and closed. The driver pressed nothing and the meter started.

The door opens and it reads Hired — the screen says the meter was started by a vehicle frame, not by a person
The door opens and it reads Hired — the screen says the meter was started by a vehicle frame, not by a person

Driving. The fare is climbing.

On the move — 5,500 won at 0.58 km with 9.5 s of waiting; the speed top right and "22 vehicle frames" at the foot are where those figures come from
On the move — 5,500 won at 0.58 km with 9.5 s of waiting; the speed top right and "22 vehicle frames" at the foot are where those figures come from

Stopped at a light. Distance has stopped, and waiting time is raising the fare.

Stopped at a light — the distance holds and only the waiting time climbs; the meter rule states where that line is
Stopped at a light — the distance holds and only the waiting time climbs; the meter rule states where that line is

The passenger screen shows only the fare while under way.

The passenger's screen — the same meter, seen from the back seat. Under the fare, three lines split it into flagfall, distance and waiting
The passenger's screen — the same meter, seen from the back seat. Under the fare, three lines split it into flagfall, distance and waiting

Arrival. Payment appeared on the passenger screen by itself. The driver does not have to turn around and say so.

Arrived — 6,600 won at 1.23 km, and the button carries the amount: `Pay 6,600 won by card`
Arrived — 6,600 won at 1.23 km, and the button carries the amount: Pay 6,600 won by card

The passenger presses once. The only button pressed by a human on this trip.

After approval — the code T8801 (7788) stays on the screen
After approval — the code T8801 (7788) stays on the screen

The approval comes up on the driver's screen too.

The same approval on the driver's screen — the two are not wired together; they are looking at one meter
The same approval on the driver's screen — the two are not wired together; they are looking at one meter

The whole picture

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

① Two kinds of traffic on one wire

Here is where this piece differs from the earlier shop one. A CAN bus is not a thing you ask. Frames arrive when the vehicle wants to send them, unrequested, with nobody to answer.

            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 --- */

But the dashboard also has something attached that must be waited on. Ask the card terminal and it answers. The two mix on one wire. Fail to separate them and it breaks in the classic way — the code waiting for an authorisation response takes the speed frame that happened to arrive at that moment as the authorisation result.

So they are split exactly once, at the point where bytes become meaning.

    _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: () {

Dropping a corrupt line and continuing is not taste either. Noise travels on a vehicle bus, and a dashboard that dies of a parse error mid-fare is not a product.

② Getting distance from speed — two lines that change the fare

The bus does not send distance. It sends instantaneous speed only. Distance has to be made. distance += speed × interval looks like it would do, but two places are not that simple.

/// 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++;
}

"Assume 100 ms and receive 140 ms, and 40% is added to the fare." That is the most important line in this file. A meter that trusts the nominal period is right when the bus is quiet and overcharges the passenger when it is busy. And that class of error survives a very long time, because nobody reports it.

Dropping an out-of-order frame while counting that it was dropped is for the same reason. Absorb it silently and the fare goes out wrong with no way to learn why afterwards.

And waiting time. A taxi stopped at a light is not idle.

  /// is still working, and a meter that only counted distance would bill the
  /// driver for the city's traffic.

③ A door frame starts the meter

This is the part that replaces the driver's hand. Listening to frames, and deciding when a door opens.

            _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() {

Please note the meter.lastKph == 0 condition. The fact that a door opened does not tell you it is an arrival. A door opening while moving is not an arrival, it is an accident, and stopping the meter and raising payment then would be wrong. The stationary condition is what makes it "got out."

The passenger screen renders that state conditionally. The pay button is not on the screen before arrival.

{
  'type': 'conditional',
  'condition': '{{payDue}}',
  'then': { /* Arrived — tap to pay + the pay button */ },
  'else': { 'type': 'text', 'content': 'Enjoy the ride' },
}

This content requires Developer or above

Sign in and upgrade your plan to continue reading.

View Plans
Twitter