Contents

Integrating a dumb coffee maker with Home Assistant via ESPHome

My beloved coffee maker of 10 years has finally died 😢. Parts are no longer available from either the manufacturer or the second-hand market.

Taking advantage of a (slight) holiday sale discount, I pulled the trigger on a coffee maker that’s designed to be repairable forever. The perpetual serviceability is a side effect of an ultra-simple design; this coffee maker has zero intelligent features which means there’s next to no remote control or customizability.

Adding Home Assistant integration

Fortunately, we can add a decent amount of control with a basic smart outlet. Naturally, I went with the amazing Sonoff S31 but you can get similar results with any ESPHome compatible device so long as you have a way to control power to the coffee maker and to monitor the power used by the coffee maker.

This borrows a technique that I first wrote about in Dynamic timers in ESPHome and tweaks it a bit to add of coffee-specific automations:

  • Run the boiler for a moment, then pause. This is to let the coffee bloom.
  • Turn relay off automatically after a period of no power use. This is done locally for safety and to save me the hassle of putting a template “is coffee brewing” sensor in home assistant. I still report energy data to home assistant for statistical purposes but don’t use the data for automations.

Here’s what that looks like:

S31 powered coffee maker in Home Assistant. Note that the bloom settings can be adjusted on the fly.

S31 powered coffee maker in Home Assistant. Note that the bloom settings can be adjusted on the fly.

ESPHome

Below is a simplified ESPHome configuration file that resembles what I use “in production”. As is, you’ll need to fill it out / add in your own versions of some of the unique-to-me details intentionally omitted; these will be the device/component id: values, mostly.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
substitutions:
  friendly_name: "Mr. Coffee"
  friendly_name_short: "Coffee"

esphome:
  name: ${hostname}

esp8266:
  board: esp01_1m

  restore_from_flash: false

packages:
  # <...>
  # Has ID: out_relay_1
  output: !include ../../packages/sonoff_s31_outlet/output_relay.yaml

globals:
  # Does the button toggle the relay
  - id: glbl_relay_latched
    type: bool
    restore_value: no
    initial_value: "true"

  ##
  # Bloom settings
  ##
  # How long do we run the heater for?
  # The time from power on to first water in basket depends on water temperature.
  # The cooler the water, the longer it'll be.
  # For now, the intent is to use other sensors to estimate the water temp and have HA
  #   update the configuration before starting a brew.
  #
  # Maybe in a future version, outfit with water level/temp probe directly.
  ##
  - id: glbl_pre_bloom_time_sec
    type: int
    restore_value: yes
    # Note: value for demonstration purposes; do your own testing to determine appropriate value for your
    #   environment / coffee maker
    initial_value: "30"

  # How long do we pause to do a bloom for?
  - id: glbl_bloom_time_sec
    type: int
    restore_value: yes
    # Note: value for demonstration purposes; do your own testing to determine appropriate value for your
    #   environment / coffee maker
    initial_value: "30"

  # And of course we need something to hold the timer value
  - id: _glbl_brew_timer_ticks
    type: int
    restore_value: no
    initial_value: "0"

  # We need a place to store the number of ticks that we have observed low power while brewing.
  - id: _glbl_brew_low_power_ticks
    type: int
    restore_value: no
    initial_value: "0"

##
# See: https://esphome.io/components/sensor/index.html
sensor:
  - platform: cse7766
    update_interval: 1s
    # <...>
    power:
      name: "${friendly_name_short} Power"
      accuracy_decimals: 1
      # Needed for total daily calculations
      id: s31_power
      filters:
        - or:
            - throttle: 300s
            # Publish every time there's been more than 3.5W change
            - delta: 3.5

  # Do the integration locally so HA does not have to
  # See: https://esphome.io/components/sensor/total_daily_energy.html
  ##
  - name: "${friendly_name_short} Daily Energy"
    platform: total_daily_energy
    power_id: s31_power
    icon: "mdi:meter-electric-outline"

