Debouncing an ultrasonic distance sensor for Car presence detection

Hey there!

I’d like to use an ultrasonic sensor (the ubiquitous HC-SR04) to detect the presence of a car, after every closing of my garage door.

It’s also battery powered, so I’m looking to conserve juice. I used an on_boot lambda to disable the automatic interval on the ultrasonic sensor, and trigger it manually. That part works well.

How I did the manual polling on my sensor
esphome:
  on_boot:
    - priority: 590 # "600.0: This is where most sensors are set up."
      then:
        - lambda: !lambda |-
            ESP_LOGI("main", "Disabling polling on the ultrasonic sensor.");
            App.scheduler.cancel_interval(id(ultrasonic_sensor), "update");

I can still trigger it on-demand:

button:
  - name: "Measure distance"
    platform: template
    on_press:
      lambda: !lambda |-
        ESP_LOGI("main", "Button pressed in HA");
        id(ultrasonic_sensor).update();
        float distance = id(ultrasonic_sensor).get_state();
        ESP_LOGI("main", "Distance measured: %f", distance);

Now the issue is that the sensor values can be a bit noisy. I want to trigger automations on the rising/falling edges (e.g. auto-close the garage door X seconds after the car pulls out), so I have an appetite for latency, but not for any false transitions from present to absent or vice versa.

I tried several approaches, but I hit a dead end on each of them.

Attempt 1: a template `binary_sensor` with a `delayed_on` filter

This seemed promising, but I can’t figure out a way to express “repeat this loop until you hit a stable value”

button:
  # For testing only. I'll replace this with a lambda triggered by `cover.on_closed` on my Garage Door
  - name: "Check car presence"
    platform: template
    on_press:
      - repeat: 
          count: 10 # Sample 10 times
          then:
            - logger.log: "Sampling ultrasonic sensor"
            - component.update: ultrasonic_sensor
            - if:
                condition:
                  sensor.in_range: { id: ultrasonic_sensor, below: 1.0 } # < 1 meter away, car is present
                then:
                  - logger.log:
                      format: "Sampled the car as being present (distance: %f)"
                      args: ['id(ultrasonic_sensor).state']
                  - binary_sensor.template.publish: { id: garage_car_presence, state: ON }
                else:
                  - logger.log:
                      format: "Sampled the car as being absent (distance: %f)"
                      args: ['id(ultrasonic_sensor).state']
                  - binary_sensor.template.publish: { id: garage_car_presence, state: OFF }

            - delay: 1s

sensor:
  - name: "Ultrasonic demo"
    id: ultrasonic_sensor
    platform: ultrasonic
    trigger_pin: GPIO14
    echo_pin: GPIO12
    timeout: 3m # My sensor can measure up to 3 meters
    update_interval: 4294967295ms # UInt32.max
    filters:
      - lambda: !lambda "return isnan(x) ? 3.0 : x;" # Treat a timeout as a measumrent of 3 meters

binary_sensor:
  - name: "Car presence"
    id: garage_car_presence
    platform: template
    filters:
      - delayed_on: 10s

Attempt 2: Implement a custom lambda to the looping
button:
  # For testing only. I'll replace this with a lambda triggered by `cover.on_closed` on my Garage Door
  - name: "Check car presence"
    platform: template
    on_press:
      lambda: !lambda |-
        ESP_LOGI("main", "Button pressed in HA");

        auto numSamples = 10;
        auto numShortSamples = 0;
        auto carPresenceDistance = 1.0; // If less than 1 meter, consider the car present.

        for (auto i = 0; i < 10; i++) {
          id(ultrasonic_sensor).update();
          auto distance = id(ultrasonic_sensor).get_state();
          if (distance < carPresenceDistance) {
            ESP_LOGI("main", "Ultrasonic sensor sample %i: present", i);
            numShortSamples += 1;
          } else {
            ESP_LOGI("main", "Ultrasonic sensor sample %i: absent", i);
          }

          // ℹ️ How do I delay here? There's no easy way to capture this into a lambda to use with a DelayAction
        }

        // TODO: this only considers consensus for presence. Alter it to check for 7/10 consensusr for both presence and absense.
        auto consensusThreshold = 7;
        auto carIsPresent = consensusThreshold <= numShortSamples;
        id(garage_car_presence).publish_state(carIsPresent);
