ESPHome-based Lelit Elizabeth V3 Coffee Machine Boiler and Brew Monitor WIP

A bit of a pet project, we have a Lelit Elizabeth V3 Coffee Machine which I have been mucking around with to try and get some data into HA. Im getting there decoding the UART stream with my mate Claude...

For those interested in my findings so far:

Project Manifest: Lelit Elizabeth V3 Stats Monitor

Project Goal

Monitor Lelit Elizabeth V3 boiler temperatures and shot timer via ESPHome and Home Assistant, reading data from the machine's LCC UART port.

Status: Protocol fully decoded from a real shot (2026-06-30). Shot detection code deployed. Awaiting first test with new code to confirm SHOT START/SHOT END log markers fire correctly.


Hardware Environment

  • Microcontroller: M5Stack Atom (ESP32)
  • Machine Connection: UART via LCC port, GPIO26, 9600 baud, inverted RX
  • ESPHome Version: 2026.6.3
  • Home Assistant Version: 2026.6.4

Wiring — LCC Port to M5Stack Atom

  • LCC Pin 4 (Purple wire): Ground → M5Stack Atom Orange wire (GND)
  • LCC TX wire (White wire): Data → M5Stack Atom GPIO26 (RX, inverted)

The LCC port is a proprietary Lelit connector on the machine's controller board. Only RX is needed (receive-only monitoring). The signal is inverted, hence inverted: true in the YAML.


Home Assistant Integration

  • Device name: "Lelit Elizabeth V3 Monitor"
  • Power monitoring plug: switch.coffee_power_plug / sensor.coffee_power_plug_power
  • The plug turns the machine on/off and monitors power draw
  • An automation turns the machine on at 6:30am and off at 2:30pm
  • Auto-standby is disabled on this machine (EU-mandated feature, toggled off by user)
  • The LCC natively displays a live shot timer during extraction and holds the final time for 20 seconds after the shot ends

HA Entity IDs

  • sensor.lelit_elizabeth_monitor_brew_temperature
  • sensor.lelit_elizabeth_monitor_steam_temperature
  • sensor.lelit_elizabeth_monitor_shot_timer
  • binary_sensor.lelit_elizabeth_v3_monitor_brewing
  • sensor.coffee_power_plug_power
  • switch.coffee_power_plug

