iKettle 3.0 ESP32 Conversion

The Smarter.am cloud service is (for all intents and purposes) dead. AFAIK, all Smarter products are therefore no longer ‘smart’. Long live ESPHome!

With help from the information in this guide, I’ve successfully converted my Smarter iKettle 3.0 to ESP32/ESPHome. My project is a bit more ambitious. I’ve completely replaced the logic board within the iKettle 3.0, and have attempted to replicate all of its functions (with one exception), including:

  1. No change to appearance/no additional wires
  2. Retain original power button and power LED
  3. RGB LED to show WiFi connection status
  4. HX711 to read the load cell to weigh the kettle to estimate fill level
  5. Buzzer to indicate turn on/off or on error
  6. Connect to and read kettle thermistor for auto-off/thermostatic control
    I haven’t bothered trying to replicate the light sensor/LDR that interprets flashes from your phone’s screen to upload WiFi connection details for initial setup.

DISCLAIMER: Probably don’t do this. I’m no expert. I’m scraping by with an electronics GCSE from 1999, ChatGPT, a multimeter, basic soldering iron and awareness of the potential dangers. That said, such a disclaimer wouldn’t have stopped me, so I’m sharing my work for likeminded people.

Opening up the iKettle base, you’ll find a 3-wire connector joining the power board and the logic board. These three wires are a 5v supply, GND and connection to the positive side of the relay coil. Bridging the 5v and relay control wires causes the relay to click on and would provide power to the kettle element.

So, safety aside, this is a pretty easy conversion. We can leave the power board alone, and build something that can read the kettle temp and connect 5v to the relay control wire to turn on/disconnect to turn off.

Parts list:

  • ESP32-S3 Mini
  • HX711 ADC/pressure sensor to measure weight
  • RGB LED (common cathode) for status indication
  • White LED for power indication
  • Active buzzer for the beep
  • PN2222 transistor
  • 104pF ceramic cap
  • 3 x 330 ohm resistors
  • 1 x 47k resistor for the thermistor (10k might be better - not sure)
  • Right angle push button
  • JST-XH connector kit
  • Assorted multicoloured wire

Tools:

  • Soldering iron
  • Philips screwdriver
  • Wire strippers
  • Multimeter (to check continuity across joined connections/voltages)
  • 2.54mm crimp tool
  • Dremel to cut perf board
  • Thermometer (to read water temp and tune the thermistor values accordingly)

I started by attempting to cut a perf board to roughly the same shape as the original iKettle logic board:

I then attempted to situate the components in the only way I could get them all to fit. I had to sacrifice one of the screw holes by the status LED, and had to cut the standoff to make room for my new board.

I’ve neglected to take a photo of the underside of my board, but honestly that wouldn’t necessarily help because it’s a bit of a mess.

Here’s what my board looks like installed. I soldered JST-XH connectors onto the board for the connection to the power board, HX711 and thermistor so that it can be removed easily. The load cell and resistors required to create a ‘half bridge’ (see diagram) are soldered to the HX711 itself:


The HX711 is just kinda placed inside, unsecured.

Ignore the second transistor next to the buzzer. It’s not needed.

Here’s my circuit diagram:

Note that the thermistor is not on the board, it’s inside the kettle itself. On the board I have a 2-pin HX-2A connector, which re-uses the existing connector. The same applies to the 5v/GND/Relay connections - I’ve soldered an HX-3A to the board and am re-using the existing connector.

…and here’s my YAML:

esphome:
  name: smart_kettle
  friendly_name: "Smart Kettle"

  on_boot:
    priority: -100
    then:
      - script.execute: boot_led_sequence

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

logger:
  level: info

api:
  encryption:
    key: !secret api_key

ota:
  - platform: esphome
    password: !secret ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  on_connect:
    then:
      - script.execute: wifi_connected

captive_portal:

web_server:
  port: 80

status_led:
  pin: GPIO48

globals:
  - id: kettle_on
    type: bool
    restore_value: no
    initial_value: "false"

switch:
  - platform: gpio
    id: rgb_red
    pin: GPIO6
    internal: true

  - platform: gpio
    id: rgb_green
    pin: GPIO5
    internal: true

  - platform: gpio
    id: rgb_blue
    pin: GPIO4
    internal: true

  - platform: gpio
    id: power_led
    pin: GPIO10
    internal: true

  - platform: gpio
    id: buzzer
    pin: GPIO12
    internal: true

  - platform: gpio
    id: relay
    pin: GPIO13
    restore_mode: ALWAYS_OFF
    internal: true
    on_turn_on:
      - globals.set:
          id: kettle_on
          value: "true"
      - script.execute: relay_safety_timer
    on_turn_off:
      - globals.set:
          id: kettle_on
          value: "false"
      - switch.turn_off: power_led

  - platform: template
    name: "Smart Kettle"
    icon: mdi:kettle
    lambda: |-
      return id(kettle_on);
    turn_on_action:
      - script.execute: kettle_on_request
    turn_off_action:
      - script.execute: kettle_off_sequence

binary_sensor:
  - platform: gpio
    id: power_button
    pin:
      number: GPIO11
      mode: INPUT_PULLUP
      inverted: true
    on_press:
      - script.execute: kettle_toggle_request

  - platform: template
    id: kettle_on_base
    name: "Kettle On Base"
    lambda: |-
      return id(load_cell).state < -80000;
    on_release:
      then:
        - if:
            condition:
              lambda: 'return id(kettle_on);'
            then:
              - script.execute: kettle_off_sequence