```yaml

<hr>

I’m not scared to write lambdas or even a custom virtual component that manages the ultrasonic sensor, but if possible I’m trying to stay “on the beaten path” so future updates are less likely to break anything.

Any tips on how I should approach this?

Couple of thoughts…

You can also set the update interval to “never” to disable time based updates.

Then you can use a moving median to smooth noise. Manually request updates 10 times in a repeat like you’ve done and then push it out every 10th value.

Then use the result to feed a binary sensor (detected/not detected).

Have a look at some of my WIP config here, I’m doing something similar.

1 Like

Hey @Mahko_Mahko, I though I had responded at the time, but it appears not! Your example was super great, and really helped me out!

  • update_interval: never was a big help, and really cut out a lot of the hackery.
  • I couldn’t figure out what the platform: copy sensors were for in your template. It seems to work fine when I combined them and had my filters and on_value on the same sensor.

For future readers, here’s the rough idea:

esphome:
  name: garage-door
  on_boot: 
    - priority: -100 # "At this priority, pretty much everything should already be initialized."
      then:
        - delay: 60s
        - logger.log: "Checking if the car is home."
        # - button.press: read_ultrasonic_sensor

button:
  - name: "Read Ultrasonic sensor"
    id: read_ultrasonic_sensor
    platform: template
    internal: true
    on_press: 
      - repeat:
          count: 30 # Roughly 30 readings/s for 10 s, should match `ultrasonic_sensor`
          then:
            - component.update: ultrasonic_sensor
            - delay: 100ms

sensor:
  # Measures the distance to the floor, detecting whether the car is present or not. 
  - name: "Ultrasonic sesnor"
    id: ultrasonic_sensor
    platform: ultrasonic
    trigger_pin: GPIO33
    echo_pin: GPIO18
    timeout: 3m
    filters:
      - clamp: { min_value: 0.0, max_value: 3.0 }
      - median:
          window_size: 30 # 10 readings/s for 3 s, should match `read_ultrasonic_sensor`
          send_every: 30
          send_first_at: 30
    on_value:
      if:
        condition:
          # The floor is 2.3 m away, so if it's < 2.0 m, it's pretty likely to be the car.
          lambda: !lambda 'return id(ultrasonic_sensor).state < 2.0;'
        then:
          - logger.log: "The car is in the garage."
          - binary_sensor.template.publish: { id: car_presence, state: ON }
        else:
          - logger.log: "The car is not in the garage."
          - binary_sensor.template.publish: { id: car_presence, state: OFF }

binary_sensor:
  - name: Car presence
    id: car_presence
    platform: template
    device_class: presence

One quirk I found is that somehow the burst of 30 reads gets “de-synchronized” from the median filter’s send_every: 30 window. So for example, I’d manually trigger 30 reads, and see that the median value gets emitted after only 5 readings. This means that the previous burst of reads pushed 25 values in the median filter, and only 5 more were needing to fill out 30.

I wish there was a way to flush the median filter’s buffer on-demand, so I can ensure they’re always in-sync.

1 Like

Hmm yeah I know what you mean. I usually use the technique with deep sleep which wipes the stale values. Annoying.

Edit: I see you’re doing it with deep sleep so it’s not that. I wonder if it’s because the sensor isn’t returning values for some requests? Maybe try increasing your 100ms requests a little (200ms)?You could probably decrease your 30 readings a bit if required. 10 or even 5 might do.

Or maybe timeout could be used to purge stale values somehow?

A copy sensor does exactly that. It copies another sensor. Sometimes I use them to break up logic a bit, especially for debugging. Sometimes I combine them later when everything is stable.