Notes on integrating a Nissan Leaf ZE1 and Home Assistant

(WIP - last updated 13/01/2025)

I recently bought a 2020 Nissan Leaf, having previously owned a 2015 model. The car has essentially not changed much over time, different batteries, a new body, but the interior/electrical system is quite a bit different on the newer models.

One significant change is that the OBD port is disabled when the car is switched off (other than 12V/GND). This means that it's not possible to remotely monitor the vehicle other than using the Nissan Connect EV app. If you want full-time access you need to tap directly into the CAN buses. A neat solution found by the OVMS community is to tap the harness which goes into the "CAN gateway", which has all the CAN buses. You can buy a short harness extension cable and the cut/splice/crimp/solder that without worrying about damaging the factory harness.

Splicing in

The CAN gateway box is right behind the main instrument cluster. This sounds bad, but it's actually pretty easy to get to, and far easier than attempting to access it by reaching up behind the dash (you can just about see it and might be able to reach up). The other advantage is you'll have plenty of space to secure the extension cable and make it all neat at your own leisure. Here is a good video guide. One tip, you do need to use a fair bit of force to pull out the various parts, but the clips are quite sturdy and I don't think there's much chance of breaking them (as was common on older vehicles).

The completed splicing cable. I carefully stripped away the insulation and wrapped my wires around the copper core. This avoided cutting the cable, which would have made some of the wires in the bundle shorter.
The CAN gateway is the white box with the socket on the front. This is immediately behind the instrument cluster.
The splice in place.

Which CAN bus do we need?

Below are which car functions are hooked onto which CAN buses, I compiled it by looking through the service manual's block wiring diagrams. As you can see, the EV CAN is necessary to monitor (and presumably control) the battery, the Vehicle (also called CAR) CAN is necessary to monitor the basic car stats, and the ITS CAN would be needed if you want to monitor/control the AV unit. The others might be interesting, but given they are safety-related and we are limited to 2-3 CANs on most hardware implementations probably not worth investigating further. The Diagnostic CAN is the one connected to the dashboard socket, and should be ignored.

CAN Bus M101 Pins (H,L) Modules connected
ITS CAN Intelligent Transport Systems, the component parts of ADAS 7,19 Around view monitor control unit (M32), side radar (B86,B87), distance sensor (E47), Lane camera unit (R17), ADAS control unit (M2)
IT CAN Presumably Information Technology 4,16 AV Control Unit (M96,M103), TCU (M67)
IC CAN Inter-chassis? n/a ADAS control unit (M2), Chassis control module (B64)
Vehicle CAN Also called "CAR CAN", the higher-level small vehicle components 1,13 VCM (E61), Airbag diagnostic sensor (M61), TPM (M75), ASP (vehicle approaching sound) unit (M47), BCM (M24), CPU/"IPDM E/R" (E13), Combination meter (instrument cluster/speedo) (M34)
Chassis CAN All the low-level vehicle bits 9/6,21/18 Electrically driven brake (E34), parking brake (B70), ABS (E35), chassis control module (B64), steering angle sensor (M30), EPS control unit (M37)
EV CAN The battery and other high voltage parts. Only active when the car is switched on or charging. 12,24 VCM (E61), A/C auto amp(lifier) (M55), Li-ion battery controller, traction motor inverter (F13), PDM (power delivery module) (F23), steering angle sensor (M30), EPS control unit (M37)
Diagnostic CAN 8/11,20/23 Data link connector (OBD socket) (M105)
ADAS CAN Advanced Driver Assistance System 10/2,22/14 ADAS control unit (M2), Chassis control module (B64)
M CAN Comms between audio system and instrument panel? n/a Combination meter (instrument cluster/speedo) (M34), TCU (M67), audio system (various)
3 12V
5/17 Vehicle GND

Connector pinouts

M101 CAN Gateway

This is behind the instrument cluster

The pin numbering is while looking at the socket, and with the plug's wires coming towards you. This is the reverse of the numbering printed on the plug of the "extension lead" I bought off AliExpress.

M67 TCU (Telematics Control Unit)