sensor:
  - platform: hx711
    id: load_cell
    name: "Load Cell"
    dout_pin: GPIO7
    clk_pin: GPIO8
    update_interval: 200ms
    filters:
      - median:
          window_size: 7
          send_every: 3
    unit_of_measurement: "g"
    accuracy_decimals: 0
    internal: true

  - platform: template
    name: "Kettle Fill Percentage"
    lambda: |-
      float w = id(load_cell).state;
      float pct = (w + 119000.0) / (-445000.0 + 119000.0) * 100.0;
      if (pct < 0) pct = 0;
      if (pct > 100) pct = 100;
      return pct;
    unit_of_measurement: "%"
    update_interval: 2s

  - platform: adc
    pin: GPIO9
    id: adc_ntc
    attenuation: 11db
    update_interval: 2s

  - platform: resistance
    id: ntc_res
    sensor: adc_ntc
    resistor: 47kOhm
    configuration: DOWNSTREAM

  - platform: ntc
    id: kettle_temp_raw
    sensor: ntc_res
    calibration:
      b_constant: 3931
      reference_resistance: 50.9kOhm
      reference_temperature: 25

  - platform: template
    id: kettle_temp
    name: "Kettle Temperature"
    lambda: |-
      return (id(kettle_temp_raw).state + 16.7) * (85.8 / 67.7) + 14.2;
    unit_of_measurement: "°C"
    accuracy_decimals: 1
    update_interval: 2s
    filters:
      - lambda: |-
          if (x < 0 || x > 120) return NAN;
          return x;
      - sliding_window_moving_average:
          window_size: 5
          send_every: 1
    on_value:
      then:
        - if:
            condition:
              lambda: 'return x >= 100.0 && id(kettle_on);'
            then:
              - script.execute: kettle_off_sequence

script:

  - id: kettle_toggle_request
    then:
      - if:
          condition:
            lambda: 'return id(kettle_on);'
          then:
            - script.execute: kettle_off_sequence
          else:
            - script.execute: kettle_on_request

  - id: kettle_on_request
    then:
      - if:
          condition:
            and:
              - binary_sensor.is_on: kettle_on_base
              - lambda: 'return id(kettle_temp).state < 100.0;'
          then:
            - switch.turn_on: relay
            - switch.turn_on: power_led
            - switch.turn_on: buzzer
            - delay: 200ms
            - switch.turn_off: buzzer
          else:
            - script.execute: error_beep

  - id: relay_safety_timer
    mode: restart
    then:
      - delay: 120s
      - if:
          condition:
            lambda: 'return id(kettle_on);'
          then:
            - script.execute: kettle_off_sequence

  - id: kettle_off_sequence
    then:
      - switch.turn_off: relay
      - switch.turn_off: power_led
      - repeat:
          count: 3
          then:
            - switch.turn_on: buzzer
            - delay: 250ms
            - switch.turn_off: buzzer
            - delay: 250ms

  - id: error_beep
    then:
      - repeat:
          count: 5
          then:
            - switch.turn_on: buzzer
            - delay: 250ms
            - switch.turn_off: buzzer
            - delay: 250ms

  - id: boot_led_sequence
    mode: restart
    then:
      - while:
          condition:
            not:
              wifi.connected:
          then:
            - switch.turn_on: rgb_blue
            - delay: 250ms
            - switch.turn_off: rgb_blue
            - delay: 250ms

  - id: wifi_connected
    then:
      - switch.turn_off: rgb_blue
      - switch.turn_on: rgb_green

For testing, you’ll want to set comment out the ‘internal: true’ lines so that all the controls/sensor values will be displayed on the web portal for testing/tuning, and then you can update the YAML accordingly. To tune the load cell and thermistor readings, paste the YAML into ChatGPT and say ‘help me to correct these sensor values’, and provide the readings when the kettle is on/off the base, empty versus full, room temp water versus boiling, etc.), it’ll do the work for you.

There are no doubt many improvements that could be made to the code. Feel free to let me know.

I spent a good few days on this, all so I can once again control my kettle via HA/Alexa. Was it worth it? Of course!

Well done, brillant project for a dead kettle as the smarter devices are at the minute… I need to do this aswell.

Well done! Glad to see someone took this on! Kudos. I have a few questions if you get a mo:

  • I know the incentive wasn’t price but more likely satisfaction and challenge but how much would you say the conversion cost you once you had it all worked out?

  • Did you manage to retain the functionality to set a specific temperature via alexa for people that aren’t using nabu casa but are instead manually alexa connected? I believe there might be a limitation where alexa (manually connected) users wouldn’t be able to pass alexa commands that use custom variables, like passing “set kettle to 92c” But im not a 100% sure on that. I suppose a work around would be to create a load of scripts for every possible degree in celcius/fahrenheit… but it seems messy.

  • What are the chances of a custom, drop-in PCB or do you think that’s outside of the scope you set for this project for yourself?

  • Were any of the kettles IP rating’s affected in the disassembly of the base? or was it just a simple snap together base with no gaskets or seals to worry about ruining?

Again, congrats on breathing new life into this old device, I think how smatter.am handled the bricking of these devices should have a spotlight brightly shone on it to save others the pain of dealing with their products - Can you believe they still sell this as a WIFI connected smart kettle on their website? False advertising of the highest order.

Well done! Glad to see someone took this on! Kudos.

Thanks!

I have a few questions if you get a mo:

  • I know the incentive wasn’t price but more likely satisfaction and challenge but how much would you say the conversion cost you once you had it all worked out?

