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 door opened and closed. The driver pressed nothing and the meter started.

Driving. The fare is climbing.

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

The passenger screen shows only the fare while under way.

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

Pay 6,600 won by cardThe passenger presses once. The only button pressed by a human on this trip.

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

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