This is behind the glovebox.

This suggests that for a ZE1, the IT CAN is not used for active control, and only diagnosis purposes. Presumably the climate control is from pins 26+27?
(WIP) pinouts for A/C AUTO AMP M55 (heating control panel)
(WIP) pinouts for COMBINATION METER M34 (instrument cluster unit)

Testing

To fiddle around, my initial testing is an ESP32 devkit module with ESP32RET installed, and connected to SavvyCAN. Separately, I tested that setup with a second ESP32 with some simple Arduino code which pinged a "hello world". This guide is helpful in getting started from scratch doing that. The second device (connected in parallel to the same CAN bus) is an M5Stack AtomS3 with the CAN Base. I've had to add a PCB to join them because I didn't have a way to power it. That is running ESPHome to test the actual HA integration, however that ends up.

The CAN Messages

(WIP)

ESPHome Code (WIP)

NB: This was heavily ChatGPT-assisted, especially for the lambda and bitwise work.

NB2: The code includes sensors for both the EV and CAR CAN buses. Currently only sensors relating to the relevant bus will be read and updated. For example on the EV bus only the gear shift, car state, outside temperature and LBC SOC will update. On the CAR bus all the others should update.

esp32:
  board: esp32-s3-devkitc-1
  variant: ESP32S3
  framework:
    type: arduino


esphome:
  name: can-test-atoms3
  friendly_name: can-test-atoms3

  on_boot:  
    - priority: -100.0
      then:
        - deep_sleep.prevent: deep_sleep_handler
        - script.execute: test_ota
        - delay: 1s
        - script.execute: send_can_queries
    
wifi:
  manual_ip:
    static_ip: 192.168.1.62

packages:
  wifi: !include includes/wifi.yaml
  config: !include includes/config.yaml
  ha: !include includes/ha.yaml

logger:
  logs:
    canbus: INFO

globals:
  - id: otamode_default_state
    type: bool
    restore_value: no
    initial_value: "true"  # Set the default to ON (true)