£15-20 or so… I had most of the components already from an Arduino kit I bought years ago, but they’d be pennies. The ESP32 I used was about £12 on Amazon.

  • Did you manage to retain the functionality to set a specific temperature via alexa for people that aren’t using nabu casa but are instead manually alexa connected? I believe there might be a limitation where alexa (manually connected) users wouldn’t be able to pass alexa commands that use custom variables, like passing “set kettle to 92c” But im not a 100% sure on that. I suppose a work around would be to create a load of scripts for every possible degree in celcius/fahrenheit… but it seems messy.

I haven’t yet tried, but it would be simple, i.e. instead of turn off at 100 degrees celcius, turn off at value of ‘x’

  • What are the chances of a custom, drop-in PCB or do you think that’s outside of the scope you set for this project for yourself?

I’ve started having a go at replicating my board using EasyEDA, but I am a complete novice. I’m hoping to request a review of my design once I’ve had a go and see if I can get some expert advice.

  • Were any of the kettles IP rating’s affected in the disassembly of the base? or was it just a simple snap together base with no gaskets or seals to worry about ruining?

The base just clicks together, however I did have to make an extra hole. I don’t know that this impacts the IP rating, however, because it’s definitely not sealed well anyway.

Again, congrats on breathing new life into this old device, I think how smatter.am handled the bricking of these devices should have a spotlight brightly shone on it to save others the pain of dealing with their products - Can you believe they still sell this as a WIFI connected smart kettle on their website? False advertising of the highest order.

Absolutely agree. I’m contemplating writing to trading standards about their practices.

Im short on time the next while, but im an electrical engineer facing the same issue as you with this ikettle, and im hoping we can all put our efforts together to write a better esphome firmware, and design a custom pcb as a drop in replacement fix to replace the current controller in the affected ikettles that parent company Smarter has left out to die with us by turning off the servers and ignoring us.

Im hoping somebody creates a clearly named github repo that i can contribute to. Id be interested in cleaning up the code and potentially (if i have the time) help design a high quality pcb with decent safety features.

If i ever have the spare time, and nobody has taken the role of repo manager onto him, then i might will myself.

Also wondering if louis rossman would be of help in making this situation a bit more public?

Hoping all of this gets the Smarter Ikettle outtage under our own local control if a github repo gets some traction.

Sorry for such a slow reply, been struggling to find time to compile what I’ve been working on with respect to this.

Firstly, I’ve made a number of improvements to the YAML (thanks, Claude.ai):

  1. Button debounce (500ms) — prevented rapid repeated presses registering as multiple on/off commands.
  2. Predictive boil timer — added a time-based shutoff as a fallback for when steam makes the temp sensor unreliable, calculated from fill level and starting temperature using a fitted formula.
  3. Estimated boil time sensor — exposed the countdown to HA, ticking down while heating and showing a live idle estimate when off.
  4. On-base detection via temp sensor — the NTC goes NaN when the jug is removed, making it a more direct indicator than the load cell alone.
  5. Status message text sensor — exposed the last shutoff reason or turn-on refusal reason to HA as a persistent string.
  6. HX711 settle wait replaced with fill>0 poll — the fixed 5s delay before snapshotting fill was unreliable; polling until fill registers >0 fires as soon as the sensor is ready.
  7. Low water shutoff (<10% fill) — added an immediate shutoff if fill drops below 10% while heating, with a distinct status message.
  8. On-base logic corrected to OR/AND — either sensor alone (temp valid or weight present) is sufficient to consider the jug present; both must fail simultaneously to trigger removal.
  9. Idle estimate stability filter — the idle estimate now only updates every 30s and only if temperature hasn’t shifted more than 5°C since the last accepted value, preventing steam noise from corrupting the displayed estimate after boiling.
  10. Idle estimate sentinel — last_estimate_temp initialised to -999 so the very first idle update is always accepted regardless of temperature delta.
  11. Fill calibration corrected — applied a two-step linear correction (raw→calibrated) fitted from three measured data points to fix the fill percentage readout.
  12. Tare button — added a HA button entity that captures the current load cell reading as the zero offset, stored in flash. Rejected if the temp sensor is invalid (jug not on base).
  13. Tare exposed as button not switch — corrected the entity type to ESPHome’s native button platform.
  14. Status sensor moved to text_sensor: — it was incorrectly placed in sensor: which only accepts numeric lambdas, causing a compile error.
  15. Hex escape sequences removed — \xc2\xb0 and \xe2\x80\x94 in string literals caused compiler errors; replaced with plain ASCII.
  16. Dynamic boil estimate while heating — the countdown was previously purely time-based; it now recalculates every 2s from live temperature, reflecting actual heating progress.
  17. Steam noise rejection during heating — dynamic updates are rejected if temperature drops more than 2°C from the last accepted reading, with countdown_tick carrying the display during noisy periods.
  18. Monotonic check — after 5s from turn-on, dynamic estimates that are equal to or greater than the current remaining value are rejected, preventing upward jumps in the countdown.
  19. Target temperature — added a HA number entity (slider, 40–100°C) so the kettle can target temperatures below boiling; all shutoff logic and time estimates updated to use this value.
  20. shutoff_pending latch — prevented multiple concurrent shutoff triggers (the on_value automation fires every 2s, so without a latch multiple parallel 5s delays stacked up and called kettle_off_sequence repeatedly).
  21. countdown_tick triggers shutoff at zero — previously only Phase 2 of the estimator could trigger boil_time_reached, but the monotonic check blocked it when the temperature wasn’t rising fast enough; countdown_tick now exits its loop at zero and fires boil_time_reached directly.
