title: "How an EV Charger Talks to Your Car: Building an EVSE State Machine in Rust"
published: true
description: "How the control pilot handshake between an EV charger and a car works, and how to model it as a safe, testable Rust state machine."
tags: rust, embedded, iot, tutorial.
When you plug an electric car into a charger, nothing happens for a moment. The charger isn't broken, it's doing a handshake. Before any real power flows, the charger (the EVSE, Electric Vehicle Supply Equipment) and the car agree over a single wire that it is safe to start.
That handshake is a classic state machine problem, and Rust handles it well. In this post I'll explain how the signalling works, show a firmware architecture that keeps the safety logic testable, and build the core in Rust.
Disclaimer: this is an educational example. Real chargers handle mains voltage and must be designed and certified to standards like IEC 61851 and UL 2594. Don't use this code as-is in a real product.
The control pilot signal
The connector has a signal wire called the Control Pilot (CP). The charger puts a 1 kHz square wave on it, and the car pulls the voltage level down with a resistor network. The charger reads the positive peak voltage to learn what the car is doing:
| State | CP voltage | Meaning |
|---|---|---|
| A | +12 V | No vehicle connected |
| B | +9 V | Vehicle connected, not ready to charge |
| C | +6 V | Vehicle ready, charging |
| D | +3 V | Charging, ventilation required |
| E | 0 V | Error (short to ground) |
| F | -12 V | EVSE unavailable |
The charger also uses the duty cycle of that square wave to tell the car the maximum current it may draw. For 6 to 51 A, the duty cycle in percent is amps / 0.6. For 32 A that's about 53.3%.
The handshake
- Idle: nothing plugged in. The charger outputs a steady +12 V and reads state A.
- Connected: the car is plugged in, and the pilot voltage drops to +9 V (state B). The charger starts the PWM to advertise how much current it can supply.
- Charging: the car accepts and pulls the voltage to +6 V (state C). Only now does the charger close the contactor and let power flow.
- Faulted: if anything looks wrong (ground fault, error states, ventilation required), the charger opens the contactor immediately and stays latched in the fault until the car is unplugged.
The most important design rule is that the contactor closes only in Charging, and Charging requires pilot state C with no ground fault. Everything else in the design exists to enforce that rule.
Firmware architecture
A good EVSE firmware separates concerns into layers:
- Hardware: the pilot generator (+/-12 V, 1 kHz), an ADC to sample the pilot peak, the contactor driver, the ground fault (GFCI) detector, the proximity pilot (cable rating), and an energy meter.
- Hardware abstraction layer: a Rust
traitthat hides the real peripherals. On the microcontroller it talks to ADC, PWM and GPIO. In tests it's a fake. - Pure Rust core: the part that decides things. It takes inputs (pilot voltage, ground fault flag) and returns the next state and the required outputs. No I/O, no globals, no heap, so it can be
no_std. - Application / supervisor: authorization (RFID or app), load management, display and logging. It decides what max current to offer, and it can request a stop, but it can never force the contactor closed.
- Backend: the charging station management system, usually reached over OCPP. DC fast chargers also talk to the car with higher-level protocols like ISO 15118, but the basic pilot handshake still comes first.
The key decision is keeping the core as pure functions. Pure functions are trivial to test exhaustively, and you can run the exact same code on your laptop and on the chip.
The Rust implementation
First, classify the measured voltage into a pilot state, and compute the PWM duty cycle for a given current limit:
#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PilotState { A, B, C, D, E, F } /// Classify the positive peak of the control pilot signal. fn classify_pilot(volts: f32) -> PilotState { match volts { v if v >= 10.5 => PilotState::A, // ~ +12 V v if v >= 7.5 => PilotState::B, // ~ +9 V v if v >= 4.5 => PilotState::C, // ~ +6 V v if v >= 1.5 => PilotState::D, // ~ +3 V v if v >= -1.5 => PilotState::E, // ~ 0 V _ => PilotState::F, // ~ -12 V } } /// Duty cycle (percent) that advertises `amps` to the vehicle. fn duty_for_amps(amps: f32) -> f32 { let amps = amps.clamp(6.0, 80.0); if amps <= 51.0 { amps / 0.6 } else { amps / 2.5 + 64.0 } } Next, the state machine itself. States and faults are enums, so the compiler forces us to handle every combination:
#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Fault { PilotFault, VentilationRequired, GroundFault, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum EvseState { Idle, Connected, Charging, Faulted(Fault), } struct Inputs { pilot: PilotState, ground_fault: bool, } fn next_state(current: EvseState, input: Inputs) -> EvseState { use EvseState::*; use PilotState::*; // Safety checks override everything else. if input.ground_fault { return Faulted(Fault::GroundFault); } match (current, input.pilot) { // A fault only clears when the car is unplugged. (Faulted(_), A) => Idle, (Faulted(f), _) => Faulted(f), (_, E) | (_, F) => Faulted(Fault::PilotFault), (_, A) => Idle, (_, D) => Faulted(Fault::VentilationRequired), (Idle, B | C) => Connected, (Connected | Charging, B) => Connected, (Connected | Charging, C) => Charging, } } The match has no wildcard arm for the non-faulted cases. If you add a new state later, the compiler refuses to build until you've decided what happens in every combination. That is the main reason I like Rust for this kind of code.
Now the outputs. Notice that the contactor is a pure function of the state:
#[derive(Debug, Clone, Copy, PartialEq)] enum PilotOutput { Steady12V, Pwm { duty_pct: f32 }, } #[derive(Debug, Clone, Copy, PartialEq)] struct Outputs { contactor_closed: bool, pilot: PilotOutput, } fn outputs(state: EvseState, max_amps: f32) -> Outputs { use EvseState::*; let pwm = PilotOutput::Pwm { duty_pct: duty_for_amps(max_amps) }; match state { Idle => Outputs { contactor_closed: false, pilot: PilotOutput::Steady12V }, Connected => Outputs { contactor_closed: false, pilot: pwm }, Charging => Outputs { contactor_closed: true, pilot: pwm }, // Contactor open, no PWM: the car sees "not ready". Faulted(_) => Outputs { contactor_closed: false, pilot: PilotOutput::Steady12V }, } } Plugging in the hardware
The real hardware lives behind a trait, and the control loop is just glue:
trait EvseHardware { fn read_pilot_peak_volts(&mut self) -> f32; fn ground_fault(&mut self) -> bool; fn set_pilot(&mut self, out: PilotOutput); fn set_contactor(&mut self, closed: bool); } fn control_tick<H: EvseHardware>(hw: &mut H, state: &mut EvseState, max_amps: f32) { let pilot = classify_pilot(hw.read_pilot_peak_volts()); let ground_fault = hw.ground_fault(); *state = next_state(*state, Inputs { pilot, ground_fault }); let out = outputs(*state, max_amps); hw.set_pilot(out.pilot); hw.set_contactor(out.contactor_closed); } Call control_tick from a timer interrupt or an async task and you have the full loop. On an STM32 or ESP32, EvseHardware would wrap embedded-hal peripherals.
Running a simulated session
Here's a small main that replays a scripted session, including a ground fault halfway through:
fn main() { let max_amps = 32.0; let mut state = EvseState::Idle; // (pilot voltage read by the EVSE, ground fault flag) let script = [ (12.0, false), // nothing plugged in (9.0, false), // car plugged in (6.0, false), // car asks for power (6.0, false), // charging (6.0, true), // ground fault! (6.0, false), // fault stays latched (12.0, false), // unplugged, fault clears ]; for (volts, gf) in script { let pilot = classify_pilot(volts); state = next_state(state, Inputs { pilot, ground_fault: gf }); let out = outputs(state, max_amps); let pilot_out = match out.pilot { PilotOutput::Steady12V => "steady +12 V".to_string(), PilotOutput::Pwm { duty_pct } => format!("PWM {duty_pct:.1}%"), }; let contactor = if out.contactor_closed { "CLOSED" } else { "open" }; println!("{volts:>5.1} V | {pilot:?} | {state:?} | contactor {contactor} | {pilot_out}"); } } Expected output:
12.0 V | A | Idle | contactor open | steady +12 V 9.0 V | B | Connected | contactor open | PWM 53.3% 6.0 V | C | Charging | contactor CLOSED | PWM 53.3% 6.0 V | C | Charging | contactor CLOSED | PWM 53.3% 6.0 V | C | Faulted(GroundFault) | contactor open | steady +12 V 6.0 V | C | Faulted(GroundFault) | contactor open | steady +12 V 12.0 V | A | Idle | contactor open | steady +12 V Testing the safety rule
Because the core is pure, we can check the most important rule exhaustively. This test tries every state, every pilot reading and both ground fault values, and asserts that the contactor only ever closes when the pilot is C and there is no ground fault:
#[cfg(test)] mod tests { use super::*; #[test] fn contactor_closes_only_in_state_c_without_fault() { use PilotState::*; let states = [ EvseState::Idle, EvseState::Connected, EvseState::Charging, EvseState::Faulted(Fault::PilotFault), ]; for s in states { for p in [A, B, C, D, E, F] { for gf in [false, true] { let next = next_state(s, Inputs { pilot: p, ground_fault: gf }); if outputs(next, 32.0).contactor_closed { assert_eq!(p, C); assert!(!gf); } } } } } #[test] fn ground_fault_opens_contactor() { let s = next_state( EvseState::Charging, Inputs { pilot: PilotState::C, ground_fault: true }, ); assert_eq!(s, EvseState::Faulted(Fault::GroundFault)); assert!(!outputs(s, 32.0).contactor_closed); } #[test] fn thirty_two_amps_is_about_53_percent_duty() { assert!((duty_for_amps(32.0) - 53.3).abs() < 0.1); } } Run it with cargo test. This is something you can't easily do with firmware that mixes I/O into the decision logic.
What this leaves out
A real charger needs much more than this:
- Proximity Pilot (PP) to detect the cable's current rating, and the plug-latch button.
- Diode check on the pilot's negative half, which proves the car's circuitry is intact.
- Welded contactor detection, so the charger notices if the contactor didn't actually open.
- Timing requirements from the standard for how quickly outputs must change after a state change.
- Hysteresis and debounce on the ADC reading, so noise doesn't cause false transitions.
- Watchdogs and redundant protection that work independently of the firmware.
Where to take it next
- Move the core to
no_stdand run it on an ESP32 or STM32 withembedded-hal. - Add the proximity pilot and cap
max_ampsto the cable rating. - Add an OCPP client in the application layer for remote start/stop.
- Write a fake
EvseHardwareand replay recorded sessions as integration tests.
Wrapping up
An EV charger handshake is a small problem with real safety stakes, which makes it a great fit for Rust. Enums make the states explicit, exhaustive match stops you from forgetting a case, and keeping the core pure lets you prove the safety rule with a test instead of hoping for it.
If you build on this, tell me in the comments.



