I’m using Frigate to detect pigeons and a DIY ESPHome device to scare them.
It’s been working great so far.
Thanks to ssieb and nkinnan over on Discord for some wiring advice a while back.

Overview of deterrants which get triggered…
ESPHome code:
substitutions:
disco_ball_pin: GPIO27
laser_beam_pin: GPIO32
water_spray_pin: GPIO25
buzzer_pin: GPIO18
interlock_wait_time_s: 1s
switch_auto_off_after_x_secs: 10s
scare_auto_off_after_x_min: 1min
scare_cycle_delay: 5s
esphome:
name: pigeon-punisher
friendly_name: Pigeon Punisher
esp32:
board: mhetesp32devkit
framework:
type: esp-idf
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
manual_ip:
static_ip:
gateway:
subnet:
api:
ota:
- platform: esphome
logger:
# level: VERBOSE
# baud_rate: 0
output:
#Rttl buzzer
- platform: ledc
pin: ${buzzer_pin}
id: buzzer_pwm
binary_sensor:
- platform: homeassistant
name: "People On Balcony"
id: "people_on_balcony"
entity_id: binary_sensor.human_watch_person_occupancy
icon: mdi:account-question
publish_initial_state: true
internal: false
- platform: copy
source_id: people_on_balcony
name: "People On Balcony Delay Off"
id: "people_on_balcony_delay_off"
filters:
- delayed_off: 3min
- platform: homeassistant
name: "Bird Detected"
id: "bird_detected"
entity_id: binary_sensor.pigeon_watch_bird_occupancy
icon: mdi:bird
publish_initial_state: true
internal: false
on_release:
- switch.turn_off: scare_pigeons
- platform: template
id: bird_detected_humans_clear
name: Bird Detected Humans Clear
icon: mdi:hand-okay
lambda: |-
return
id(bird_detected).state
&& !id(people_on_balcony_delay_off).state // No people on balcony
&& !id(recently_scared_pigeons).state // Scare pigeons actions not triggered recently
;
filters:
- delayed_off: 3sec
on_press:
- switch.turn_on: scare_pigeons
on_release:
- switch.turn_off: scare_pigeons
- platform: template
id: recently_scared_pigeons
name: Recently Scared Pigeons
# icon: mdi:hand-okay
lambda: |-
return id(scare_pigeons).state ;
filters:
- delayed_on: 30sec
- delayed_off: 30min
number:
- platform: template
name: "Buzzer Frequency"
id: buzzer_freq
min_value: 100
max_value: 10000
step: 100
initial_value: 8000
unit_of_measurement: Hz
optimistic: true
on_value:
- lambda: |-
id(buzzer_pwm).update_frequency((int)x);
- platform: template
name: "Buzzer Level"
id: buzzer_level
min_value: 0
max_value: 1
step: 0.05
initial_value: 0.5
unit_of_measurement: "%"
optimistic: true
on_value:
- lambda: |-
id(buzzer_pwm).set_level(x);
globals:
- id: last_base_freq
type: int
initial_value: '4000'
- id: last_base_level
type: float
initial_value: '0.5'
select:
- platform: template
name: "Random Buzzer Effect"
id: buzzer_effect
options:
- "IDLE"
- "WARBLE"
- "CHIRP"
- "PULSING"
- "LONG SHRIEK"
- "BEATING"
- "TREMOLO"
optimistic: true
initial_option: "IDLE"
on_value:
- if:
condition:
lambda: 'return id(buzzer_effect).state == "WARBLE";'
then:
- repeat:
count: 15
then:
- lambda: |-
int freq = 2500 + ((iteration % 2 == 0) ? 300 : -300) + (esphome::random_uint32()%200-100);
float lvl = id(last_base_level) * 0.8 + ((esphome::random_uint32()%20)/100.0f - 0.1);
if(lvl<0) lvl=0; if(lvl>1) lvl=1;
id(buzzer_pwm).update_frequency(freq);
id(buzzer_pwm).set_level(lvl);
ESP_LOGD("buzzer","WARBLE iteration %d: freq=%d, level=%.2f", iteration, freq, lvl);
- delay: 100ms
- output.set_level:
id: buzzer_pwm
level: 0
- delay: 500ms
- select.set:
id: buzzer_effect
option: "IDLE"
- if:
condition:
lambda: 'return id(buzzer_effect).state == "CHIRP";'
then:
- repeat:
count: 30
then:
- lambda: |-
int step = iteration % 10;
int freq = 2200 + step*100 + (esphome::random_uint32()%200-100);
float lvl = id(last_base_level) * 0.75 + ((esphome::random_uint32()%20)/100.0f -0.1);
if(lvl<0) lvl=0; if(lvl>1) lvl=1;
id(buzzer_pwm).update_frequency(freq);
id(buzzer_pwm).set_level(lvl);
- delay: 20ms
- output.set_level:
id: buzzer_pwm
level: 0
- delay: 50ms
- delay: 200ms
- select.set:
id: buzzer_effect
option: "IDLE"
- if:
condition:
lambda: 'return id(buzzer_effect).state == "PULSING";'
then:
- repeat:
count: 6
then:
- lambda: |-
int freq = 2300 + (iteration % 300) + (esphome::random_uint32()%200-100);
float lvl = ((iteration%2==0)?id(last_base_level)*0.8:id(last_base_level)*0.4) + ((esphome::random_uint32()%20)/100.0f -0.1);
if(lvl<0) lvl=0; if(lvl>1) lvl=1;
id(buzzer_pwm).update_frequency(freq);
id(buzzer_pwm).set_level(lvl);
- delay: 50ms
- output.set_level:
id: buzzer_pwm
level: 0
- delay: 200ms
- select.set:
id: buzzer_effect
option: "IDLE"
- if:
condition:
lambda: 'return id(buzzer_effect).state == "LONG SHRIEK";'
then:
- lambda: |-
int freq = 3000 + (esphome::random_uint32()%200-100);
float lvl = id(last_base_level)*0.8 + ((esphome::random_uint32()%20)/100.0f -0.1);
if(lvl<0) lvl=0; if(lvl>1) lvl=1;
id(buzzer_pwm).update_frequency(freq);
id(buzzer_pwm).set_level(lvl);
- delay: 700ms
- output.set_level:
id: buzzer_pwm
level: 0
- delay: 500ms
- select.set:
id: buzzer_effect
option: "IDLE"
- if:
condition:
lambda: 'return id(buzzer_effect).state == "BEATING";'
then:
- repeat:
count: 8
then:
- lambda: |-
int base = 2500 + (esphome::random_uint32()%200-100);
int delta = 50 + (esphome::random_uint32()%50);
id(buzzer_pwm).update_frequency(base);
id(buzzer_pwm).set_level(0.7);
- delay: 25ms
- lambda: |-
int base = 2500 + (esphome::random_uint32()%200-100);
int delta = 50 + (esphome::random_uint32()%50);
id(buzzer_pwm).update_frequency(base+delta);
id(buzzer_pwm).set_level(0.7);
- delay: 25ms
- output.set_level:
id: buzzer_pwm
level: 0
- delay: 200ms
- select.set:
id: buzzer_effect
option: "IDLE"
- if:
condition:
lambda: 'return id(buzzer_effect).state == "TREMOLO";'
then:
- repeat:
count: 30
then:
- lambda: |-
id(buzzer_pwm).update_frequency(2600 + (esphome::random_uint32()%200-100));
id(buzzer_pwm).set_level(0.65);
- delay: 10ms
- lambda: |-
id(buzzer_pwm).set_level(0.25);
- delay: 10ms
- output.set_level:
id: buzzer_pwm
level: 0
- delay: 300ms
- select.set:
id: buzzer_effect
option: "IDLE"
sensor:
- platform: uptime
id: uptime_sensor
name: "Uptime Sensor"
update_interval: 5s
switch:
- platform: template
name: "Buzzer Enable"
id: buzzer_enable
optimistic: true
turn_on_action:
- lambda: |-
id(buzzer_pwm).set_level(id(buzzer_level).state);
turn_off_action:
- lambda: |-
id(buzzer_pwm).set_level(0);
- name: Disco Ball
id: disco_ball
platform: gpio
pin: ${disco_ball_pin}
internal: false
restore_mode: ALWAYS_OFF
interlock: &interlock_group [disco_ball, laser_beam, water_spray]
interlock_wait_time: ${interlock_wait_time_s}
on_turn_on: # Auto Off timer
- script.stop: auto_off_disco # Stop existing count down timers
- script.execute: auto_off_disco # Start new timer
- name: Laser Beam
id: laser_beam
platform: gpio
pin: ${laser_beam_pin}
internal: false
restore_mode: ALWAYS_OFF
interlock: *interlock_group
interlock_wait_time: ${interlock_wait_time_s}
on_turn_on: # Auto Off timer
- script.stop: auto_off_laser # Stop existing count down timers
- script.execute: auto_off_laser # Start new timer
- name: Water Spray
id: water_spray
platform: gpio
pin: ${water_spray_pin}
internal: false
restore_mode: ALWAYS_OFF
interlock: *interlock_group
interlock_wait_time: ${interlock_wait_time_s}
on_turn_on: # Auto Off timer
- script.stop: auto_off_water_spray # Stop any existing count down timers
- script.execute: auto_off_water_spray # Start new timer
- platform: template
name: Scare Pigeons
id: scare_pigeons
optimistic: true
on_turn_on: # Auto Off timer
- script.stop: auto_off_scare_pigeons # Stop any existing count down timers
- script.execute: auto_off_scare_pigeons # Start new timer
turn_on_action:
- delay: 500ms
- switch.turn_on: annoying_sounds
- while:
condition:
switch.is_on: scare_pigeons
then: # Cycle through scare devices with randomised duration of each.
- switch.turn_on: water_spray
- delay: !lambda "return (esphome::random_uint32() % 18 + 2) * 1000;"
- switch.turn_on: laser_beam
- delay: !lambda "return (esphome::random_uint32() % 18 + 2) * 1000;"
- switch.turn_on: disco_ball
- delay: !lambda "return (esphome::random_uint32() % 18 + 2) * 1000;"
turn_off_action:
- switch.turn_off: laser_beam
- switch.turn_off: water_spray
- switch.turn_off: disco_ball
- switch.turn_off: annoying_sounds
- platform: template
name: "Annoying Sounds"
id: annoying_sounds
optimistic: true
turn_on_action:
- switch.turn_on: buzzer_enable
- script.execute: annoying_sounds_loop
turn_off_action:
- switch.turn_off: buzzer_enable
- script.stop: annoying_sounds_loop
script:
- id: auto_off_disco
mode: restart
then:
- delay: ${switch_auto_off_after_x_secs}
- switch.turn_off: disco_ball
- id: auto_off_laser
mode: restart
then:
- delay: ${switch_auto_off_after_x_secs}
- switch.turn_off: laser_beam
- id: auto_off_water_spray
mode: restart
then:
- delay: ${switch_auto_off_after_x_secs}
- switch.turn_off: water_spray
- id: auto_off_scare_pigeons
mode: restart
then:
- delay: ${scare_auto_off_after_x_min}
- switch.turn_off: scare_pigeons
# --- Loop script: pick random effect each cycle ---
- id: annoying_sounds_loop
mode: restart
then:
- delay: 500ms
- while:
condition:
switch.is_on: annoying_sounds
then:
- lambda: |-
auto size = id(buzzer_effect).size();
if (size == 0) return;
int idx = esphome::random_uint32() % size;
auto call = id(buzzer_effect).make_call();
call.set_index(idx);
call.perform();
ESP_LOGD("buzzer", "Random effect selected index: %d", idx);
- wait_until:
lambda: 'return id(buzzer_effect).state == "IDLE";'
- delay: !lambda "return (esphome::random_uint32() % 3000 + 500);" # Delay 0.5–3.5 s before next change