esphome:
  name: smart_kettle
  friendly_name: "Smart Kettle"

  on_boot:
    priority: -100
    then:
      - script.execute: boot_led_sequence
      - script.execute: idle_estimate_updater

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

logger:
  level: info

api:
  encryption:
    key: !secret api_key

ota:
  - platform: esphome
    password: !secret ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  on_connect:
    then:
      - script.execute: wifi_connected

captive_portal:

web_server:
  port: 80

status_led:
  pin: GPIO48

# ======================
# GLOBALS
# ======================
globals:
  - id: kettle_on
    type: bool
    restore_value: no
    initial_value: "false"

  # Debounce flag: true while ignoring repeated button presses
  - id: button_debounce_active
    type: bool
    restore_value: no
    initial_value: "false"

  # Stores the calculated boil time limit in seconds (set 5s after kettle turn-on)
  - id: boil_time_limit
    type: float
    restore_value: no
    initial_value: "300"

  # Countdown value published to HA (seconds); ticks down while kettle is on,
  # shows live estimate when kettle is off
  - id: boil_time_remaining
    type: float
    restore_value: no
    initial_value: "300"

  # Last valid temperature reading — used to avoid publishing NAN to HA
  # when the kettle jug is off the base
  - id: last_valid_temp
    type: float
    restore_value: no
    initial_value: "20.0"

  # Temperature at the time the idle estimate was last calculated.
  # Used by idle_estimate_updater to reject updates when temp has shifted
  # more than 5 deg C (indicating instability or steam noise).
  - id: last_estimate_temp
    type: float
    restore_value: no
    initial_value: "-999.0"

  # HX711 zero-point offset captured by the tare button.
  # Replaces the hardcoded 119000 in the fill percentage formula.
  # restore_value: yes so it survives reboots.
  - id: tare_offset
    type: float
    restore_value: yes
    initial_value: "119000.0"

  # Last temperature accepted as plausible during active heating.
  # Rejects steam noise: temp must not drop >2 deg C from this value.
  # Reset to sentinel -999 at turn-on so first reading is always accepted.
  - id: last_accepted_temp
    type: float
    restore_value: no
    initial_value: "-999.0"

  # Set to true 5 s after relay turn-on; enables the monotonic check
  # that rejects dynamic estimates >= the current boil_time_remaining
  # (i.e. estimates that are not decreasing).
  - id: monotonic_check_active
    type: bool
    restore_value: no
    initial_value: "false"

  # Target temperature in deg C. Range 40-100, default 100 (full boil).
  # Persisted to flash so it survives reboots.
  # Also exposed as a HA number entity (slider) via the number: platform.
  - id: target_temp
    type: float
    restore_value: yes
    initial_value: "100.0"

  # Latch to prevent multiple concurrent shutoff triggers (e.g. from
  # on_value firing every 2 s while temp stays above target).
  # Set to true by the first shutoff path that fires; reset at turn-on.
  - id: shutoff_pending
    type: bool
    restore_value: no
    initial_value: "false"

  # Status message string published to HA
  - id: kettle_status
    type: std::string
    restore_value: no
    initial_value: '""'

# ======================
# OUTPUTS (INTERNAL)
# ======================
switch:
  - platform: gpio
    id: rgb_red
    pin: GPIO6
    internal: true

  - platform: gpio
    id: rgb_green
    pin: GPIO5
    internal: true

  - platform: gpio
    id: rgb_blue
    pin: GPIO4
    internal: true

  - platform: gpio
    id: power_led
    pin: GPIO10
    internal: true

  - platform: gpio
    id: buzzer
    pin: GPIO12
    internal: true

  - platform: gpio
    id: relay
    pin: GPIO13
    restore_mode: ALWAYS_OFF
    internal: true
    on_turn_on:
      - globals.set:
          id: kettle_on
          value: "true"
      # Reset sentinels so first readings after turn-on are always accepted
      - globals.set:
          id: last_estimate_temp
          value: "-999.0"
      - globals.set:
          id: last_accepted_temp
          value: "-999.0"
      - globals.set:
          id: monotonic_check_active
          value: "false"
      - globals.set:
          id: shutoff_pending
          value: "false"
      - script.stop: idle_estimate_updater
      - script.execute: relay_safety_timer
      - script.execute: boil_time_estimator
      - script.execute: countdown_tick
      - script.execute: enable_monotonic_check
    on_turn_off:
      - globals.set:
          id: kettle_on
          value: "false"
      - switch.turn_off: power_led
      - script.stop: countdown_tick

  # ======================
  # EXPOSED HA SWITCH
  # ======================
  - platform: template
    name: "Smart Kettle"
    icon: mdi:kettle
    lambda: |-
      return id(kettle_on);
    turn_on_action:
      - script.execute: kettle_on_request
    turn_off_action:
      then:
        - globals.set:
            id: kettle_status
            value: '"Turned off manually"'
        - script.execute: kettle_off_sequence

# ======================
# BUTTON & BASE SENSOR
# ======================
button:
  - platform: template
    name: "Tare Load Cell"
    icon: mdi:scale-balance
    on_press:
      - script.execute: perform_tare