# On the device page, HA will display a widget so we can adjust bloom time
number:
  - name: "Pre Bloom Time"
    id: num_pre_bloom_sec
    platform: template
    icon: "mdi:timer-sand"
    entity_category: "config"
    unit_of_measurement: seconds
    mode: slide
    # Note: value for demonstration purposes; do your own testing to determine appropriate value for your
    #   environment / coffee maker
    min_value: 10
    max_value: 60
    step: 2

    lambda: |-
      return (int) id(glbl_pre_bloom_time_sec);      

    set_action:
      then:
        - globals.set:
            id: glbl_pre_bloom_time_sec
            value: !lambda |-
              // TODO: we're relying on HA to pass an integer; perhaps we should do atoi() and catch any exceptions
              return (int) x;

  - name: "Bloom Time"
    id: num_bloom_sec
    platform: template
    icon: "mdi:timer-sand-paused"
    entity_category: "config"
    unit_of_measurement: seconds
    mode: slider
    # Note: value for demonstration purposes; do your own testing to determine appropriate value for your
    #   environment / coffee maker
    min_value: 10
    max_value: 60
    step: 2

    lambda: |-
      return (int) id(glbl_bloom_time_sec);      

    set_action:
      then:
        - globals.set:
            id: glbl_bloom_time_sec
            value: !lambda |-
              // TODO: we're relying on HA to pass an integer; perhaps we should do atoi() and catch any exceptions
              return (int) x;

# Show current phase (IDLE/BREW/BLOOM/NEEDS-CLEANING...etc)
text_sensor:
  - name: "Mode"
    id: txt_operation_mode
    platform: template
    icon: "mdi:information-off-outline"
    entity_category: "diagnostic"
    # Will be called to update as necessary from other components
    update_interval: never
    lambda: |
      // This lambda function should never be called / we should never update the text sensor this way
      return {"Unknown"};      

# Not sure if useful but including it anyways.
switch:
  - name: "${friendly_name_short} Relay Latch"
    platform: template
    id: sw_relay_mode
    device_class: "switch"
    icon: "mdi:link-variant"
    entity_category: "config"

    lambda: |-
      if (id(glbl_relay_latched)) {
        return true;
      } else {
        return false;
      }      

    turn_on_action:
      - globals.set:
          id: glbl_relay_latched
          value: "true"

    turn_off_action:
      - globals.set:
          id: glbl_relay_latched
          value: "false"

  - name: "${friendly_name_short} Power"
    id: sw_relay_toggle
    platform: output
    output: out_relay_1
    icon: "mdi:coffee-maker-outline"
    restore_mode: ALWAYS_OFF
    device_class: "switch"

    # As soon as the power is turned on, start the timer
    on_turn_on:
      - script.execute: _brew_timer_tick

    # And if turned off, reset
    on_turn_off:
      - script.execute: _on_brew_turned_off

binary_sensor:
  # Make the manual activation button behave the same as "stock".
  # May use for some future functionality:
  #   - Triple click to indicate that I want to descale / ignore bloom settings
  #   - Double click to indicate that the water / grounds have been refreshed
  ##
  - name: "${friendly_name_short} Button"
    platform: gpio
    internal: true
    pin:
      number: GPIO0
      mode: INPUT_PULLUP
      inverted: True

    # Press is momentary quick
    on_press:
      then:
        - if:
            condition:
              # If we are in latched mode
              lambda: 'return id(glbl_relay_latched);'
            then:
              - switch.toggle:
                  id: sw_relay_toggle

            else:
              - logger.log:
                  level: DEBUG
                  format: "Button1 pressed but relays unlinked"