canbus:
  - platform: esp32_can
    tx_pin: GPIO4
    rx_pin: GPIO5
    can_id: 999
    bit_rate: 500KBPS

    on_frame:
      # VCM statuses
    - can_id: 0x11A
      use_extended_id: false
      then:
        - lambda: |-
            if (x.size() >= 2) {  // Ensure the frame has at least 2 bytes
              // Extract Gear Shift (MSBits of byte 0)
              uint8_t gear_shift = (x[0] & 0xF0) >> 4;  // MSBits are the top 4 bits

              // Extract Car Status (byte 1)
              uint8_t car_status = x[1];

              // Publish the gear shift state
              if (gear_shift == 4) {
                id(gear_shift_sensor).publish_state("Drive/B");
              } else if (gear_shift == 3) {
                id(gear_shift_sensor).publish_state("Neutral");
              } else if (gear_shift == 2) {
                id(gear_shift_sensor).publish_state("Reverse");
              } else if (gear_shift == 0) {
                id(gear_shift_sensor).publish_state("Park");
              } else {
                id(gear_shift_sensor).publish_state("Unknown");
              }

              // Publish the car status
              if (car_status == 0x40) {
                id(car_status_sensor).publish_state("ON");
              } else if (car_status == 0x80) {
                id(car_status_sensor).publish_state("OFF");
              } else {
                id(car_status_sensor).publish_state("Unknown");
              }

              ESP_LOGI("Gear Shift Sensor", "Gear Shift: %d", gear_shift);
              ESP_LOGI("Car Status Sensor", "Car Status: %02X", car_status);
            } else {
              ESP_LOGW("0x11A Frame", "Frame too short to process");
            }

    - can_id: 0x55b
      use_extended_id: false
      then:
      - lambda: |-
          std::string b(x.begin(), x.end());
          ESP_LOGI("can id 0x60d", "%s", &b[0] );

    # Climate
    - can_id: 0x510
      use_extended_id: false
      then:
      - lambda: |-
          std::string b(x.begin(), x.end());
          ESP_LOGI("can id 0x510", "%s", &b[0] );

    # Shifter position
    - can_id: 0x421
      use_extended_id: false
      then:
      - lambda: |-
          std::string b(x.begin(), x.end());
          ESP_LOGI("can id 0x421", "%s", &b[0] );

    - can_id: 0x60d
      use_extended_id: false
      then:
      - lambda: |-
          if (x.size() >= 2) {
            // Extract light and door statuses from the first byte
            bool trunk_open = x[0] & (1 << 7);
            bool rear_right_door_open = x[0] & (1 << 6);
            bool rear_left_door_open = x[0] & (1 << 5);
            bool driver_door_open = x[0] & (1 << 4);
            bool passenger_door_open = x[0] & (1 << 3);

            // Extract light modes (bits 1-2 of byte 0)
            uint8_t light_mode = (x[0] >> 1) & 0x03;

            // Extract fog and high beam lights (bit 0 of byte 0 and bit 7 of byte 1)
            bool fog_lights = x[0] & (1 << 0);
            bool high_beam = x[1] & (1 << 7);

            // Extract indicators (bits 6 and 5 of byte 1)
            bool right_indicator = x[1] & (1 << 6);
            bool left_indicator = x[1] & (1 << 5);

            // Extract car state (bits 1-2 of byte 1)
            uint8_t car_state = (x[1] >> 1) & 0x03;

            // Publish values to respective sensors
            id(trunk_open_sensor).publish_state(trunk_open);
            id(rear_right_door_open_sensor).publish_state(rear_right_door_open);
            id(rear_left_door_open_sensor).publish_state(rear_left_door_open);
            id(driver_door_open_sensor).publish_state(driver_door_open);
            id(passenger_door_open_sensor).publish_state(passenger_door_open);
            id(light_mode_sensor).publish_state(light_mode);
            id(fog_lights_sensor).publish_state(fog_lights);
            id(high_beam_sensor).publish_state(high_beam);
            id(right_indicator_sensor).publish_state(right_indicator);
            id(left_indicator_sensor).publish_state(left_indicator);
            id(car_state_sensor).publish_state(car_state);
          } else {
            ESP_LOGW("can id 0x60d", "Frame too short to extract statuses");
          }

    - can_id: 0x763
      use_extended_id: false
      then:
      - lambda: |-
          if (x.size() >= 7) {
            uint8_t parameter_id = x[3];

            if (parameter_id == 0x01) {
              // Odometer response
              uint32_t odometer = (x[4] << 16) | (x[5] << 8) | x[6];
              id(odometer_sensor).publish_state(odometer);
            } else if (parameter_id == 0x25 || parameter_id == 0x26 || parameter_id == 0x27 || parameter_id == 0x28) {
              // Tire pressure responses
              float pressure = (x[4] * 0.068947576) * 100 / 4;

              if (parameter_id == 0x25) {
                id(tp_fr_sensor).publish_state(pressure);
              } else if (parameter_id == 0x26) {
                id(tp_fl_sensor).publish_state(pressure);
              } else if (parameter_id == 0x27) {
                id(tp_rr_sensor).publish_state(pressure);
              } else if (parameter_id == 0x28) {
                id(tp_rl_sensor).publish_state(pressure);
              }
            } else {
              ESP_LOGW("CAN Response", "Unhandled parameter ID: 0x%02X", parameter_id);
            }
          } else {
            ESP_LOGW("CAN Response", "Frame too short to process");
          }

    - can_id: 0x79A

      use_extended_id: false
      then:
        - lambda: |-
            if (x.size() >= 5) {
              uint8_t parameter_id = x[3];

              if (parameter_id == 0x34) {
                // PlugState response
                id(plug_state_sensor).publish_state(x[4]);
                ESP_LOGI("PlugState Sensor", "PlugState: %d", x[4]);
              } 
              else if (parameter_id == 0x4E) {
                // Charge Mode response
                id(charge_mode_sensor).publish_state(x[4]);
                ESP_LOGI("Charge Mode Sensor", "Charge Mode: %d", x[4]);
              } 
              else if (parameter_id == 0x36) {
                // Calculate OBC Out Power using the formula
                uint32_t obc_out_power = ((x[4] << 8) | x[5]) * 100;
                id(obc_out_power_sensor).publish_state(obc_out_power);
              }
              else if (parameter_id == 0x03) {
                // Bat 12V Voltage response
                float voltage = x[4] * 0.08;
                id(bat_12v_voltage_sensor).publish_state(voltage);
                ESP_LOGI("Bat 12V Voltage Sensor", "Voltage: %.2f V", voltage);
              } 
              else if (parameter_id == 0x83 && x.size() >= 6) {
                // Bat 12V Current - comment: this is from a document I found, but almost certainly not correct, mine reads -2A but this would drain the battery v.quickly
                int16_t current_temp = (x[4] << 8) | x[5];
                if (current_temp & 32768) {
                  current_temp |= -65536;  // Convert to signed negative
                }
                float current = current_temp / 256.0;
                id(bat_12v_current_sensor).publish_state(current);
                ESP_LOGI("Bat 12V Current Sensor", "Current: %.3f A", current);
              }
            } else {
              ESP_LOGW("CAN Response", "Frame too short to process");
            }
      
              
    - can_id: 0x54C
      use_extended_id: false
      then:
        - lambda: |-
            if (x.size() >= 7) {  // Ensure the frame has at least 7 bytes
              // Extract raw ambient temperature from byte 6
              float ambient_temp_c = (x[6] / 2.0) - 40;  // Divide by 2 and subtract 40

              // Publish the value to the sensor
              id(ambient_temp_sensor).publish_state(ambient_temp_c);
              ESP_LOGI("Ambient Temp Sensor", "Ambient Temperature: %.1f°C", ambient_temp_c);
            } else {
              ESP_LOGW("0x54C Frame", "Frame too short to process");
            }

    # Lithium Battery Controller
    - can_id: 0x55B
      use_extended_id: false
      then:
        - lambda: |-
            if (x.size() >= 2) {  // Ensure the frame has at least 2 bytes
              // Extract the first 10 bits from the frame
              uint16_t raw_lbc_soc = ((x[0] << 8) | x[1]) >> 6;  // Combine first two bytes and shift right by 6

              // Convert to percentage with one decimal place
              float lbc_soc = raw_lbc_soc / 10.0;

              // Publish the value to the sensor
              id(lbc_soc_sensor).publish_state(lbc_soc);
              ESP_LOGI("LBC SOC Sensor", "LBC SOC: %.1f%%", lbc_soc);
            } else {
              ESP_LOGW("LBC SOC Sensor", "Frame too short to process");
            }
    # 5C0 battery heater
    # 5BC Battery in GIDS and temp for display
    # 59E SOC related correction
    # 55B is LBC SOC
    # 55A various temps
    # 54F ac auo amp temps and consumption
    # 54C evap temp, cc status, fan v, outside temp, screen defrost, ac status
    # 54B climate status and modes
    # 54A
    # 1DB LBC stats
    # 1DA Inverter stuff
    # 11A car statuses

