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_temperaturesensor.lelit_elizabeth_monitor_steam_temperaturesensor.lelit_elizabeth_monitor_shot_timerbinary_sensor.lelit_elizabeth_v3_monitor_brewingsensor.coffee_power_plug_powerswitch.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)
- Button 4 (Hot water) — hot water to cup for warming
- Buttons 1+2 simultaneously — flush/purge to clear previous puck
- Button 1 — shot pull (stopped manually)
- Steam wand knob — milk steaming
- 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:
0xA0family: 10-byte packets, headerp[0] & 0xF0 == 0xA0— carries temperatures and machine state0xFBfamily: 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 |
| p[6] | - 132 |
Brew temperature °C |
Example: A1 FF FB 43 01 F9 E3 01 FF 23
- p[5]=
0xF9=249 → 249-104 = 145°C steam
- p[6]=
0xE3=227 → 227-132 = 95°C brew
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 |
| p[3] | - 132 |
Brew temperature °C |
Example: AF 01 F9 A1 FF FF 25 FF DD 3F
- p[2]=
0xF9=249 → 249-104 = 145°C steam
- p[3]=
0xA1=161 → 161-132 = 29°C brew (cold machine)
0xFB Packet Family — Decoded 
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] == 0xFFuniquely identifies active extraction — all other states (flush, refill, steam, idle) havep[5] == 0x01p[15] == 0xFFfires at shot end as a secondary signalp[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:
- Shot start:
p[5]transitions to0xFF→ setis_brewing = true, recordbrew_start_ms - Shot end:
p[5]leaves0xFFORp[15] == 0xFF→ record elapsed time, clearis_brewing - Timer: 1s interval lambda counts
(millis() - brew_start_ms) / 1000whileis_brewing - 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 | |
| 0xFF idle | Steam | p[5] | -104 | |
| 0x01 standby | Brew | p[3] | -132 | |
| 0x01 standby | Steam | p[2] | -104 | |
| 0xFB any | Steam | p[3] | -104 |
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 
- Steam offset wrong: Was
-103, then-124, corrected to-104after verifying vs LCD at 145°C - Brew offset wrong: Was
-145, corrected to-132 - False Brewing ON during warm-up:
0x3Fpackets treated as brew mode — fixed by ignoring0x3F - 255s ghost timer:
p[4] = 0xFFpassed 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_msglobal added to distinguish machine-off from extraction (where 0xA0 stops but 0xFB continues) - Brew window logic: Fixed trigger condition — was using
last_any_packet_msgoing stale (never happens during extraction), switched tolast_packet_msstale ANDlast_any_packet_msfresh - 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]=0xFFdiscriminator in 0xFB packets - 0xFB packet family decoded: 17-byte structure fully mapped including shot discriminator, profile byte, shot-end signal
Pending verification 
- Shot detection end-to-end: Newly implemented. Needs a real shot to confirm
SHOT START/SHOT ENDfire 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]astext_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_LOGIcalls 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