binary_sensor:
  - platform: gpio
    id: power_button
    pin:
      number: GPIO11
      mode: INPUT_PULLUP
      inverted: true
    on_press:
      then:
        # Ignore press if debounce window is still active (500 ms)
        - if:
            condition:
              lambda: 'return !id(button_debounce_active);'
            then:
              - globals.set:
                  id: button_debounce_active
                  value: "true"
              - script.execute: kettle_toggle_request
              - delay: 500ms
              - globals.set:
                  id: button_debounce_active
                  value: "false"

  # On-base detection:
  #   Present  (on_press allowed) : temp valid OR weight present — either is enough
  #   Removed  (triggers shutoff) : temp invalid AND weight absent — both must fail
  - platform: template
    id: kettle_on_base
    name: "Kettle On Base"
    lambda: |-
      bool temp_valid = !isnan(id(kettle_temp_corrected).state);
      bool weight_present = id(load_cell).state < -80000;
      return temp_valid || weight_present;
    on_release:
      then:
        - if:
            condition:
              lambda: 'return id(kettle_on);'
            then:
              - globals.set:
                  id: kettle_status
                  value: '"Removed from base"'
              - script.execute: kettle_off_sequence

# ======================
# SENSORS
# ======================
sensor:
  - platform: hx711
    id: load_cell
    name: "Load Cell"
    dout_pin: GPIO7
    clk_pin: GPIO8
    update_interval: 200ms
    filters:
      - median:
          window_size: 7
          send_every: 3
    unit_of_measurement: "g"
    accuracy_decimals: 0
    internal: true

  - platform: template
    name: "Kettle Fill Percentage"
    id: fill_pct_sensor
    lambda: |-
      // Step 1: raw percentage from load cell using tare offset
      float w = id(load_cell).state;
      float raw_pct = (w + id(tare_offset)) / (-445000.0f + id(tare_offset)) * 100.0f;
      // Step 2: linear calibration correction fitted from measured points:
      //   readout 16% -> actual 0%, 60% -> 50%, 100% -> 95%
      //   actual = 1.1310 * raw_pct - 18.095
      float pct = 1.1310f * raw_pct - 18.095f;
      if (pct < 0) pct = 0;
      if (pct > 100) pct = 100;
      return pct;
    unit_of_measurement: "%"
    update_interval: 2s
    on_value:
      then:
        # Shut off immediately if water is critically low while heating.
        # We also require kettle_on_base to be true so that a not-yet-settled
        # load cell reading (which may briefly read near 0) after the jug is
        # placed on the base does not falsely trigger this.
        - if:
            condition:
              lambda: 'return id(kettle_on) && id(kettle_on_base).state && x < 10.0f;'
            then:
              - globals.set:
                  id: kettle_status
                  value: '"Turned off: low water level"'
              - script.execute: kettle_off_sequence

  - platform: adc
    pin: GPIO9
    id: adc_ntc
    attenuation: 11db
    update_interval: 2s

  - platform: resistance
    id: ntc_res
    sensor: adc_ntc
    resistor: 47kOhm
    configuration: DOWNSTREAM

  - platform: ntc
    id: kettle_temp_raw
    sensor: ntc_res
    calibration:
      b_constant: 3931
      reference_resistance: 50.9kOhm
      reference_temperature: 25

  # Internal corrected temperature — may be NAN when jug is off base.
  # Used directly by kettle_on_base and as the source for kettle_temp.
  - platform: template
    id: kettle_temp_corrected
    internal: true
    lambda: |-
      return (id(kettle_temp_raw).state + 16.7) * (85.8 / 67.7) + 14.2;
    unit_of_measurement: "°C"
    accuracy_decimals: 1
    update_interval: 2s
    filters:
      # Reject impossible values (ADC glitches or jug off base)
      - lambda: |-
          if (x < 0 || x > 120) return NAN;
          return x;
      # Smooth short ADC spikes without slowing response too much
      - sliding_window_moving_average:
          window_size: 5
          send_every: 1
    on_value:
      then:
        # Keep last_valid_temp updated whenever we have a real reading
        - lambda: |-
            if (!isnan(x)) {
              id(last_valid_temp) = x;
            }
        # Shutoff when target temperature is reached.
        # shutoff_pending latch prevents multiple on_value firings from
        # stacking up parallel 5 s delays and calling kettle_off_sequence
        # repeatedly.
        - if:
            condition:
              lambda: 'return x >= id(target_temp) && id(kettle_on) && !id(shutoff_pending);'
            then:
              - globals.set:
                  id: shutoff_pending
                  value: "true"
              - delay: 5s
              - lambda: |-
                  char buf[56];
                  snprintf(buf, sizeof(buf), "Reached target temperature (%.0f deg C)", id(target_temp));
                  id(kettle_status) = std::string(buf);
              - script.execute: kettle_off_sequence

  # HA-facing temperature sensor: always publishes a number using the last
  # valid reading when the jug is off the base, so it never shows as
  # unavailable in Home Assistant.
  - platform: template
    id: kettle_temp
    name: "Kettle Temperature"
    lambda: |-
      float corrected = id(kettle_temp_corrected).state;
      if (isnan(corrected)) {
        return id(last_valid_temp);
      }
      return corrected;
    unit_of_measurement: "°C"
    accuracy_decimals: 1
    update_interval: 2s

  # Countdown sensor (seconds).
  # - Kettle ON:  ticks down in real-time via countdown_tick script.
  # - Kettle OFF: shows the last stable estimate maintained by
  #               idle_estimate_updater (updated every 30 s when temp is stable).
  # boil_time_remaining is the single source of truth for both states.
  - platform: template
    name: "Estimated Boil Time"
    id: boil_time_sensor
    lambda: |-
      return id(boil_time_remaining);
    unit_of_measurement: "s"
    accuracy_decimals: 0
    update_interval: 2s
    icon: mdi:timer-outline

