Skip to main content

Automating a Fridge Water Dispenser

·15 mins

We’re fortunate to have several first-world conveniences in our house; one of them being a fridge that can dispense cold water. A few years ago we had need to replace our kitchen fridge, and the new fridge’s water dispenser was much slower than the previous fridge. Much, much slower.

So it was a few weeks ago when my youngest daughter (8 at time of writing) said to me, it’d be great if we could just put the water bottle under the fridge, press the button, and have it beep when the water bottle was full. My response to this was, you’ve piqued my interest, and leave this with me for a little bit…

The project goals #

Extrapolating slightly from my daughters request, and working within what I felt would be the best way for this to work with the parts that I have, we came up with these goals:

  • Be able to place a cup or bottle on the dispenser;
  • Select how much to fill the container (in our case, 350ml, 500ml, or 750ml to meet the most common requirements).
  • Have the device automatically start filling and then stop filling when the target fill was reached.
  • Have a high Wife Acceptance factor by being out of the way, and not modifying the fridge’s internals.
  • Work on our Samsung SRS675DLS fridge.

Seems simple enough.

The basic solution #

Based on what was in the spare parts collection, I came up with the following, after some thought.

  • Determine the volume of water dispensed by weight, given that for our purposes, 1 gram can be considered 1ml of water. And I also had a spare 20kg load cell and matching HX711 amplifier left over from some DIY postage scales that was in production at our former business for 6 years. I did consider an optical sensor but the weight works best as it takes the actual container out of the equation.
  • Activate the fridge’s water button with a tiny SG90 micro servo to actually dispense the water, meaning that no wiring needed to be changed on the fridge. This does mean that the dispenser can’t switch between the water and ice modes and you have to make sure you’ve got this correct; the ice/water buttons are a capacitive sensor and will be hard to operate without pulling apart the wiring. In practice, we’ve found that the ice dispenser has a delay of around 5 seconds which allows you to realise your mistake and switch the fridge back into water mode.
  • Use several illuminated push buttons (left over from another project), a TM1637 module with four seven segment digits (also left over), and an ESP32 development board (also left over) to connect the whole thing together.
  • Use ESPHome to program the system, as I’m really enjoying it’s robust code-generating capabilities, rather than spending lots of time gluing other pieces of code together and resulting with something that crashes every few days.

Let’s start with the hardware design #

Step one was to fire up Fusion 360 and to model the ice and water dispenser, so I could then go ahead and work out where to place all the components, and even if I could fit in all the components in the space that was available. So this is the rough model that I came up with - it’s close to correct - certainly enough for me to be able to continue with the design:

Fridge Cubby Model
Fridge Cubby Model

And then add in my parts:

Fridge Cubby Model with Parts
Fridge Cubby Model with Parts

Apologies for the all grey design; I didn’t fuss too much with making it pretty as that didn’t affect the function of the drawing.

But here is a description of this fairly straightforward design:

  • The load cell is a metal bar at the front. The plate under the side controls is a separate piece so I can print it with the right orientation on the 3D printer.
  • The plate that the water bottle will sit on is a separate part, and is designed to be just 1mm above the bottom of fridge alcove. It doesn’t flex more than this under weight, and I wanted to keep as much vertical height under the dispenser as possible.
  • The servo is positioned so in theory it will trigger the switch when it’s moved towards the push button at the back of the alcove.
  • The side controls are hollow behind to mount the ESP32 expansion board and the HX711 amplifier board.
  • The side controls panel (that includes the servo) is printed as a separate piece and screwed to the base plate. This allowed me to switch it out more easily without reprinting everything, although it’s a major component.
  • The side control panel has the buttons (to match the buttons that I have) and a way to mount the TM1637 module I happened to have.
  • My intended way for this to mount without modifying the fridge was to have it basically squeeze itself into the alcove height. But I didn’t trust my model measurements, so behind the top of the side panel is actually a 20x40 V Slot extrusion, that I can push up to the top and then tighten to keep it in place. In production, this almost worked but wasn’t secure enough; turns out a piece of wide elastic borrowed from my wifes sewing collection was enough to get the whole assembly to grip in place.

And here is behind the side panel:

Behind the side panel
Behind the side panel