# Every second we need to increment the timer
script:
  - id: _brew_timer_tick
    # Start a new run after previous runs completes. This will happen until timer.stop() is called on us
    ##
    mode: queued
    max_runs: 0
    then:
      # A single 'tick' is 1 second long
      - delay: 1s
      - lambda: |-
          /*
            Called every second while relay is on.
            Compare number of invocations to user settings to figure out which phase we should be in.
            Update the text sensor and turn switch on/off as needed.
          */
          auto const static TAG = "lambda._brew_timer_tick";
          // Number of seconds post bloom with low power before we transition from brewing to idle.
          static int idle_ticks_threshold = 30;
          static int num_ticks = 0;
          static int total_bloom_time_sec = 0;
          static bool post_bloom = false;
          total_bloom_time_sec = (id(glbl_bloom_time_sec) + id(glbl_pre_bloom_time_sec));
          ESP_LOGD(TAG, "Idle. num_ticks: %i, total_bloom_time_sec: %i", num_ticks, total_bloom_time_sec);

          // Count execution
          id(_glbl_brew_timer_ticks) += 1;
          num_ticks = id(_glbl_brew_timer_ticks);

          // If the switch has been turned off - for any reason - we cancel any scheduled executions and don't continue
          if( !id(sw_relay_toggle) ) {
            id(_brew_timer_tick).stop();
            return;
          }

          // If the brew timer ticks counter is 0, assume IDLE
          if (num_ticks == 0) {
            ESP_LOGE(TAG, "Idle. _glbl_brew_timer_ticks: %i", _glbl_brew_timer_ticks);
            id(txt_operation_mode).publish_state("Idle");
          }

          // If we are between 1 and glbl_pre_bloom_time_sec then brewing -> Blooming
          if( (num_ticks >= 1 ) && (num_ticks <= id(glbl_pre_bloom_time_sec)) ) {
            id(txt_operation_mode).publish_state("Pre Bloom");
          }

          // If we are between glbl_pre_bloom_time_sec and glbl_bloom_time_sec then blooming.
          if( (num_ticks > id(glbl_pre_bloom_time_sec) ) && (num_ticks <= total_bloom_time_sec ) ) {
            id(txt_operation_mode).publish_state("Blooming");
            // Note, we turn the relay off directly and not the switch. We want the switch / web UI to still show "on/brewing".
            // The text sensor will show that we are blooming
            id(out_relay_1).turn_off();
          }

          // Otherwise we are brewing.
          if(num_ticks > total_bloom_time_sec ) {
            id(txt_operation_mode).publish_state("Brewing");
            id(out_relay_1).turn_on();
            post_bloom = true;
          }

          // After bloom, start counting the number of times we observe low power.
          // After T seconds of no power use, we assume that we're out of water and reset everything.
          ESP_LOGD(TAG, "post bloom: %i, Current power consumption: %f Watts", post_bloom, id(s31_power).state);
          if ( post_bloom && (id(s31_power).state < 2)) {
            id(_glbl_brew_low_power_ticks) +=1;
            ESP_LOGD(TAG, "_glbl_brew_low_power_ticks: %i", id(_glbl_brew_low_power_ticks));
          }

          if (id(_glbl_brew_low_power_ticks) >= idle_ticks_threshold){
            ESP_LOGD(TAG, "Assuming done with brew as _glbl_brew_low_power_ticks: %i >= %i", id(_glbl_brew_low_power_ticks), idle_ticks_threshold);
            id(sw_relay_toggle).turn_off();
            return;
          }

          //re-schedule so we're called again in a second!
          id(_brew_timer_tick).execute();          


  - id: _on_brew_turned_off
    mode: single
    then:
    # Disable timer, have the text sensor update
    - lambda: |-
        id(_glbl_brew_timer_ticks) = 0;
        id(_glbl_brew_low_power_ticks) = 0;
        id(txt_operation_mode).publish_state("Idle");
        id(_brew_timer_tick).stop();        

Home Assistant automations

When some automation determines that it’s now time to start making coffee, this is a portion of the script that is executed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21

sequence:
  # ...
- service: number.set_value
    data:
      # This is a bit simplified; my template does some more maths to map the temperature to a more precise value.
      # I simplified the value template here to illustrate how the adjustable timers are meant to be used.
      value: >-
        {% if states('sensor.kitchen_temperature')|float(-1) < 25 %}35{% else
        %}20{% endif %}        
    target:
      entity_id: number.pre_bloom_time
  - service: number.set_value
    data:
      value: "35.0"
    target:
      entity_id: number.bloom_time
  - type: turn_on
    device_id: #< Your Device ID Here >
    entity_id: switch.coffee_power
    domain: switch