It is not 25 °C outside: giving the emulated car a real thermometer

· aospautomotivevhalcuttlefishvsocktreble


It hit 38 °C in Germany this summer. The window was open, the fan was losing, and on the second monitor an Android Automotive build was telling me, with total confidence, what the weather was like outside the car:

$ adb shell cmd car_service get-property-value 0x11600703
HalPropValue{Property ID: ENV_OUTSIDE_TEMPERATURE(0x11600703), ... Value: 25.0 CELSIUS}

Twenty-five degrees. It had been twenty-five degrees in that car since the day I first booted it in the spring. It was twenty-five degrees during the June heat wave, twenty-five during the July one, twenty-five on the day the Rhine ferry stopped running because the river was too low. The car lives in a virtual machine and the virtual machine lives in a perpetual, pleasant late-May afternoon.

I decided this was a bug. Not in the software — the software is doing exactly what it’s told — but in the premise. A property called ENV_OUTSIDE_TEMPERATURE should have some relationship, however loose, with the environment. So I set out to give the car a thermometer, and the closest to reality I have is the one inside the CPU.

Where the 25 lives

The first surprise is that the 25 isn’t in the car at all.

On a phone, a HAL is a process on the device that talks to hardware on the device. The automotive Cuttlefish target does something more interesting with its vehicle HAL. The VHAL process that runs in the guest — the one car_service talks to over binder as IVehicle/default — knows nothing about cars. It’s a stock DefaultVehicleHal, the generic AIDL front door that every VHAL implementation shares, sitting on top of a pluggable “hardware” object. On a real head unit that object would speak CAN. On Cuttlefish it’s GRPCVehicleHardware, and every question it’s asked, it forwards over gRPC to a process on the host called vhal_proxy_server.

That host process is where the fake car is. It runs FakeVehicleHardware, the reference implementation, and the reference implementation gets its opinions from a JSON file:

{
    "property": "VehicleProperty::ENV_OUTSIDE_TEMPERATURE",
    "defaultValue": { "floatValues": [ 25.0 ] },
    "maxSampleRate": 2.0,
    "minSampleRate": 1.0
}

So the weather is a constant in a config file, on a machine the car can’t see, behind two process boundaries and a VM. I find this genuinely elegant — the same guest image can be pointed at a different fake, or a real vehicle bus simulator, without rebuilding anything — but it does mean the obvious fix, “just make the fake say something else”, puts the change in the wrong place. I didn’t want a different constant. I wanted the car to read a sensor.

Can’t I just set it?

The second surprise is how thoroughly you’re prevented from cheating.

ENV_OUTSIDE_TEMPERATURE is declared in the VHAL’s AIDL with access READ and change mode CONTINUOUS. Continuous means the hardware pushes it at a subscribed rate, one to two hertz according to the config above. Read-only means nobody sets it. Not an app, not car_service, not a shell with root: DefaultVehicleHal checks the property’s declared access on every setValues call and answers ACCESS_DENIED before the request gets anywhere near the hardware object. There’s a debug back door — the fake hardware accepts --set through dumpsys on userdebug builds — but a debug back door is not a thermometer.

Which settles the architecture. If the value can’t be pushed into the VHAL, it has to originate inside it. The vehicle HAL needs to be the thing that reads the sensor. That’s actually how it works on real cars, where the VHAL implementation is the vendor’s and does whatever it takes to get numbers off the bus; it’s just that on Cuttlefish “the bus” is a gRPC channel to a JSON file.

Choosing a thermometer

The workstation, it turns out, has eighteen of them. The kernel’s hwmon subsystem lists the Ryzen die sensors, a real NTC thermistor on the motherboard, two more on the water-cooling headers, the NVMe drive, all four DDR5 modules, the GPU, and — this one made me laugh — the Ethernet controller.

I picked the CPU die, k10temp’s Tctl, for a reason that’s only half a joke: it’s the one temperature that responds to building Android. Kick off m and it climbs ten degrees in a minute. The car would be reporting, as its outside temperature, the thermal cost of its own existence. There’s something honest about that.

It is also, in September, more than twice the actual outside temperature. We’ll come back to that.

Getting a number across the VM boundary

A host-to-guest channel on Cuttlefish means vsock, the address family purpose-built for talking to the hypervisor’s other side. The guest connects to CID 2 — “the host”, always — on a port of my choosing, and the host listens. Every Cuttlefish HAL that reaches host-side hardware does this: GNSS, sensors, lights. Ten lines of Python on the host, a connect() in the guest, done.