# ======================
# NUMBER ENTITIES
# ======================
number:
  # Target temperature slider — exposed to HA as a number entity.
  # Range 40-100 deg C, step 1. Synced to the target_temp global so
  # all formula and shutoff logic picks it up immediately.
  - platform: template
    name: "Target Temperature"
    id: target_temp_number
    icon: mdi:thermometer-chevron-up
    unit_of_measurement: "deg C"
    min_value: 40
    max_value: 100
    step: 1
    mode: slider
    lambda: |-
      return id(target_temp);
    set_action:
      - globals.set:
          id: target_temp
          value: !lambda 'return x;'

# ======================
# TEXT SENSORS
# ======================
text_sensor:
  # Status message sensor — surfaces the last event reason in HA.
  # Shows "Heating" while kettle is on; otherwise the last recorded reason.
  - platform: template
    name: "Kettle Status"
    id: kettle_status_sensor
    lambda: |-
      return id(kettle_status);
    update_interval: 2s
    icon: mdi:information-outline

# ======================
# SCRIPTS
# ======================
script:

  - id: kettle_toggle_request
    then:
      - if:
          condition:
            lambda: 'return id(kettle_on);'
          then:
            - globals.set:
                id: kettle_status
                value: '"Turned off manually"'
            - script.execute: kettle_off_sequence
          else:
            - script.execute: kettle_on_request

  - id: kettle_on_request
    then:
      - if:
          condition:
            and:
              - binary_sensor.is_on: kettle_on_base
              - lambda: 'return id(last_valid_temp) < id(target_temp);'
          then:
            - globals.set:
                id: kettle_status
                value: '"Heating"'
            - switch.turn_on: relay
            - switch.turn_on: power_led
            - switch.turn_on: buzzer
            - delay: 100ms
            - switch.turn_off: buzzer
          else:
            # Distinguish the two failure reasons
            - lambda: |-
                if (!id(kettle_on_base).state) {
                  id(kettle_status) = std::string("Not on base");
                } else {
                  char buf[48];
                  snprintf(buf, sizeof(buf), "Already at target temp (%.0f deg C)", id(target_temp));
                  id(kettle_status) = std::string(buf);
                }
            - script.execute: error_beep

  - id: relay_safety_timer
    mode: restart
    then:
      - delay: 240s
      - if:
          condition:
            lambda: 'return id(kettle_on);'
          then:
            - globals.set:
                id: kettle_status
                value: '"Turned off: safety time limit reached"'
            - script.execute: kettle_off_sequence

  # Predictive boil time estimator.
  #
  # Phase 1 - Initial snapshot:
  #   Polls every 500 ms until fill > 0 (HX711 settled), then snapshots
  #   fill and starting temperature to set boil_time_limit (hard ceiling)
  #   and seeds boil_time_remaining.
  #
  # Phase 2 - Dynamic recalculation loop (every 2 s while kettle is on):
  #   Recalculates remaining time from live temperature so the countdown
  #   tracks actual heating progress. A reading is only accepted if temp
  #   has NOT dropped more than 2 deg C from the last accepted value -
  #   larger drops are steam noise; countdown_tick carries the display
  #   until the next plausible reading arrives.
  #
  # Phase 3 - Hard ceiling: relay_safety_timer (240 s) acts as final backstop.
  #
  # Model: t = 3.051 * fill_fraction * (100 - T) + 3.7  (x1.15 safety margin)
  - id: boil_time_estimator
    mode: restart
    then:
      # Phase 1: poll until load cell returns a valid non-zero fill reading
      - while:
          condition:
            lambda: |-
              float raw_w = id(load_cell).state;
              float raw_pct = (raw_w + id(tare_offset)) / (-445000.0f + id(tare_offset)) * 100.0f;
              float pct = 1.1310f * raw_pct - 18.095f;
              return pct <= 0.0f;
          then:
            - delay: 500ms
      - lambda: |-
          float raw_w = id(load_cell).state;
          float raw_pct = (raw_w + id(tare_offset)) / (-445000.0f + id(tare_offset)) * 100.0f;
          float fill_pct = 1.1310f * raw_pct - 18.095f;
          if (fill_pct < 0.0f)   fill_pct = 0.0f;
          if (fill_pct > 100.0f) fill_pct = 100.0f;

          float t_start = id(last_valid_temp);
          if (t_start < 0.0f) t_start = 20.0f;
          float t_target = id(target_temp);
          if (t_start >= t_target) t_start = t_target - 0.5f; // guard: already at target

          float estimate = 3.051f * (fill_pct / 100.0f) * (t_target - t_start) + 3.7f;
          estimate *= 1.15f;
          if (estimate < 1.0f)   estimate = 1.0f;
          if (estimate > 320.0f) estimate = 320.0f;

          id(boil_time_limit)     = estimate;
          id(boil_time_remaining) = estimate;
          id(last_accepted_temp)  = t_start;

          ESP_LOGI("kettle", "Boil estimate (initial): fill=%.1f%% t_start=%.1f target=%.0f deg C limit=%.0fs",
                   fill_pct, t_start, t_target, estimate);
      # Phase 2: dynamic recalculation every 2 s using live temp
      - while:
          condition:
            lambda: 'return id(kettle_on);'
          then:
            - delay: 2s
            - lambda: |-
                float t_now = id(last_valid_temp);

                // Reject if temp dropped more than 2 deg C - steam noise
                bool first     = (id(last_accepted_temp) < -900.0f);
                bool plausible = first || (t_now >= id(last_accepted_temp) - 2.0f);

                if (!plausible) {
                  ESP_LOGI("kettle", "Dynamic estimate skipped: temp dropped %.1f deg C (noise)",
                           id(last_accepted_temp) - t_now);
                  return;
                }

                float raw_w = id(load_cell).state;
                float raw_pct = (raw_w + id(tare_offset)) / (-445000.0f + id(tare_offset)) * 100.0f;
                float fill_pct = 1.1310f * raw_pct - 18.095f;
                if (fill_pct < 0.0f)   fill_pct = 0.0f;
                if (fill_pct > 100.0f) fill_pct = 100.0f;

                float t_target  = id(target_temp);
                float remaining = 3.051f * (fill_pct / 100.0f) * (t_target - t_now) + 3.7f;
                remaining *= 1.15f;

                id(last_accepted_temp) = t_now;

                if (remaining <= 0.0f) {
                  // Estimated time to target reached — hand off to 5 s grace script
                  if (!id(shutoff_pending)) {
                    id(shutoff_pending) = true;
                    id(boil_time_remaining) = 0.0f;
                    ESP_LOGI("kettle", "Dynamic estimate reached zero, starting 5 s grace period");
                    id(boil_time_reached).execute();
                  }
                  return;
                }

                // Monotonic check: once active (5 s after turn-on), reject
                // any estimate >= current value — timer should only decrease
                if (id(monotonic_check_active) && remaining >= id(boil_time_remaining)) {
                  ESP_LOGI("kettle", "Dynamic estimate skipped (monotonic): new=%.0fs >= current=%.0fs",
                           remaining, id(boil_time_remaining));
                  return;
                }

                id(boil_time_remaining) = remaining;
                ESP_LOGI("kettle", "Dynamic estimate: temp=%.1f target=%.0f remaining=%.0fs",
                         t_now, t_target, remaining);

  # Ticks boil_time_remaining down by 1 every second while the kettle is on.
  # When it reaches zero it fires boil_time_reached (the 5 s grace period
  # script) and stops itself — this covers the case where Phase 2 of
  # boil_time_estimator is blocked by the monotonic check and can't detect
  # the zero crossing itself.
  - id: countdown_tick
    mode: restart
    then:
      - while:
          condition:
            lambda: 'return id(kettle_on) && id(boil_time_remaining) > 0.0f;'
          then:
            - delay: 1s
            - lambda: |-
                id(boil_time_remaining) -= 1.0f;
                if (id(boil_time_remaining) < 0.0f) id(boil_time_remaining) = 0.0f;
      # Loop exited because remaining hit zero (not because kettle turned off)
      - if:
          condition:
            lambda: 'return id(kettle_on) && !id(shutoff_pending);'
          then:
            - globals.set:
                id: shutoff_pending
                value: "true"
            - globals.set:
                id: boil_time_remaining
                value: "0.0"
            - script.execute: boil_time_reached

  # Idle boil time estimator.
  #
  # Runs continuously while the kettle is off, waking every 30 s to
  # recalculate the estimated boil time from current fill level and
  # temperature — but only committing the result if the temperature has
  # not shifted more than 5 deg C since the last accepted update.
  # This prevents steam-induced ADC noise (which can cause large transient
  # spikes) from corrupting the displayed estimate.
  #
  # Restarted immediately by kettle_off_sequence so a fresh reading is
  # taken shortly after each boil rather than waiting up to 30 s.
  - id: idle_estimate_updater
    mode: restart
    then:
      # Short settling delay before the first sample after kettle turn-off
      - delay: 5s
      - while:
          condition:
            lambda: 'return !id(kettle_on);'
          then:
            - lambda: |-
                float raw_w = id(load_cell).state;
                float raw_pct = (raw_w + id(tare_offset)) / (-445000.0f + id(tare_offset)) * 100.0f;
                float fill_pct = 1.1310f * raw_pct - 18.095f;
                if (fill_pct < 0.0f)   fill_pct = 0.0f;
                if (fill_pct > 100.0f) fill_pct = 100.0f;

                // Reject if load cell hasn't settled — near-zero fill produces
                // ~4 s from the formula's constant term alone (b=3.7 * 1.15 = 4.25)
                if (fill_pct <= 0.0f) {
                  ESP_LOGI("kettle", "Idle estimate skipped: fill not yet valid (%.1f%%)", fill_pct);
                  return;
                }

                float t_now = id(last_valid_temp);

                // On first run (sentinel value) always accept; thereafter reject
                // if temperature has shifted more than 5 deg C since the last
                // accepted estimate, which indicates steam noise or instability.
                bool first_run = (id(last_estimate_temp) < -900.0f);
                bool temp_stable = (fabsf(t_now - id(last_estimate_temp)) <= 5.0f);
                if (!first_run && !temp_stable) {
                  ESP_LOGI("kettle", "Idle estimate skipped: temp delta %.1f deg C exceeds threshold",
                           fabsf(t_now - id(last_estimate_temp)));
                } else {
                  float t_target = id(target_temp);
                  if (t_now >= t_target) {
                    id(boil_time_remaining) = 0.0f;
                  } else {
                    float estimate = 3.051f * (fill_pct / 100.0f) * (t_target - t_now) + 3.7f;
                    estimate *= 1.15f;
                    if (estimate < 1.0f)   estimate = 1.0f;
                    if (estimate > 320.0f) estimate = 320.0f;
                    id(boil_time_remaining) = estimate;
                    ESP_LOGI("kettle", "Idle estimate updated: fill=%.1f%% temp=%.1f target=%.0f deg C -> %.0fs",
                             fill_pct, t_now, t_target, estimate);
                  }
                  id(last_estimate_temp) = t_now;
                }
            - delay: 30s

  # Grace period script — fired when the dynamic boil estimate reaches zero.
  # Immediately stops the estimator and countdown so neither can overwrite
  # boil_time_remaining or re-trigger during the 5 s wait.
  # mode: single means if it somehow fires twice it won't double-wait.
  - id: boil_time_reached
    mode: single
    then:
      # Stop estimator and tick immediately — prevents Phase 2 from
      # overwriting boil_time_remaining back to a positive value, and
      # prevents countdown_tick from decrementing below zero.
      - script.stop: boil_time_estimator
      - script.stop: countdown_tick
      - globals.set:
          id: boil_time_remaining
          value: "0.0"
      - delay: 5s
      - if:
          condition:
            lambda: 'return id(kettle_on);'
          then:
            - globals.set:
                id: kettle_status
                value: '"Boiled (estimated time limit reached)"'
            - script.execute: kettle_off_sequence

  - id: kettle_off_sequence
    then:
      # Cancel all active timing scripts when kettle stops for any reason
      - script.stop: boil_time_estimator
      - script.stop: boil_time_reached
      - script.stop: enable_monotonic_check
      - script.stop: countdown_tick
      # Turn off relay first — this sets kettle_on = false via on_turn_off,
      # which must happen before idle_estimate_updater starts so its while
      # condition (!kettle_on) is true when first evaluated.
      - switch.turn_off: relay
      - switch.turn_off: power_led
      # Now safe to start idle estimator — kettle_on is guaranteed false
      - script.execute: idle_estimate_updater
      - repeat:
          count: 3
          then:
            - switch.turn_on: buzzer
            - delay: 50ms
            - switch.turn_off: buzzer
            - delay: 150ms

  # Tare the load cell: captures the current raw HX711 reading as the new
  # zero-point offset (empty kettle on base).
  # Rejected if the temperature sensor is not reporting a valid value,
  # since that indicates the kettle jug is not on the base.
  - id: perform_tare
    then:
      - if:
          condition:
            lambda: 'return isnan(id(kettle_temp_corrected).state);'
          then:
            - globals.set:
                id: kettle_status
                value: '"Tare failed: kettle not on base"'
            - logger.log: "Tare rejected — temp sensor invalid (jug not on base)"
          else:
            - lambda: |-
                float raw = id(load_cell).state;
                id(tare_offset) = -raw;
                ESP_LOGI("kettle", "Tare complete: raw=%.0f new offset=%.0f", raw, id(tare_offset));
            - globals.set:
                id: kettle_status
                value: '"Tare complete"'

  # Activates the monotonic check 5 s after relay turn-on.
  # This grace period prevents the check from firing during the initial
  # snapshot phase before the estimate has stabilised.
  - id: enable_monotonic_check
    mode: restart
    then:
      - delay: 5s
      - globals.set:
          id: monotonic_check_active
          value: "true"

  - id: error_beep
    then:
      - repeat:
          count: 5
          then:
            - switch.turn_on: buzzer
            - delay: 100ms
            - switch.turn_off: buzzer
            - delay: 250ms

  - id: boot_led_sequence
    mode: restart
    then:
      - while:
          condition:
            not:
              wifi.connected:
          then:
            - switch.turn_on: rgb_blue
            - delay: 250ms
            - switch.turn_off: rgb_blue
            - delay: 250ms

  - id: wifi_connected
    then:
      - switch.turn_off: rgb_blue
      - switch.turn_on: rgb_green