binary_sensor:
  - platform: template
    name: "Trunk Open"
    id: trunk_open_sensor
    icon: "mdi:car-back"

  - platform: template
    name: "Rear Right Door Open"
    id: rear_right_door_open_sensor
    icon: "mdi:car-door"

  - platform: template
    name: "Rear Left Door Open"
    id: rear_left_door_open_sensor
    icon: "mdi:car-door"

  - platform: template
    name: "Driver Door Open"
    id: driver_door_open_sensor
    icon: "mdi:car-door"

  - platform: template
    name: "Passenger Door Open"
    id: passenger_door_open_sensor
    icon: "mdi:car-door"

  - platform: template
    name: "Fog Lights"
    id: fog_lights_sensor
    icon: "mdi:car-light-fog"

  - platform: template
    name: "High Beam"
    id: high_beam_sensor
    icon: "mdi:car-light-high"

  - platform: template
    name: "Right Indicator"
    id: right_indicator_sensor
    icon: "mdi:car-right"

  - platform: template
    name: "Left Indicator"
    id: left_indicator_sensor
    icon: "mdi:car-left"

## Deep sleep stuff
  - platform: status
    name: "Leaf Status"

  - platform: homeassistant
    id: otamode
    entity_id: input_boolean.leaf_ota_mode
    on_state:
      then:
        - lambda: |-
            id(otamode_default_state) = id(otamode).state;

  - platform: template
    name: "OTA Mode Sensor"
    lambda: |-
      // Use default state until Home Assistant updates the sensor
      if (id(otamode).has_state()) {
        return id(otamode).state;
      } else {
        return id(otamode_default_state);
      }