Machine Settings (User's Configuration)

  • Brew temperature setpoint: 95°C (factory default)
  • Steam temperature setpoint: 145°C (factory max; factory default is 135°C)
  • Pre-infusion Button 1: OFF (confirmed by user)
  • Pre-infusion Button 2: unconfirmed (factory default is 6s)
  • Brewing time (auto-stop timer): OFF — machine runs in manual stop mode
  • Auto-standby: DISABLED (EU-mandated feature, toggled off by user)
  • Shot timer max: 99 seconds
  • Typical shot: Button 1, 18g in, ~25g out, ~23-32s

Typical Shot Workflow (for log interpretation)

  1. Button 4 (Hot water) — hot water to cup for warming
  2. Buttons 1+2 simultaneously — flush/purge to clear previous puck
  3. Button 1 — shot pull (stopped manually)
  4. Steam wand knob — milk steaming
  5. Steam wand knob briefly — purge wand to clean tip

Packet Protocol

Physical Layer

  • Packets transmitted continuously at 9600 baud, inverted signal
  • Two packet families coexist on the bus at all times:
    • 0xA0 family: 10-byte packets, header p[0] & 0xF0 == 0xA0 — carries temperatures and machine state
    • 0xFB family: 17-byte packets — present during ALL machine states including idle and extraction; carries shot detection data

The 0xA0 header nibble varies (A1, A3, A5, A7, A9, AB, AD, AF) — sequence counter, not meaningful for decoding. Mode byte is p[1].


0xA0 Packet Family — Fully Decoded

0xFF — Idle Mode

Machine is on and at temperature, not brewing.

Byte Formula Result
p[5] - 104 Steam temperature °C :white_check_mark: verified vs LCD
p[6] - 132 Brew temperature °C :white_check_mark: verified vs LCD

Example: A1 FF FB 43 01 F9 E3 01 FF 23

  • p[5]=0xF9=249 → 249-104 = 145°C steam :white_check_mark:
  • p[6]=0xE3=227 → 227-132 = 95°C brew :white_check_mark:

During active extraction, 0xFF packets stop entirely. The bus stays active with 0xFB packets only.

0x3F — Steam Boiler Heater Firing

Sent when the steam boiler PID is actively heating. Contains NO valid temperature data. p[2] is always 0xFF (heater-on flag). Ignore for sensor updates; still updates watchdog timestamp.

Example: AF 3F FF 83 01 FF 19 01 FB 2F

0x01 — Standby / Post-Shot Variant

Machine on but not brewing, or cooling after a shot.

Byte Formula Result
p[2] - 104 Steam temperature °C :white_check_mark:
p[3] - 132 Brew temperature °C :white_check_mark:

Example: AF 01 F9 A1 FF FF 25 FF DD 3F

  • p[2]=0xF9=249 → 249-104 = 145°C steam :white_check_mark:
  • p[3]=0xA1=161 → 161-132 = 29°C brew (cold machine) :white_check_mark:

0xFB Packet Family — Decoded :white_check_mark:

17-byte packets. Fixed positions: FB [p1] 01 F9 [p4] [p5] FF [p7] FF [p9] 3F FF [p12] 01 FD [p15] FF

Byte Meaning Values
p[1] Unknown, varies varies
p[3] Steam boiler temp (raw) 0xF9=249 always → 249-104=145°C
p[4] Countdown counter during shot 31→0→255→247 over ~32s; idle=0xE1=225→93°C brew
p[5] Shot discriminator 0xFF = active extraction; 0x01 = all other states
p[7] Profile byte 0x23 = Button 1; 0x21 = Button 2
p[12] Unknown varies
p[15] Shot-end signal Jumps to 0xFF at the exact moment the shot ends

Key findings:

  • p[5] == 0xFF uniquely identifies active extraction — all other states (flush, refill, steam, idle) have p[5] == 0x01
  • p[15] == 0xFF fires at shot end as a secondary signal
  • p[4] during idle: 0xE1=225 → 225-132=93°C (brew temp via same formula as 0xA0 idle)
  • p[4] during shot: counts down ~31→0→255→247 over a 32s shot (~1 unit/s). Machine is in manual-stop mode so this is not a countdown-to-auto-stop; likely a flow meter count or PID cycle counter

During extraction: 0xA0 packets stop entirely; only 0xFB packets flow. The brew window detection relies on this: last_packet_ms goes stale but last_any_packet_ms stays fresh.


Shot Detection Logic

Shot detection is implemented purely from 0xFB packet analysis:

  1. Shot start: p[5] transitions to 0xFF → set is_brewing = true, record brew_start_ms
  2. Shot end: p[5] leaves 0xFF OR p[15] == 0xFF → record elapsed time, clear is_brewing
  3. Timer: 1s interval lambda counts (millis() - brew_start_ms) / 1000 while is_brewing
  4. Machine off: If all bus activity stops for >5s, reset all globals

Log markers: SHOT START profile=0x23 and SHOT END: 32s


Temperature Offsets — Verified Values

Mode Sensor Byte Offset Notes
0xFF idle Brew p[6] -132 :white_check_mark: verified vs LCD
0xFF idle Steam p[5] -104 :white_check_mark: verified vs LCD (145°C setpoint)
0x01 standby Brew p[3] -132 :white_check_mark: verified vs LCD
0x01 standby Steam p[2] -104 :white_check_mark: verified vs LCD
0xFB any Steam p[3] -104 :white_check_mark: consistent (249→145°C)

Note: The steam offset of -104 is specific to a 145°C steam setpoint. Relationship: offset = raw_byte_value - actual_temp.


Known Issues / History

Resolved :white_check_mark:

  • Steam offset wrong: Was -103, then -124, corrected to -104 after verifying vs LCD at 145°C
  • Brew offset wrong: Was -145, corrected to -132
  • False Brewing ON during warm-up: 0x3F packets treated as brew mode — fixed by ignoring 0x3F
  • 255s ghost timer: p[4] = 0xFF passed as shot timer — fixed with range guard
  • Stale sensors after power-off: Fixed with watchdog that zeros globals when no packets received
  • Brew temp clamped to 0 on hot restart: Clamp raised to 140°C to handle post-shot overshoot (boiler can hit 113°C+ during extraction)
  • mDNS issues: Fixed by switching to static IP
  • Packet flood during extraction: Fixed with 20ms polling delay
  • Unknown mode bytes silently discarded: Added catch-all logger for discovery
  • False unknown state trigger: last_any_packet_ms global added to distinguish machine-off from extraction (where 0xA0 stops but 0xFB continues)
  • Brew window logic: Fixed trigger condition — was using last_any_packet_ms going stale (never happens during extraction), switched to last_packet_ms stale AND last_any_packet_ms fresh
  • Shot detection never fired on real shots: The 0xFF→0x01 transition never occurs on real shots — machine returns directly to 0xFF after extraction. Replaced with p[5]=0xFF discriminator in 0xFB packets
  • 0xFB packet family decoded: 17-byte structure fully mapped including shot discriminator, profile byte, shot-end signal

Pending verification :warning:

  • Shot detection end-to-end: Newly implemented. Needs a real shot to confirm SHOT START / SHOT END fire correctly and timer duration matches actual shot length
  • p[4] countdown precise meaning: Likely flow meter or PID cycle counter; exact unit unknown. Machine is in manual-stop mode so it's not a countdown-to-auto-stop

Future Enhancements

  • Profile text sensor: Expose p[7] as text_sensor → "Button 1" / "Button 2"
  • Last shot duration sensor: Capture final timer on shot end, expose to HA
  • HA machine state sensor: 3-state (off / standby / brewing) combining power plug + brewing binary sensor
  • HA template sensor: Suppress 0.0°C readings when machine is off
  • HA automation: Notify when shot duration is outside target range (e.g. <20s or >45s)
  • Strip debug logging: Remove ESP_LOGI calls and reduce logger level to WARN once shot detection is confirmed stable

Current ESPHome YAML

esphome:
  name: lelit-elizabeth-monitor
  friendly_name: Lelit Elizabeth Monitor
  on_boot:
    priority: -10
    then:
      - script.execute: uart_worker

esp32:
  board: m5stack-atom
  framework: { type: arduino }

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  fast_connect: true
  use_address: 10.1.2.39

api:
  encryption:
    key: !secret api_key

ota:
  - platform: esphome
    password: !secret ota_password

logger:
  level: DEBUG
  baud_rate: 0

time:
  - platform: sntp
    timezone: Pacific/Auckland
    servers:
      - 0.nz.pool.ntp.org
      - 1.nz.pool.ntp.org

uart:
  id: lelit_bus
  rx_pin:
    number: GPIO26
    inverted: true
  baud_rate: 9600
  rx_buffer_size: 1024

globals:
  - id: brew_temp
    type: float
    restore_value: no
    initial_value: '0.0'
  - id: steam_temp
    type: float
    restore_value: no
    initial_value: '0.0'
  - id: shot_timer
    type: int
    restore_value: no
    initial_value: '0'
  - id: is_brewing
    type: bool
    restore_value: no
    initial_value: 'false'
  - id: last_packet_ms
    type: uint32_t
    restore_value: no
    initial_value: '0'
  - id: last_any_packet_ms
    type: uint32_t
    restore_value: no
    initial_value: '0'
  - id: brew_start_ms
    type: uint32_t
    restore_value: no
    initial_value: '0'

sensor:
  - platform: template
    name: "Brew Temperature"
    id: brew_temp_sensor
    unit_of_measurement: "°C"
    device_class: temperature
    state_class: measurement
    accuracy_decimals: 1
    update_interval: never

  - platform: template
    name: "Steam Temperature"
    id: steam_temp_sensor
    unit_of_measurement: "°C"
    device_class: temperature
    state_class: measurement
    accuracy_decimals: 1
    update_interval: never

  - platform: template
    name: "Shot Timer"
    id: shot_timer_sensor
    unit_of_measurement: "s"
    state_class: measurement
    accuracy_decimals: 0
    update_interval: never

  - platform: uptime
    name: "Uptime Sensor"

  - platform: wifi_signal
    name: "WiFi Signal Sensor"
    update_interval: 60s

binary_sensor:
  - platform: status
    name: "Status"

  - platform: template
    name: "Brewing"
    id: brewing_sensor
    device_class: running

interval:
  - interval: 1s
    then:
      - lambda: |-
          uint32_t now = millis();

          // Machine-off watchdog: reset when all bus activity gone for 5s
          if (id(last_any_packet_ms) > 0 &&
              (now - id(last_any_packet_ms)) > 5000) {
            id(brew_temp) = 0.0f;
            id(steam_temp) = 0.0f;
            id(shot_timer) = 0;
            id(is_brewing) = false;
            id(last_packet_ms) = 0;
            id(last_any_packet_ms) = 0;
          }

          // Count elapsed shot time while extracting
          if (id(is_brewing)) {
            id(shot_timer) = (int)((now - id(brew_start_ms)) / 1000);
          }

          id(brew_temp_sensor).publish_state(id(brew_temp));
          id(steam_temp_sensor).publish_state(id(steam_temp));
          id(shot_timer_sensor).publish_state(id(shot_timer));
          id(brewing_sensor).publish_state(id(is_brewing));

script:
  - id: uart_worker
    mode: restart
    then:
      - while:
          condition:
            lambda: 'return true;'
          then:
            - lambda: |-
                while (id(lelit_bus).available() >= 10) {
                  uint8_t first;
                  id(lelit_bus).peek_byte(&first);

                  if ((first & 0xF0) != 0xA0) {
                    if (first == 0xFB) {
                      // FB packets are 17 bytes — wait until we have the full packet
                      if (id(lelit_bus).available() < 17) break;
                      uint8_t fb[17];
                      if (id(lelit_bus).read_array(fb, 17)) {
                        id(last_any_packet_ms) = millis();
                        // p[5]==0xFF uniquely identifies active extraction.
                        // All other states (flush, steam, refill) have p[5]==0x01.
                        bool extracting = (fb[5] == 0xFF);
                        if (extracting && !id(is_brewing)) {
                          id(is_brewing) = true;
                          id(brew_start_ms) = millis();
                          ESP_LOGI("lelit", "SHOT START profile=0x%02X", fb[7]);
                        } else if (id(is_brewing) && (!extracting || fb[15] == 0xFF)) {
                          // p[5] leaving 0xFF or p[15] hitting 0xFF both signal shot end
                          int elapsed = (int)((millis() - id(brew_start_ms)) / 1000);
                          id(shot_timer) = elapsed;
                          id(is_brewing) = false;
                          ESP_LOGI("lelit", "SHOT END: %ds (p5=0x%02X p15=0x%02X)", elapsed, fb[5], fb[15]);
                        }
                      }
                    } else {
                      uint8_t b;
                      id(lelit_bus).read_byte(&b);
                      id(last_any_packet_ms) = millis();
                    }
                    continue;
                  }

                  // 0xA0 packet — read 10 bytes and extract temperatures
                  uint8_t p[10];
                  if (id(lelit_bus).read_array(p, 10)) {
                    id(last_packet_ms) = millis();
                    id(last_any_packet_ms) = millis();

                    if (p[1] == 0xFF) {
                      // Idle mode: brew=p[6]-132, steam=p[5]-104
                      float b = (float)p[6] - 132.0f;
                      float s = (float)p[5] - 104.0f;
                      if (b > 0.0f && b < 140.0f) id(brew_temp) = b;
                      if (s > 0.0f && s < 160.0f) id(steam_temp) = s;
                    } else if (p[1] == 0x01) {
                      // Post-shot / standby: brew=p[3]-132, steam=p[2]-104
                      float b = (float)p[3] - 132.0f;
                      float s = (float)p[2] - 104.0f;
                      if (b > 0.0f && b < 140.0f) id(brew_temp) = b;
                      if (s > 0.0f && s < 160.0f) id(steam_temp) = s;
                    }
                  }
                }
            - delay: 20ms