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:
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.
substitutions:friendly_name:"Mr. Coffee"friendly_name_short:"Coffee"esphome:name:${hostname}esp8266:board:esp01_1mrestore_from_flash:falsepackages:# <...># Has ID: out_relay_1output:!include ../../packages/sonoff_s31_outlet/output_relay.yamlglobals:# Does the button toggle the relay- id:glbl_relay_latchedtype:boolrestore_value:noinitial_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_sectype:intrestore_value:yes# Note: value for demonstration purposes; do your own testing to determine appropriate value for your# environment / coffee makerinitial_value:"30"# How long do we pause to do a bloom for?- id:glbl_bloom_time_sectype:intrestore_value:yes# Note: value for demonstration purposes; do your own testing to determine appropriate value for your# environment / coffee makerinitial_value:"30"# And of course we need something to hold the timer value- id:_glbl_brew_timer_tickstype:intrestore_value:noinitial_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_tickstype:intrestore_value:noinitial_value:"0"### See: https://esphome.io/components/sensor/index.htmlsensor:- platform:cse7766update_interval:1s# <...>power:name:"${friendly_name_short} Power"accuracy_decimals:1# Needed for total daily calculationsid:s31_powerfilters:- 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_energypower_id:s31_powericon:"mdi:meter-electric-outline"# On the device page, HA will display a widget so we can adjust bloom timenumber:- name:"Pre Bloom Time"id:num_pre_bloom_secplatform:templateicon:"mdi:timer-sand"entity_category:"config"unit_of_measurement:secondsmode:slide# Note: value for demonstration purposes; do your own testing to determine appropriate value for your# environment / coffee makermin_value:10max_value:60step:2lambda:|- return (int) id(glbl_pre_bloom_time_sec);set_action:then:- globals.set:id:glbl_pre_bloom_time_secvalue:!lambda |-// TODO:we're relying on HA to pass an integer; perhaps we should do atoi() and catch any exceptionsreturn (int) x;- name:"Bloom Time"id:num_bloom_secplatform:templateicon:"mdi:timer-sand-paused"entity_category:"config"unit_of_measurement:secondsmode:slider# Note: value for demonstration purposes; do your own testing to determine appropriate value for your# environment / coffee makermin_value:10max_value:60step:2lambda:|- return (int) id(glbl_bloom_time_sec);set_action:then:- globals.set:id:glbl_bloom_time_secvalue:!lambda |-// TODO:we're relying on HA to pass an integer; perhaps we should do atoi() and catch any exceptionsreturn (int) x;# Show current phase (IDLE/BREW/BLOOM/NEEDS-CLEANING...etc)text_sensor:- name:"Mode"id:txt_operation_modeplatform:templateicon:"mdi:information-off-outline"entity_category:"diagnostic"# Will be called to update as necessary from other componentsupdate_interval:neverlambda:| // 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:templateid:sw_relay_modedevice_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_latchedvalue:"true"turn_off_action:- globals.set:id:glbl_relay_latchedvalue:"false"- name:"${friendly_name_short} Power"id:sw_relay_toggleplatform:outputoutput:out_relay_1icon:"mdi:coffee-maker-outline"restore_mode:ALWAYS_OFFdevice_class:"switch"# As soon as the power is turned on, start the timeron_turn_on:- script.execute:_brew_timer_tick# And if turned off, reseton_turn_off:- script.execute:_on_brew_turned_offbinary_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:gpiointernal:truepin:number:GPIO0mode:INPUT_PULLUPinverted:True# Press is momentary quickon_press:then:- if:condition:# If we are in latched modelambda:'return id(glbl_relay_latched);'then:- switch.toggle:id:sw_relay_toggleelse:- logger.log:level:DEBUGformat:"Button1 pressed but relays unlinked"# Every second we need to increment the timerscript:- id:_brew_timer_tick# Start a new run after previous runs completes. This will happen until timer.stop() is called on us##mode:queuedmax_runs:0then:# 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_offmode:singlethen:# 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:
sequence:# ...- service:number.set_valuedata:# 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_valuedata:value:"35.0"target:entity_id:number.bloom_time- type:turn_ondevice_id:#< Your Device ID Here >entity_id:switch.coffee_powerdomain:switch