Except that when I did exactly that, my temperature came back as %CTZV: 26/09/09:12:56:10+4:1:Europe!Berlin+CREG: 0+CGREG: 0. The car was reading the weather off a modem.

The lesson is small but worth writing down: port 9600 looked free in the instance config but is the modem simulator’s vsock port, and the modem simulator will happily greet any caller with unsolicited AT results. Pick a port nothing else in cuttlefish_config.json has claimed. With that sorted, the host side of my thermometer is a Python script that listens on the vsock port, finds the k10temp device by name (the hwmonN numbering reshuffles on every boot), and answers each connection with one line — 67625 — before closing. Milli-degrees, one reading per connection, no protocol to get out of sync.

workstation (host) Cuttlefish guest (the car) k10temp · Tctl /sys/class/hwmon/hwmonN/temp1_input = 67625 bridge.py listen vsock :9920 → "67625\n", one line per connection vhal_proxy_server FakeVehicleHardware + DefaultProperties.json ENV_OUTSIDE_TEMPERATURE: floatValues [25.0] car_service · CarPropertyManager getProperty(ENV_OUTSIDE_TEMPERATURE) — unchanged binder, IVehicle/default the VHAL process DefaultVehicleHal (stock, AIDL front door) HeatwaveVehicleHardware 0x11600703 → vsock 2:9920 · everything else ↓ GRPCVehicleHardware (stock shim) vsock, one line per read gRPC, all other props
The reading’s route into the car. Nothing above the VHAL changes: car_service asks the same IVehicle/default it always did. Inside the VHAL process, a decorator sits between the stock front door and the stock gRPC shim, answers exactly one property over vsock, and forwards the other several hundred to the host proxy — where the fake car, and its 25.0, live on untouched.

An alternative VHAL, without forking the VHAL

Here is where the pluggable-hardware design pays off. IVehicleHardware is a small C++ interface — get configs, get values, set values, subscribe, unsubscribe, register a change callback, dump. GRPCVehicleHardware implements it. So does FakeVehicleHardware. So can I, and the cheapest way to do it is to write a decorator: an IVehicleHardware that owns a GRPCVehicleHardware and forwards every call to it, except when the property is 0x11600703 (ENV_OUTSIDE_TEMPERATURE).

For that one property, four methods do something:

Everything else — the several hundred other properties, HVAC, gear, speed, seat heaters — is a one-line passthrough. If the host bridge is down, the decorator forwards our request too, and the car quietly goes back to twenty-five. Graceful degradation to eternal spring.

Selecting the new VHAL was one product-makefile variable, plus the discovery that the Cuttlefish auto product only applies its default VHAL when that variable is empty, which makes the order of your inherit-product lines suddenly matter. The SELinux side was a single rule letting the VHAL domain open a vsock socket — and one afternoon of the kernel denying a rule that was demonstrably present in the policy file on the device. That one turned out to be a precompiled policy on the /odm partition that init prefers over the text policy whenever its hashes match, which they did, because I had only synced /vendor. File under “Treble has more partitions than you remember”.

What the car says now

$ adb shell cmd car_service get-property-value 0x11600703
... Value: 67.625 CELSIUS}
$ adb shell cmd car_service get-property-value 0x11600703
... Value: 68.0 CELSIUS}
$ adb shell cmd car_service get-property-value 0x11600703
... Value: 67.5 CELSIUS}

Between sixty and seventy degrees, and it moves. Start a build on the host and the car watches the outside world heat up in real time; let the machine idle and it cools back down; kill the bridge and it snaps back to 25.0, which is now recognisably a fallback rather than a fact. Every other property still comes from the untouched fake on the host, car_service never knew anything changed, and the whole thing is one decorator class, one sepolicy line, and a hundred lines of Python on the host.

Is 65 °C the outside temperature? No. Even this summer it never got there. But the two things a heat wave teaches you about weather — that it’s hotter than the brochure said, and that it changes — are both true of the car’s new reading and neither was true of the old one. As a model of the environment, a live thermometer pointed at the wrong thing beats a constant pointed at nothing. And when the autumn comes and the number finally drops into the fifties, I’ll know it wasn’t the weather. It was that I stopped building Android.

Reading list

Everything below is in an AOSP 15 tree; the VHAL reference implementation is small enough to read in an evening.

The previous post on Treble’s tripwires explains why replacing a HAL is a product-makefile decision and nothing more — and why the SELinux label on the new binary is the first thing that bites when you skip the build system and adb sync it into place.