sensor:
  - platform: template
    name: "Light Mode"
    id: light_mode_sensor
    unit_of_measurement: ""

  - platform: template
    name: "Car State"
    id: car_state_sensor
    unit_of_measurement: ""

  - platform: template
    name: "Leaf SOC"
    id: can_value_sensor
    unit_of_measurement: "%"
    icon: "mdi:numeric"

  - platform: template
    name: "Odometer"
    id: odometer_sensor
    unit_of_measurement: "mile"
    accuracy_decimals: 0
    icon: "mdi:counter"

  - platform: template
    name: "TP Front Right (kPa)"
    id: tp_fr_sensor
    unit_of_measurement: "kPa"
    accuracy_decimals: 2
    icon: "mdi:car-tire-alert"
  
  - platform: template
    name: "TP Front Left (kPa)"
    id: tp_fl_sensor
    unit_of_measurement: "kPa"
    accuracy_decimals: 2
    icon: "mdi:car-tire-alert"
  
  - platform: template
    name: "TP Rear Right (kPa)"
    id: tp_rr_sensor
    unit_of_measurement: "kPa"
    accuracy_decimals: 2
    icon: "mdi:car-tire-alert"
  
  - platform: template
    name: "TP Rear Left (kPa)"
    id: tp_rl_sensor
    unit_of_measurement: "kPa"
    accuracy_decimals: 2
    icon: "mdi:car-tire-alert"

  - platform: template
    name: "OBC Out Power (W)"
    id: obc_out_power_sensor
    unit_of_measurement: "W"
    accuracy_decimals: 0
    icon: "mdi:car-electric"

  - platform: template
    name: "PlugState"
    id: plug_state_sensor
    unit_of_measurement: ""
    accuracy_decimals: 0
    icon: "mdi:power-plug"

  - platform: template
    name: "Charge Mode"
    id: charge_mode_sensor
    unit_of_measurement: ""
    accuracy_decimals: 0
    icon: "mdi:ev-station"

  - platform: template
    name: "Bat 12V Voltage (V)"
    id: bat_12v_voltage_sensor
    unit_of_measurement: "V"
    accuracy_decimals: 2
    icon: "mdi:car-battery"

  - platform: template
    name: "Bat 12V Current (A)"
    id: bat_12v_current_sensor
    unit_of_measurement: "A"
    accuracy_decimals: 3
    icon: "mdi:current-dc"

  - platform: template
    name: "LBC SOC"
    id: lbc_soc_sensor
    unit_of_measurement: "%"
    accuracy_decimals: 1
    icon: "mdi:battery"

  - platform: template
    name: "Leaf Outside Ambient Temperature"
    id: ambient_temp_sensor
    unit_of_measurement: "°C"
    accuracy_decimals: 1
    icon: "mdi:thermometer"

text_sensor:
  - platform: template
    name: "Gear Shift"
    id: gear_shift_sensor
    icon: "mdi:car-shift-pattern"

  - platform: template
    name: "Car Status"
    id: car_status_sensor
    icon: "mdi:car-info"

interval:
  - interval: 600s
    then:
      - script.execute: send_can_queries