Of note here is that the base plate is offset about 2mm away from the alcove. This is because my measurements were not exact and I didn’t expect the curve to match the fridges curve. For this reason, I printed this part first to check, and it had enough clearance without being silly, so I left it as is and didn’t make any other adjustments to the fit.

From this view, you can see where I allocated spots for the ESP32 board and the HX711 board, and how the TM1637 module would mount. This part printed standing up in the printer, so chamfers were added to the boss holes so I didn’t need supports. The screws (M3, except for the TM1637 and the Servo which needed M2) were designed to be self threading, as this seems to be giving me some really quick and easy solutions with my chosen PETG material.

The design files won’t match your fridge or setup; but I provide them here as a hopefully helpful reference for you to see what I’ve done. You can get the Fusion 360 version or the STEP version.

And now make it real… #

A few hours (well, 12 hours) of 3D printing time later on my Bambu Labs P1S, and only around 250g of black PETG filament, we had all the parts. Due to spending some time on the CAD design, everything fit together the first time. A rarity for me, but I’ll take it.

The wiring looks a bit rough, but it’s all contained behind the side cover. The most rough part of the wiring is that all the push buttons have a pair of wires for the button and a second set of wires for the internal LED.

I should have designed a channel to hide the load cell wires, rather than just having them duck around the side, but my usual this-is-near-the-end-of-a-personal-project feels kicked in and I covered it with some black cloth tape, as I didn’t feel like reprinting the base for just this purpose.

Installed dispenser system
Installed dispenser system

The only thing that wasn’t accounted by the design is that the servo horn arm didn’t quite reach the button! I should have designed and 3D printed a slightly longer horn arm for it, but instead, I resorted to a 20mm long M2 bolt and some small cable ties. This hacky workaround surprisingly worked, and I promptly forgot about it and moved on - it’s fine for my internal use, but if I was doing this for an external provider, I would instead take the time to design and print a better one.

Servo arm extension
Servo arm extension

And onto the software #

As I mentioned before, I elected to use ESPHome for this one. If you’re not familiar, it’s designed for quickly adding ESP32 devices to Home Assistant, and since I’ve played with it, I actually really like it. For relatively simple (or even slightly complex) projects, it makes it quite easy to put together a range of different inputs and outputs. Some really smart and dedicated people have curated and tested a great set of libraries and got them to work together, rather than the usual technique of trying three or four different Arduino libraries until you find one that a) works and b) works with the other sensors or devices you’ve got on your system.

Plus, ESPHome doesn’t actually have to be used with Home Assistant. For another unpublished project, I made a device that forms an interlock on a industrial CO2 laser cutter, using ESPHome. It’s turned out to be very reliable and has instant MQTT support, without having to drop to low level Arduino code and try to get it all to work properly together. Anyway, I’m getting off track.

Now - taring. This actually wasn’t very complicated. When you place your bottle or container on the weight sensor, and press a start button, it immediately stores whatever the current weight reading is as the tare weight. That’s then considered zero, and as the water adds weight, we can easily work out what the difference was. In production, it’s within about 5ml, mainly because the scales are only read every 200ms, and our script loop runs evert 100ms, so there is a little delay between hitting the weight and turn off. But it’s well within the tolerances we need.

substitutions:
  # The percentage positions that have the servo off or on.
  servo_off_position: "0"
  servo_on_position: "50"

  # How many ml to fill the bottle when you press each button.
  fill_1_value: "350"
  fill_2_value: "500"
  fill_3_value: "720"

esphome:
  # ... snip ...
  # Ensure servo is off during boot.
  on_boot:
    - priority: 500
      then:
        - lambda: id(dispenser_servo_position).make_call().set_value(${servo_off_position}).perform();

