In theory, every time you go to the coffee machine for your morning cup, there’s only a one-in-twenty chance you’ll have to fill the water tank. In practice, however, it seems that the machine somehow finds a way to always put this chore on you. The more you want coffee, the more likely you are to get the dreaded “fill the water tank” message. My colleagues feel the same way about this. Being the nerds that we are, we decided to implement the technology that would put an end to this.Our EquipmentOur Goals
- Use an electric pump driven by some kind of a controller or a microcomputer through a relay.
- Have a way to measure the water level in the coffee machine’s tank so our system knows when to refill it.
- Have means to control the system, preferably in real-time from a mobile device.
- Receive notifications (through Slack or a similar service) if anything goes wrong with the system.
A quick web search will show several electric pump models designed for your water bottle of choice. Such pumps are usually controlled by an ON/OFF switch (for example, Hot Frost A12 or SMixx ХL-D2). Here’s the pump we chose for our project:
We tried several devices but settled on a Raspberry Pi due to the following advantages:
- It has a GPIO that allows us to connect a proximity sensor
- It supports Python
We installed a fresh version of Raspbian Buster Lite and everything required to run Python 3.How We Toggle the Pump
To control the power, we picked a medium power (12V/2A) solid state relay suited for alternating current.
The relay connects the pump to the outlet and is controlled by the Raspberry Pi’s digital pin.How We Check the Water Level
It was important for us to not alter the coffee machine’s construction, so we decided to use the HC-SR04 Ultrasonic proximity sensor to measure the water level.
We 3d-printed a custom water tank cover with two holes for the sensor’s emitters.
We easily found a GitHub library for the sensor.
At this point all preparations were finished.2. Designing and Running the SystemSystem’s Logic
The system is designed with the following simple logic in mind:
- The system constantly monitors the distance between the sensor and the water surface.
- Whenever a change in distance goes over a threshold value, the system sends information about its state to the cloud.
- If the distance goes over the maximum allowed value (the tank is empty), the system activates the pump and turns it off once the distance is less than the minimum allowed value.
- Whenever the system’s state changes (for example, the pump activates), it informs the cloud.
In case of an error, a notification is sent to a Slack channel.
When the coffee machine is idle, the system pings the cloud service with diagnostic data once every minute. Additionally, it sends its state to the cloud every 5 minutes.
When the pump is active, the system sends data more frequently but no more than once every half a second.
Working with the Pump
def send(cloud, variables, dist, error_code=0, force=False): pump_on = is_pump_on() percent = calc_water_level_percent(dist) variables['Distance']['value'] = dist variables['WaterLevel']['value'] = percent variables['PumpRelay']['value'] = pump_on variables['Status']['value'] = calc_status(error_code, percent, pump_on) current = time() global last_sending_time if force or current - last_sending_time > MIN_SEND_INTERVAL: readings = cloud.read_data() cloud.publish_data(readings) last_sending_time = current
We define the following constants as a base for pump operation logic.
# GPIO Pins (BCM) GPIO_PUMP = 4 GPIO_TRIGGER = 17 GPIO_ECHO = 27 # Pump START_PUMP = 1 STOP_PUMP = 0 PUMP_BOUNCE_TIME = 50 # milliseconds PUMP_STOP_TIMEOUT = 5 # secs
IMPORTANT: If you are going to use Pin 4, do not forget to disable the 1-Wire raspi-config option to avoid conflicts.
At the program’s startup, we register a callback and set the initial state to OFF.
GPIO.setmode(GPIO.BCM) GPIO.setup(GPIO_PUMP, GPIO.IN) GPIO.add_event_detect(GPIO_PUMP, GPIO.BOTH, callback=pump_relay_handle, bouncetime=PUMP_BOUNCE_TIME) toggle_pump(STOP_PUMP)
Here’s the code for the function that toggles the pump:
def toggle_pump(value): if pump_disabled: return if is_pump_on() != value: log_debug("[x] %s" % ('START' if value else 'STOP')) GPIO.setup(GPIO_PUMP, GPIO.OUT) GPIO.output(GPIO_PUMP, value) # Start/Stop pouring
As defined in the startup code above, when the relay turns ON, the following callback is called:
pump_on = False def pump_relay_handle(pin): global pump_on pump_on = GPIO.input(GPIO_PUMP) log_debug("Pump relay changed to %d" % pump_on)
In the callback, we save the pump’s current state to a variable.
In the application’s main loop, we can detect the moment when the pump toggles as shown below:
Measuring the Distance
def is_pump_on(): global pump_on return pump_on if GPIO.event_detected(GPIO_PUMP): is_pouring = is_pump_on() # ... log_debug('[!] Pump event detected: %s' % ('On' if is_pouring else 'Off')) send(cloud, variables, distance, force=True)
It’s quite easy to measure the distance towards the water surface using an ultrasonic proximity sensor. In our repository, we shared a couple of python scripts that allow you to test a sensor.
In real applications, sensor readings can fluctuate because of the sensor’s bouncing effect and water oscillations. In some cases, readings can be completely missing.
We implemented a BounceFilter class that accumulates N recent values, discards peaks and calculates the average of remaining measurements.
The measurement process is implemented by the following asynchronous algorithm.
# Keeps the last sensor measurements readings = BounceFilter(size=6, discard_count=1) reading_complete = threading.Event() def wait_for_distance(): reading_complete.clear() thread = threading.Thread(target=read_distance) thread.start() if not reading_complete.wait(MAX_READING_TIMEOUT): log_info('Reading sensor timeout') return None return readings.avg() def read_distance(): try: value = hcsr04.raw_distance(sample_size=5) rounded = value if value is None else round(value, 1) readings.add(rounded) except Exception as err: log_error('Internal error: %s' % err) finally: reading_complete.set()
You can find the filter’s full implementation in the sources.Handling Emergency Situations
What if the sensor burned out, or fell off, or points to a wrong area? We needed a way to report such cases so that we can take manual action.
If the sensor fails to provide distance readings, the system sends the changed status to the cloud and generates a corresponding notification.
The logic is illustrated by the code below.
distance = wait_for_distance() # Read the current water depthif distance is None: log_error('Distance error!') notify_in_background(calc_alert(SENSOR_ERROR)) send(cloud, variables, distance, error_code=SENSOR_ERROR, force=True)
We have an operational water level range that should be maintained when the sensor is in its place. We test if the current water level falls in this range:
# Distance from the sensor to the water level # based on the coffee-machine's water tank MIN_DISTANCE = 2 # cm MAX_DISTANCE = 8 # cm # Distance is out of expected range: do not start pouring if distance > MAX_DISTANCE * 2: log_error('Distance is out of range: %.2f' % distance) continue
We turn the pump off if it was active when an error occurred.
if is_pump_on() and prev_distance < STOP_PUMP_DISTANCE + DISTANCE_DELTA: log_error('[!] Emergency stop of the pump. No signal from a distance sensor') toggle_pump(STOP_PUMP)
We also process the case when the bottle runs out of water. We check if the water level does not change when the pump runs. If so, the system waits for 5 seconds and then checks if the pump has turned off. If it has not, then the system implements emergency pump shutdown and sends an error notification.
PUMP_STOP_TIMEOUT = 5 # secs emergency_stop_time = Nonedef set_emergency_stop_time(now, is_pouring): global emergency_stop_time emergency_stop_time = now + PUMP_STOP_TIMEOUT if \ is_pouring else Nonedef check_water_source_empty(now): return emergency_stop_time and now > emergency_stop_time # --------- main loop ----------- if GPIO.event_detected(GPIO_PUMP): is_pouring = is_pump_on() set_emergency_stop_time(now, is_pouring) # ... global pump_disabled if check_water_source_empty(now): log_error('[!] Emergency stop of the pump. \ Water source is empty') toggle_pump(STOP_PUMP) pump_disabled = True
Below is an example of a message log generated during an emergency stop.
The code on the device is debugged and runs without problems. We launched it as a service, so it restarts if the Raspberry Pi is rebooted. For convenience, we created a Makefile that helps with deployment, running the service and viewing logs.
.PHONY: install run start stop status log deploy MAIN_FILE:= coffee-pump/main.py SERVICE_INSTALL_SCRIPT:= service_install.sh SERVICE_NAME:= coffee-pump.service install: chmod +x $(SERVICE_INSTALL_SCRIPT) sudo ./$(SERVICE_INSTALL_SCRIPT) $(MAIN_FILE) run: sudo python3 $(MAIN_FILE) start: sudo systemctl start $(SERVICE_NAME) status: sudo systemctl status $(SERVICE_NAME) stop: sudo systemctl stop $(SERVICE_NAME) log: sudo journalctl -u coffee-pump --since today deploy: rsync -av coffee-pump sensor-setup Makefile *.sh pi@XX.XX.XXX.XXX:~/
We used Cloud4RPi to implement a control panel. We first added widgets to indicate the systems essential parameters.
(By the way, the widget for the STATUS variable can use different color schemes based on its value.)
We added a chart widget to display dynamic data. In the image below you can see the moment the pump turned ON and OFF and respective water levels.
If you analyze a longer time span, you can see peaks — that’s when the pump was running.
Cloud4RPi also allows you to set different smoothing levels.
It works! The control panel in its entirety looks as shown below.
Currently, our automatic pump has been running for several weeks and all we needed to do is replace water bottles. The full code for our project is available in our GitHub repository.Demo videoBonus