#################################################    
# Script to test if the otamode switch is on or off
script:
  - id: send_can_queries
    mode: queued
    then:
      - logger.log: "Sending CAN queries"
    #then:
      # wake up
      - repeat:
          count: 5
          then:
            - canbus.send:
                data: [0x00]
                can_id: 0x56E
                use_extended_id: false
            - delay: 50ms

      # Odometer
      - delay: 1000ms
      - canbus.send:
          data: [0x03, 0x22, 0x0E, 0x01, 0x00, 0x00, 0x00, 0x00]
          can_id: 0x743
          use_extended_id: false

      # Tyre pressures
      - delay: 100ms
      - canbus.send:
          data: [0x03, 0x22, 0x0E, 0x25, 0x00, 0x00, 0x00, 0x00]
          can_id: 0x743
          use_extended_id: false

      - delay: 100ms
      - canbus.send:
          data: [0x03, 0x22, 0x0E, 0x26, 0x00, 0x00, 0x00, 0x00]
          can_id: 0x743
          use_extended_id: false

      - delay: 100ms
      - canbus.send:
          data: [0x03, 0x22, 0x0E, 0x27, 0x00, 0x00, 0x00, 0x00]
          can_id: 0x743
          use_extended_id: false

      - delay: 100ms
      - canbus.send:
          data: [0x03, 0x22, 0x0E, 0x28, 0x00, 0x00, 0x00, 0x00]
          can_id: 0x743
          use_extended_id: false

      # OBC Out Pwr
      - delay: 100ms
      - canbus.send:
          data: [0x03, 0x22, 0x12, 0x36, 0x00, 0x00, 0x00, 0x00]
          can_id: 0x797
          use_extended_id: false

      # Send query for PlugState
      - delay: 100ms
      - canbus.send:
          data: [0x03, 0x22, 0x12, 0x34, 0x00, 0x00, 0x00, 0x00]
          can_id: 0x797
          use_extended_id: false

      # Send query for Charge Mode
      - delay: 100ms
      - canbus.send:
          data: [0x03, 0x22, 0x11, 0x4E, 0x00, 0x00, 0x00, 0x00]
          can_id: 0x797
          use_extended_id: false
          
      # Send query for Bat 12V Voltage
      - delay: 100ms
      - canbus.send:
          data: [0x03, 0x22, 0x11, 0x03, 0x00, 0x00, 0x00, 0x00]
          can_id: 0x797
          use_extended_id: false

      # Send query for Bat 12V Current
      - delay: 100ms
      - canbus.send:
          data: [0x03, 0x22, 0x11, 0x83, 0x00, 0x00, 0x00, 0x00]
          can_id: 0x797
          use_extended_id: false


#################################################    
# Script to test if the otamode switch is on or off
# script:
  - id: test_ota
    mode: queued
    then:
      - logger.log: "Checking OTA Mode"
      - if:
          condition:
            binary_sensor.is_on: otamode
          then:
            - logger.log: 'OTA Mode ON'
            - deep_sleep.prevent: deep_sleep_handler
          else:
            - logger.log: 'OTA Mode OFF'
            - deep_sleep.allow: deep_sleep_handler
      - delay: 10s
      - script.execute: test_ota


#################################################
#Deep Sleep
deep_sleep:
  id: deep_sleep_handler
  run_duration: 20s
  sleep_duration: 15min  

################################################
#Make a button to reboot the ESP device
button:
  - platform: restart
    name: ${device_name} Restart

Result in Home Assistant

AliExpress links - just things I have used which work well

ESP32-C6 (smaller board): https://www.aliexpress.com/item/1005007399157637.html
ESP32-C6 (larger board): https://www.aliexpress.com/item/1005007171241033.html
CAN transceiver: https://www.aliexpress.com/item/1005006734943258.html (there's a smaller board without a screw terminal sold, I've never got those to work reliably, and in one case it appeared to badly overheat - this one has worked very well)
3A step-down converter: https://www.aliexpress.com/item/1005006245122273.html
CANable USB to CAN converter - useful for in-car recording using SavvyCAN: https://www.aliexpress.com/item/1005005721849902.html
Prototyping PCBs: https://www.aliexpress.com/item/1005006665029598.html
ESP32 touch screen ("CYD"): https://www.aliexpress.com/item/1005006160645147.html