# ... snip ...
script:
  - id: start_dispensing
    parameters:
      quantity: float
    then:
      - if:
          condition:
            # Only if we're not already dispensing.
            lambda: 'return id(current_state) == 0;'
          then:
            - lambda: |-
                // Record tare weight and setup states.
                id(tare_weight) = id(weight).state;
                id(target_weight) = quantity;
                id(cancel_light).turn_on().perform();                
            - delay: 400ms
            - lambda: |-
                // Begin dispensing.
                ESP_LOGI("main", "Target Weight set to %f, state %d, tare weight %f. Beginning fill.", id(target_weight), id(current_state), id(tare_weight));
                id(current_state) = 1;
                id(dispenser_servo_position).make_call().set_value(${servo_on_position}).perform();
                id(current_state_report).publish_state("Dispensing");
                id(target_report).publish_state(id(target_weight));                

  - id: stop_dispensing
    then:
      - lambda: |-
          if (id(current_state) == 0) {
            // Not running? Act like tare.
            id(tare_weight) = id(weight).state;
          }

          // But always, regardless of perceived state, do all cancel actions to ensure we're in a known state.
          id(current_state) = 0;
          id(dispenser_servo_position).make_call().set_value(${servo_off_position}).perform();
          id(fill_1_light).turn_off().perform();
          id(fill_2_light).turn_off().perform();
          id(fill_3_light).turn_off().perform();
          id(cancel_light).turn_off().perform();

          id(current_state_report).publish_state("Idle");          

interval:
  # Every 100ms, work out what we're doing.
  - interval: 100ms
    then:
      - lambda: |-
          if (id(current_state) == 1) {
            // We're currently filling.
            float actual_weight = id(weight).state - id(tare_weight);
            ESP_LOGI("main", "Current %.0f of target %.0f", actual_weight, id(target_weight));
            // Are we over the weight we need?
            // No need for multiple checks or settling; it's close enough for what we need.
            if (actual_weight > id(target_weight)) {
              // Yes - stop.
              id(stop_dispensing).execute();
            }
          } else {
            // Not dispensing - ensure servo is off.
            // (Prevents any phantom filling in case of logic or other failure).
            id(dispenser_servo_position).make_call().set_value(${servo_off_position}).perform();
          }          

globals:
  # To store the "tare offset" when you start filling.
  - id: tare_weight
    type: float
    restore_value: no
    initial_value: '0'
  # To store the target weight, when filling.
  - id: target_weight
    type: float
    restore_value: no
    initial_value: '0'
  # To store the current state of the device (0 or 1).
  # States:
  # 0 - Idle
  # 1 - Dispensing
  - id: current_state
    type: int
    restore_value: no
    initial_value: '0'

sensor:
  - platform: hx711
    id: weight
    name: "HX711 Value"
    # ... snip ...
    update_interval: 200ms
    internal: true
    filters:
      # To calibrate your scale:
      # - Uncomment the log lines in on_raw_value and on_value.
      # - With no weight on the device, note the raw value. (It will fluctuate; choose an average)
      # - Add a known weight from another set of scales (500g difference is enough), and note the raw value (it will fluctuate, choose an average)
      # - Enter the raw values on the left and the actual weights on the right.
      # - Comment out the log lines as they're quite frequent.
      - calibrate_linear:
          - 32000 -> 0
          - 103100 -> 543
      # Technically, the following should allow the sensor to incorporate the tare value
      # rather than having to adjust it at the time of display or calculation. However,
      # something in the esphome chain caused it not to work. I'm open to suggestions!
      # - lambda: return x - id(tare_weight);
    unit_of_measurement: g
    on_raw_value:
      then:
        lambda: |-
          // For calibration, you'll need to uncomment this.
          // ESP_LOGI("main", "Raw Value weight sensor %3.0f", x);          
    on_value:
      then:
        lambda: |-
          // For calibration, you'll need to uncomment this.
          // ESP_LOGI("main", "Scaled value of weight sensor: %3.0f", x);          

  # Output of the target fill value so you can see it on the dashboard.
  - platform: template
    id: target_report
    name: "Target Value"

# Output of the current state so you can see it on the web dashboard.
text_sensor:
  - platform: template
    id: current_state_report
    name: "Current State"

# Output display using a TM1637.
display:
    platform: tm1637
    id: tm1637_display
    # ... snip ...
    update_interval: 100ms
    lambda: |-
      it.printf("%4.0f", id(weight).state - id(tare_weight));      

# Servo setup for the dispenser servo.
servo:
  - id: dispenser_servo
    output: servo_pwm
    auto_detach_time: 500ms
    transition_length: 500ms

# Helper to allow us to manually control the servo to work out the correct positions.
number:
  - platform: template
    id: dispenser_servo_position
    name: Servo Control
    min_value: -100
    initial_value: 0
    max_value: 100
    step: 1
    optimistic: true
    # To work out your actual servo locations, you can change "internal" to false, so it appears
    # in the web dashboard, allowing you to alter it for your fridge.
    internal: true
    set_action:
      then:
        - servo.write:
            id: dispenser_servo
            level: !lambda 'return x / 100.0;'