I’ve then had a go at replicating the basic PCB outline in Onshape: Onshape

With a view to replicating the PCB design in EasyEDA: https://pro.easyeda.com/editor#id=74997d54dbac45738f3bbbbf28d64649

I’m very much feeling my way around in the dark, but if this helps someone to do what I lack the knowledge to do, then great!

Well done! Looking good.

@Ben_Terry yours is a lot tidier than mine. I didn’t want to give up the smarter board in mine as it does things like control the cut out and I figured there may be some safety features I missed. I think all smart tech should use esp modules and be able to be integrated into smarthomes. Companies that don’t do this are missing out on enthusists!

I’m giving up waiting for Smarter to do the honourable thing. This amazing project is now on the list, thank you for sharing! I was going too do an ESP thing to just turn it on and off but all that fancy control has got me excited again.

Progress so far!

Excellent stuff! But it looks like your kettle has an ESP32 soldered to the board, so you may even be able to just flash the firmware and skip the whole custom PCB altogether?! Yours is different to mine!

hey, cool Project !!

here is my version of going to ESPHome.

Hey Ben,

why did you use 5v for HX711 and not 3.3v as ESP level?

This is amazing. Thanks for sharing! I wish I had the smarts to design a drop-in replacement PCB, sadly I do not!

Regarding 3.3/5v for the HX711, I read that it could be powered by either, so I went with 5 because it was easier to wire up and solder in my layout. That may not be optimal, I honestly don’t know! But it worked for me.

as i know, if you power the HX711 at 5v, the HX output signal will also be 5v, that is not optimal for 3.3v ESP32. It might work, but it might strain or even burn the GPIO.

At first i wanted to reuse the original kettle scale circuit, but it turned out to come analog to ESP and it was too sensitive so the readings drifted (or i didn’t reverse engineer it correctly). Finally i moved to HX711