# The buttons are all marked as "internal" because we don't want remote control of these. I think you can understand why.
binary_sensor:
  # Fill 1
  - platform: gpio
    # ... snip ...
    name: "Fill 1"
    on_double_click:
      min_length: 50ms
      max_length: 350ms
      then:
          - light.turn_on: fill_1_light
          - script.execute:
              id: start_dispensing
              quantity: ${fill_1_value}

  # Fill 2
  - platform: gpio
    # ... snip ...
    name: "Fill 2"
    on_double_click:
      min_length: 50ms
      max_length: 350ms
      then:
          - light.turn_on: fill_2_light
          - script.execute:
              id: start_dispensing
              quantity: ${fill_2_value}

  # Fill 3
  - platform: gpio
    # ... snip ...
    name: "Fill 3"
    on_double_click:
      min_length: 50ms
      max_length: 350ms
      then:
          - light.turn_on: fill_3_light
          - script.execute:
              id: start_dispensing
              quantity: ${fill_3_value}

  # Cancel Button
  - platform: gpio
    # ... snip ...
    filters:
      - delayed_on: 10ms
    name: "Cancel"
    internal: true
    on_press:
      - then:
          - script.execute: stop_dispensing

light:
  # Button LED mappings into lights.
  - platform: monochromatic
    output: fill_1_pwm
    id: fill_1_light
    internal: true
    default_transition_length: 200ms
  # ... snip ...

output:
  # Button LED PWM Channels.
  - platform: ledc
    pin: 2
    id: fill_1_pwm
    channel: 0
  # ... snip ...
  # Servo PWM channel - we've allocated a specific channel for this one
  # to ensure that it's allocated a separate PWM controller with the right
  # frequency to control a servo.
  - platform: ledc
    id: servo_pwm
    pin: 25
    frequency: 50 Hz
    channel: 4

What can go wrong? #

Surprisingly, this project worked with very few issues, with surprised me and my family. There are just a few issues that happened with this one.

Phantom turn on #

In my initial version, for the buttons, I used ESPHome’s on_press event, and added a delayed_on filter to debounce the switches, as recommended by the documentation. But this didn’t quite work as I intended, and I did get two instances of phantom turn on of the device.

I probably could have spent more time digging into the code to work out what actually happened, but instead did the lazy thing and switched over to the on_double_click event which resolved the issue for me. After several weeks, we had no more phantom activations.

More protections for accidental filling #

With more time, I could build on additional protections to prevent incorrect activations and stop filling when it’s not supposed to. A possible enhancement would be to detect when we’re dispensing but the filled value isn’t going up, and from that determine that something is wrong and abort the fill.

Stops dispensing #

As the mechanics are not super tight in place, it’s possible for the mechanism to move over time such that the servo arm no longer reaches the water button, and the system stops dispensing water. However, in several weeks of use, it only happened once, so I’ll leave this as a problem for a future version.

What can be improved? #

As with all projects, there is room for improvement here.

  • Clean up the case design. Make it go right to the top of the fridge cubby, and make it look a little bit neater. Better contain the wiring and specifically the load cell wiring which just looks ugly.
  • Improved logic to detect when we’re dispensing but something is missing - like the actual container.
  • A better way to fix it in place on the fridge, so it doesn’t move and cause the servo arm not to contact the dispenser button.

Family acceptance factor #

So we’re clearly not going to be able to keep this on the fridge if the family doesn’t like it and can’t handle the appearance! But I’m happy to report that after a few weeks, I’ve been given the following feedback:

  • Wife: “That’s pretty neat and doesn’t look terrible, and isn’t in the way, so I’m happy for it to stay there.”
  • Eldest daughter (age 10): “This is life changing.”
  • Youngest daughter (age 8): “Thanks Daddy, this saves me so much time.”

For me, personally, I fill up four lots of 350ml into containers for shakes in the morning, so I’m all set for the whole day ahead, and this allows me to be off preparing other shakes while the device watches the filling for me. Typical programmer stance; automate away those dreary tasks!

So I’ll take this one as a win for now, and improve it over time as needed.