본문 바로가기

DIY

근접 자동 열림 급식기 - auto pet feeder, ESPHome, Home Assistant, DIY

개요 

근접센서로 자동 열림 기능이 있는 알리익스프레스에서 구입하여 조금 사용하다가 기능(먹는 중에 닫혀서 깜짝 놀람)에 불만을 느껴서 개조한 후 몇 년 동안 사용해 왔던 아두이노 기반 자동 열림 급식기가 오동작(잘 안열리고 잘 안닫힘)이 많아져서 미루고 미루던 홈어시스턴트 연동 급식기로 업그레이드를 하기로 했습니다. 

 

가끔 참새가 계절에 따라 춘궁기에 집중적으로 강아지 사료를 먹으러 옵니다. 참새가 먹는 사료가 아까운 것은 아닌데 입이 작은 참새들이 하나씩 물어가지 않고 파헤쳐 놓으면 주변이 난장판이 됩니다.   

 

기존 PIR(Passive InfraRed) 센서는 참새가 와도 뚜껑이 열리는 문제가 있습니다. 이번에는 초음파 거리 센서로 여닫는 기준을 삼아 보았습니다. 다만 초음파 거리 센서가 최대 절전 모드에서 깨어날 때 십 몇 초간 거리 인식을 못하거나 널뛰는 증상이 있어서 결국 포기하고, 8x8 픽셀의 열 적외선 센서로 대체하였습니다. 

 

8x8 열화상 센서로 해결되면 좋겠지만, 그리 만만하지는 않았습니다. 64개 중 최고 온도와 평균 온도의 차이 값과 최고 온도 값의 기준이 햇빛의 영향과 거리에 따른 감지 온도가 가변적이기 때문에 생각보다 복잡합니다. 

 

요약하자면 PIR 센서는 너무 자주 감지되어 문제이고, 실외에서 초음파 거리 센서는 가끔 오작동, 8x8 열화상 센서는 가격도 비싸고 실외에서는 감지를 위한 알고리즘이 더 필요한 상태 등등입니다.

 

가장 나아 보이는 것은  인체/동물 감지 시 GPIO에 연결하여 Wakeup도 가능한 mmWave 센서이고 배터리로 작동시키기에는 소비전류가 많은 것(40~100mA내외)이 단점이고 적절한 센서를 찾으면 향후에 적용해 보려고 합니다. 

 

  • HLK-LD2410(C) : 80~130mA@5V, ESPHome 지원, 인체 감지 알고리즘 내장  
  • HLK-LD2420 : 50mA@3.3V, ESPHome 지원하지 않음(2410과 프로토콜이 다릅니다) 

이번 개조를 하면서 ESPHome에 대해 조금 더 많이 알게 되었습니다. 

개조에 사용한 제품

 

기성품 급식 제품 https://ko.aliexpress.com/item/1005001426585263.html

 

36895.0₩ 30% OFF|반려동물 빙고 고양이 피더, 적외선 센서, 개 그릇 자동 개방 뚜껑, 식품 보관 용기

Smarter Shopping, Better Living! Aliexpress.com

ko.aliexpress.com

 

마당에 설치한 모습 

마당에 설치된 자동 열림 급식기(태양광 5V 충전)

홈어시스턴트에 통합한 모습

Home Assistant에 통합한 후의 모습(우측) - 1차
1차 버전 수정판

 

2차 버전 열화상센서를 이용

내부 모습

좀 어지럽지만 1차 버전의 초기 내부 모습입니다.

 

업그레이드 내용 

항목 아두이노 버전 ESPHome 버전 개선 내용
덮개 열림 방식 PIR(수동 적외선 센서)로 움직임 감지 시 1차버전) 초음파 거리 센서로 30cm 이내 접근 확인 시
2차버전) 열화상 센서로 64개(8x8) 온도 값 평균과의 차이가 7이상인 경우에 열림 
몇 m로 먼 거리에 있을 때 열리는 문제 혹은 참새들로 인해 열리는 확률을 줄임
덮개 닫힘 방식 PIR(수동 적외선 센서)로 움직임 감지가 90초간 없을 경우 1차버전) 초음파 거리 센서로 연속 30초 30cm 초과 시
2차버전) 평균 온도 차이가 7미만인 경우 60초 후 닫힘
더 빨리 닫히게 함 
원격 작동 방식 없음 홈어시스턴트를 통하여 열고, 닫고, 절전 모드 진입 등 작업 가능 
상태 모니터링 
특정 시간대(예: 새벽)에 참새의 습격(?)을 막을 수 있게 됨 
저전압 및 작동 상태를 모니터링
소비전류 및 배터리, 충전 절전 시 13mA@7.5V, 18650 2600mAh 배터리, 5V 충전 최대 절전 모드 시 13mA@7.5V/대기 시 40mA@7.5V, 18650 2600mAh 배터리 2개, 5V 충전 배터리 용량 2배로 늘림
기타 저전압 경보(LED) 및 자동 뚜껑 열림 
Aduino Pro mini 보드 
XIAO ESP32-C3 보드   
 

필요한 부품

  • XIAO ESP32-C3 : ESP8266과 달리 WAKEUP PIN을 지정할 수 있고 전력 소비가 작음 
  • L298N : ESP32-C3 보드로 5V 전원 공급 및 9V 모터 작동용
  • PIR(Passive Infrared) 센서 : 최대 절전 시 ESP32-C3 WAKEUP용으로 활용 
  • Ultrasonic distance 센서 : 초음파로 거리를 측정하여 가까이(30cm 이내) 있을 때에만 작동
  • MCU8833 열화상 센서 : 2차 버전에 사용했으며 AMG8833 칩셋을 내장하여 시리얼 통신으로 작동하는 8x8 총 64개 적외선 온도 센서 내장 
  • 18650 배터리 및 충방전용 모듈 : 5V 입력으로 4.2V 충전과 동시에 9V(조정 가능) 전원 출력
  • LED 및 저항 : 배터리 부족 시 경고용
  • 기타 : 배선용 부품, 테스터기, 인두기, 드라이버, 니퍼, 만능기판(선택) 등 

왜 ESPHome인가? 

첫번째 장점으로는 ESPHome으로 펌웨어를 만들면, 홈어시스턴트(Home Assistant)에 연동하기가 쉽습니다. 집안에 있는 각종 IoT장치들을 전부 제어할 수 있는데 자동 급식기도 같이 모니터링하고 제어하면 좋겠지요.  

두번째 장점은 아두이노로 만들 때에는 비록 (코딩 유경험자 혹은 프로그래머에게는) 어렵지 않고 짧지만 직접 코딩을 해야 하지만,  ESPHome이 지원하는 부품을 사용하면 거의 코딩을 안하다시피 합니다. 물론 이번에는 몇 줄 작성하기는 했지만, 아마 안해도 될 것 같습니다. 단, 자동으로 만들어지지는 않으므로 YAML이라는 설정 파일은 편집해 주어야 합니다. 

세번째 장점은 Home Assistant의 애드온인 ESPHome을 통해 브라우저에서 직접 연결, 편집하고 실행할 수 있습니다. 물론, PC에 ESPHome용 도구를 설치해도 됩니다. 

 

YAML 파일 

 

ESPHome을 위한 YAML로부터 다음 방법을 알 수 있습니다. 물론, ESPHome의 예제에 다 있는 내용입니다.

 

  • 장치(ESP32-C3)에서 센서 값 변화에 따라 호출되는 람다 함수와 전역 변수 사용 방법 
  • ESP32에서 L298N을 통해 PWM으로 모터를 제어하는 방법(ledc 이용, 본래 led제어용이나 모터 제어도 가능), ESP8266에서는 esp8266_pwm 이용 
  • ESP32에서 deep sleep 모드 진입과 빠져 나오기 위한 WAKEUP 핀 사용 방법(ESP8266은 wakeup핀 이용 불가) 
  • ADC로 A0핀에서 배터리 전압 읽는 방법(각 칩마다 다름) 
esphome:
  name: autopetfeeder
  friendly_name: autopetfeeder

esp32:
  board: esp32-c3-devkitm-1
  framework:
    type: arduino

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: "dV3MA/amgDRNb5V4I4kUuvhqw4EKKNMP01+ZTcPwxx="

ota:
  password: "f53940cfba614eb95e30f3ac2aa8477"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  manual_ip:
    # Set this to the IP of the ESP
    static_ip: 192.168.0.88
    # Set this to the IP address of the router. Often ends with .1
    gateway: 192.168.0.1
    # The subnet of the network. 255.255.255.0 works for most home networks.
    subnet: 255.255.255.0

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Autopetfeeder Fallback Hotspot"
    password: "cLREoSydr2Q"

captive_portal:
    
deep_sleep:
  id: deep_sleep_1
  run_duration: 
    default: 120s
    gpio_wakeup_reason: 30s
  wakeup_pin_mode: 
    IGNORE 
  sleep_duration: 1440min  # cannot omit 
  wakeup_pin: GPIO4
  #wakeup_pin_mode: INVERT_WAKEUP

globals:
- id: cover_nomovement_count 
  type: int 
  initial_value: "0" 
- id: cover_movement_count
  type: int 
  initial_value: "0" 
- id: cover_status
  type: int 
  initial_value: "0"   

sensor:
  - platform: ultrasonic
    trigger_pin: GPIO3
    echo_pin: GPIO5
    update_interval: 1s
    name: "Ultrasonic Sensor"
    id: ultrasonic_distance
    timeout: 3m
    force_update: false
    on_value:
      - lambda: |-
          if(isnan(x) || x < 0.1) {
            ESP_LOGD("lambda", "too close"); 
          }
          else if(x < 0.85) {
            if(id(cover_status) == 0) {
              id(cover_movement_count) += 1;

              if(id(cover_movement_count) > 2) {
                id(cover_status) = 1;

                auto call = id(my_cover).make_call();
                call.set_command_open();
                call.perform();

                id(cover_movement_count) = 0;
              }                 
            } 

            id(cover_nomovement_count) = 0; 
          } else {
            // 45 times detection
            id(cover_nomovement_count) += 1;
            id(cover_movement_count) = 0;
  
            ESP_LOGD("lambda", "count %d", id(cover_nomovement_count));  
            if(id(cover_nomovement_count) > 44) {
              id(cover_nomovement_count) = 0;      
              // cover is open 
              if(id(cover_status) == 1) {
                id(cover_status) = 0;    
 
                auto call = id(my_cover).make_call();
                call.set_command_close();
                call.perform();                 
              }          
            }
          }
  - platform: template 
    name: "nomovement count"
    lambda: |- 
      return id(cover_nomovement_count); 
    update_interval: 1s
  - platform: template 
    name: "movement count"
    lambda: |- 
      return id(cover_movement_count); 
    update_interval: 1s    
  - platform: adc
    pin: GPIO2 # D0 
    name: "autopetfeeder"
    raw: true 
    attenuation: 11dB
    update_interval: 2s    
    filters:
      - multiply: 0.0014652014652015  
      - sliding_window_moving_average:
          window_size: 12
          send_every: 12

binary_sensor:
  - platform: gpio
    pin: GPIO4
    name: "PIR Sensor"
    device_class: motion    
  - platform: template 
    name: "cover status"
    lambda: |- 
      if(id(cover_status) == 0) {
        return false; 
      } else {
        return true; 
      }

output:
  - platform: ledc
    id: motor_forward_pin
    pin: GPIO9 
  - platform: ledc
    id: motor_reverse_pin
    pin: GPIO8 
  - platform: ledc 
    id: motor_enable 
    pin: GPIO10
  - platform: ledc
    pin: GPIO20
    id: gpio_d7    

light:
  - platform: monochromatic
    output: gpio_d7
    id: LED1
    name: "LED"

fan:
  - platform: hbridge
    id: my_fan
    name: "petfeeder motor"
    pin_a: motor_forward_pin
    pin_b: motor_reverse_pin
    enable_pin: motor_enable
    decay_mode: slow   # slow decay mode (braking) or fast decay (coasting).

cover:
  - platform: template
    name: "Cover"
    id: my_cover
    open_action: 
      - lambda: |- 
          auto call1 = id(my_fan).turn_on();
          id(my_fan).direction = FanDirection::FORWARD; 
          call1.perform();
      - delay: 0.15s 
      - lambda: |- 
          auto call2 = id(my_fan).turn_off();
          call2.perform(); 
          id(cover_status) = 1;
      - deep_sleep.prevent: 
          id: deep_sleep_1
    close_action: 
      - lambda: |- 
          auto call1 = id(my_fan).turn_on();
          id(my_fan).direction = FanDirection::REVERSE; 
          call1.perform();
      - delay: 0.1s 
      - lambda: |- 
          auto call2 = id(my_fan).turn_off();
          call2.perform(); 

          id(cover_status) = 0;
          id(cover_nomovement_count) = 0;
          id(cover_movement_count) = 0;
      - deep_sleep.allow: 
          id: deep_sleep_1  

button:
  - platform: template
    name: autopetfeeder_sleep
    id: sleep_button
    on_press: 
      then:
        - lambda: |- 
            if(id(cover_status) == 1) { 
              auto call = id(my_cover).make_call();
              call.set_command_close();
              call.perform();   
            }

로그 내용 

INFO Starting log output from 192.168.0.88 using esphome API
INFO Successfully connected to 192.168.0.88
[12:46:47][I][app:102]: ESPHome version 2023.9.3 compiled on Oct 11 2023, 11:17:26
[12:46:47][C][wifi:546]: WiFi:
[12:46:47][C][wifi:382]:   Local MAC: A0:76:4E:40:27:8C
[12:46:47][C][wifi:383]:   SSID: [redacted]
[12:46:47][C][wifi:384]:   IP Address: 192.168.0.88
[12:46:47][C][wifi:386]:   BSSID: [redacted]
[12:46:47][C][wifi:387]:   Hostname: 'autopetfeeder'
[12:46:47][C][wifi:389]:   Signal strength: -55 dB ▂▄▆█
[12:46:47][C][wifi:393]:   Channel: 11
[12:46:47][C][wifi:394]:   Subnet: 255.255.255.0
[12:46:47][C][wifi:395]:   Gateway: 192.168.0.1
[12:46:47][C][wifi:396]:   DNS1: 0.0.0.0
[12:46:47][C][wifi:397]:   DNS2: 0.0.0.0
[12:46:47][C][logger:357]: Logger:
[12:46:47][C][logger:358]:   Level: DEBUG
[12:46:47][C][logger:359]:   Log Baud Rate: 115200
[12:46:47][C][logger:361]:   Hardware UART: UART0
[12:46:47][C][gpio.binary_sensor:015]: GPIO Binary Sensor 'PIR Sensor'
[12:46:47][C][gpio.binary_sensor:015]:   Device Class: 'motion'
[12:46:47][C][gpio.binary_sensor:016]:   Pin: GPIO4
[12:46:47][C][ledc.output:164]: LEDC Output:
[12:46:47][C][ledc.output:165]:   Pin GPIO9
[12:46:47][C][ledc.output:166]:   LEDC Channel: 0
[12:46:47][C][ledc.output:167]:   PWM Frequency: 1000.0 Hz
[12:46:47][C][ledc.output:168]:   Bit depth: 14
[12:46:47][C][ledc.output:164]: LEDC Output:
[12:46:47][C][ledc.output:165]:   Pin GPIO8
[12:46:47][C][ledc.output:166]:   LEDC Channel: 1
[12:46:47][C][ledc.output:167]:   PWM Frequency: 1000.0 Hz
[12:46:47][C][ledc.output:168]:   Bit depth: 14
[12:46:47][C][ledc.output:164]: LEDC Output:
[12:46:47][C][ledc.output:165]:   Pin GPIO10
[12:46:47][C][ledc.output:166]:   LEDC Channel: 2
[12:46:47][C][ledc.output:167]:   PWM Frequency: 1000.0 Hz
[12:46:47][C][ledc.output:168]:   Bit depth: 14
[12:46:47][C][ledc.output:164]: LEDC Output:
[12:46:47][C][ledc.output:165]:   Pin GPIO20
[12:46:47][C][ledc.output:166]:   LEDC Channel: 3
[12:46:47][C][ledc.output:167]:   PWM Frequency: 1000.0 Hz
[12:46:47][C][ledc.output:168]:   Bit depth: 14
[12:46:47][C][template.cover:071]: Template Cover 'Cover'
[12:46:47][C][light:103]: Light 'LED'
[12:46:47][C][light:105]:   Default Transition Length: 1.0s
[12:46:47][C][light:106]:   Gamma Correct: 2.80
[12:46:47][C][ultrasonic.sensor:045]: Ultrasonic Sensor 'Ultrasonic Sensor'
[12:46:47][C][ultrasonic.sensor:045]:   State Class: 'measurement'
[12:46:47][C][ultrasonic.sensor:045]:   Unit of Measurement: 'm'
[12:46:47][C][ultrasonic.sensor:045]:   Accuracy Decimals: 2
[12:46:47][C][ultrasonic.sensor:045]:   Icon: 'mdi:arrow-expand-vertical'
[12:46:47][C][ultrasonic.sensor:046]:   Echo Pin: GPIO5
[12:46:47][C][ultrasonic.sensor:047]:   Trigger Pin: GPIO3
[12:46:47][C][ultrasonic.sensor:048]:   Pulse time: 10 µs
[12:46:47][C][ultrasonic.sensor:049]:   Timeout: 11661 µs
[12:46:47][C][ultrasonic.sensor:050]:   Update Interval: 5.0s
[12:46:47][C][adc:097]: ADC Sensor 'autopetfeeder'
[12:46:47][C][adc:097]:   Device Class: 'voltage'
[12:46:47][C][adc:097]:   State Class: 'measurement'
[12:46:47][C][adc:097]:   Unit of Measurement: 'V'
[12:46:47][C][adc:097]:   Accuracy Decimals: 2
[12:46:47][C][adc:107]:   Pin: GPIO2
[12:46:47][C][adc:122]:  Attenuation: 11db
[12:46:47][C][adc:142]:   Update Interval: 10.0s
[12:46:47][D][ultrasonic.sensor:040]: 'Ultrasonic Sensor' - Got distance: 1.78 m
[12:46:47][D][sensor:094]: 'Ultrasonic Sensor': Sending state 1.78051 m with 2 decimals of accuracy
[12:46:47][D][homeassistant.sensor:024]: 'sensor.autopetfeeder_ultrasonic_sensor': Got state 1.78
[12:46:47][D][sensor:094]: 'ultrasonic_distance': Sending state 1.78000  with 1 decimals of accuracy
[12:46:47][C][fan.hbridge:038]: H-Bridge Fan 'petfeeder motor'
[12:46:47][C][fan.hbridge:151]:   Speed: YES
[12:46:47][C][fan.hbridge:152]:   Speed count: 100
[12:46:47][C][fan.hbridge:158]:   Direction: YES
[12:46:47][C][fan.hbridge:040]:   Decay Mode: Slow
[12:46:47][C][captive_portal:088]: Captive Portal:
[12:46:48][C][mdns:115]: mDNS:
[12:46:48][C][mdns:116]:   Hostname: autopetfeeder
[12:46:48][C][ota:097]: Over-The-Air Updates:
[12:46:48][C][ota:098]:   Address: 192.168.0.88:3232
[12:46:48][C][ota:101]:   Using Password.
[12:46:48][C][api:138]: API Server:
[12:46:48][C][api:139]:   Address: 192.168.0.88:6053
[12:46:48][C][api:141]:   Using noise encryption: YES
[12:46:48][C][homeassistant.sensor:030]: Homeassistant Sensor 'ultrasonic_distance'
[12:46:48][C][homeassistant.sensor:030]:   State Class: ''
[12:46:48][C][homeassistant.sensor:030]:   Unit of Measurement: ''
[12:46:48][C][homeassistant.sensor:030]:   Accuracy Decimals: 1
[12:46:48][C][homeassistant.sensor:031]:   Entity ID: 'sensor.autopetfeeder_ultrasonic_sensor'
[12:46:48][C][deep_sleep:049]: Setting up Deep Sleep...
[12:46:48][C][deep_sleep:052]:   Sleep Duration: 3600000 ms
[12:46:48][C][deep_sleep:055]:   Run Duration: 120000 ms
[12:46:48][C][deep_sleep:059]:   Wakeup Pin: GPIO4
[12:46:52][D][ultrasonic.sensor:040]: 'Ultrasonic Sensor' - Got distance: 1.77 m
[12:46:52][D][sensor:094]: 'Ultrasonic Sensor': Sending state 1.77297 m with 2 decimals of accuracy

XIAO ESP32-C3에서 배터리 전압 

일단 전압을 읽고는 있는데, 뭔가 개운치는 않습니다. 아두이노에서는 간단하게 저항 하나 달고 잘 읽었습니다만... 

 

  • 보통 ADC(Analog Digital Converter)에서 배터리 값은 0~4095 값으로 나옵니다. 
  • 보통 ESP8266이나 ESP32는 0~1V DC만 읽을 수 있습니다. 
  • 배터리 양단에 220kΩ을 연결(실제로는 200+9.7x2)하고 합친 후(분압) A0핀에 연결하였습니다.(Seeed studio wiki에 나온 어떤 사용자의 방법) 
  • ESP32는 11dB attenuation(감쇄)을 통해 2.5V DC까지 읽을 수 있다고 합니다. 
  • 분압하고 감쇄를 통하여 읽은 값은 배터리 4.1V일 때 2767가량으로 나옵니다. 이론적으는 핀에서 읽는 최대 값을 2.5V로 해야하겠지만, raw로 읽은 4095값이 3V라고 가정하고, 분압을 해서 전압이 1/2로 줄었으므로 2를 곱해 주어 보았습니다. 
  • 2767 x 2 x 3 ÷ 4095 = 4.054V
  • 일단 실제 전압 값과 유사하게 나왔으므로 이렇게 이용해 보려고 합니다. 
 

추가적인 문제점 수정 기록 

 
 
  • 홈어시스턴트에서 급식기를 절전(deep sleep) 시키는 버튼을 누르면, 열려 있는 경우 뚜껑을 닫고 절전에 곧바로 진입하도록 수정했습니다. 추가로 절전 금지 버튼은 삭제(작동 안됨)했습니다.
  • 탐지 거리는 0.85m로 넓히고, 45초 후 닫히도록 수정했습니다. 
  • PIR 센서의 경우 탐지 범위가 넓으므로 상, 좌, 우에 절연 테이프를 감아서 필요 없이 절전에서 벗어나지 않도록 했습니다. 
  • 초음파 센서의 방향을 위쪽으로 약간 올려주어 반려견의 몸이 인식되도록 했습니다.  
  • 야외에 놓은 경우 초음파센서의 값이 NaN으로 자주 나와서 닫히지 않는 문제가 있었는데, 아래의 코드를 넣어서 해결했습니다. 이제 1.8미터 이후의 값도 나오고(예: 2.1m) NaN이 가끔 나와도 문제가 없어졌습니다. => 최종으로는 람다함수에서 처리하고 사용하지 않게 되었습니다. timeout은 나중에 3미터로 수정. 기본은 2m라서 NaN이 나온다고 합니다. 
    timeout: 4m
    force_update: false
    filters:
      lambda: if (isnan(x)) { return 999.0; } return x;
 
 
 
 
 
 
 
 
 
 
  • 오작동(없는데 열리고 안닫히고 등등)이 많아서 초음파 센서의 변화 이벤트에 맞춰서 람다 함수를 기존 주기적 호출에서 이동하고, 열림 감지(0.85미터 미만)를 첫번째 값으로 처리하지 않고 세번 누적된 후 하도록 했습니다. 덧붙여 deep sleep도 enter로 바로 들어가지 않고 allow로 해서 상태 변화도 모니터링하기 편하게 했습니다. 그리고, 모터부도 완전히 분해한 후 다시 조립하고 뚜껑의 경첩 부분도 손을 봐주었습니다.   
  • 아래 그림과 같이 초음파 센서가 오작동 할 때도 있습니다. 갖고 있는 mmWave센서는 전류를 많이(40mA이상) 사용해서 채용을 고려하지 않고 있습니다만... 

9시12분경부터 절전모드로 진입시킬 때까지 랜덤한 값이 나오는 모습입니다.

 
 

초음파 거리센서 => 8x8 열화상 센서로 교체 

초음파 센서가 ESP32-C3가 deep sleep모드로 오래 꺼져 있다가 켜지면 불안정하게 작동(위 그림 참조)하거나 몇 십 초간 가까운 물체 인식이 안되는 경우가 잦았습니다. 뭔가 하드웨어적인 문제(ESPHome의 Deep Sleep과 관련된 혹은 기타 등등)가 있어 보이는데, 더 이상 파헤치는 것은 가능은 하겠지만 어려워 보였습니다. 

 

그래서, 일전에 레인지 후드 자동화(AMG8833)와 센서 교체(MCU8833)를 하고 남은 8x8 총 64개의 적외선 온도 센서를 가진 MCU8833을 써보기로 했습니다. 초음파 센서보다는 전류 소모가 많은데 MCU8833은 3.3V로 동작하고 약 20mA를 사용합니다.

 

일전에 ESPHome을 위한 9600bps로 나오는 시리얼데이터 분석용 C++ 코드는 이미 만들어 놓았으므로 쉽게 결합할 수 있을 줄 알았는데(?) 여러 가지 문제점이 발견되었고 해결하였습니다.  

  • MCU8833의 Tx와 Rx를 연결한 ESP32-C3의 GPIO21과 GPIO20은 디깅 log용으로 할당되어 있으므로 .yaml파일에서 logger:의 항목에 "baud_rate: 0"를 추가하여 시리얼포트의 중복 사용을 피하였습니다(에러 메시지: You're using the same serial port for logging and the UART component. Please disable logging over the serial port by setting logger->baud_rate to 0.) 
  • uart의 rx_buffer_size를 128로 줄이고, mcu8833.h에서도 MAX_BUFFER를 512에서 128로 줄이고 PollingComponent는 5000에서 1000으로 줄였습니다. 
  • 뚜껑을 열고 닫기 위한 람다 코드는 template 센서를 "update_interval: 1s"로 추가하여 처리하는데, binary_sensor로 추가하면 엄청나게 호출이 일어나서 처리가 안됩니다. 

MCU8833 열화상 센서를 사용하여 사람이나 동물을 감지하여 뚜껑 열기

실내에서는 max - min > 4를 쓰면 되지만, 마당에 설치해 놓고 값을 살펴보니 max - avg > 10을 써야 될 것 같습니다. 값은 경험적으로 수정해서 사용하다가 더 좋은 방법을 생각해야겠습니다. coveropen timeout값은 체온 감지 시 60으로 설정되어 카운트다운이 됩니다. 0보다 작아지면 뚜껑을 닫은 후 설정된(PIR 센서로 깨어난 경우 30초) 시간 후에 최대 절전 모드로 진입하는 방식입니다. 

 

뚜껑이 닫히면 최대 절전 모드로 진입하여 홈어시스턴트에서 접근 불가능합니다.

 

마당에 놓은 후 온도 비교(차트 위에서부터 max, avg, min), 초반에 뾰족하게 올라가는 부분이 강아지 체온 감지 부분

 

초음파를 없애고 우측 하단에 열화상 센서를 넣은 모습

PIR 센서의 좌, 상, 우측의 절연 테이프는 불필요한 wakeup을 방지하기 위한 것입니다. 

# captive_portal 아래 부분만 & deep sleep 부분은 코멘트 처리  
uart:
  id: uart_bus
  tx_pin: GPIO21  # D6
  rx_pin: GPIO20  # D7
  baud_rate: 9600
  rx_buffer_size: 128

#deep_sleep:
#  id: deep_sleep_1
#  run_duration: 
#    default: 120s
#    gpio_wakeup_reason: 30s
#  wakeup_pin_mode: 
#    IGNORE 
#  sleep_duration: 1440min  # cannot omit 
#  wakeup_pin: GPIO4
  #wakeup_pin_mode: INVERT_WAKEUP

globals:
- id: somebody_count
  type: int 
  initial_value: "0" 
- id: cover_status
  type: int 
  initial_value: "0"   

sensor:
  - platform: custom
    lambda: |-
      auto mcu8833 = new MCU8833Component(id(uart_bus));
      App.register_component(mcu8833);
      return {mcu8833->max_temperature, mcu8833->min_temperature, mcu8833->avg_temperature, mcu8833->min_index, mcu8833->max_index};
    sensors:
      - name: "Thermal Max"
        id: thermal_max 
        unit_of_measurement: °C
        device_class: temperature
        accuracy_decimals: 2
      - name: "Thermal Min"
        id: thermal_min 
        unit_of_measurement: °C
        device_class: temperature
        accuracy_decimals: 2
      - name: "Thermal Avg"
        id: thermal_avg
        unit_of_measurement: °C
        device_class: temperature
        accuracy_decimals: 2
      - name: "Thermal Min Index"
        accuracy_decimals: 0
      - name: "Thermal Max Index"
        accuracy_decimals: 0
  - platform: adc
    pin: GPIO2 # D0 
    name: "autopetfeeder"
    raw: true 
    attenuation: 11dB
    update_interval: 2s    
    filters:
      - multiply: 0.0014652014652015  
      - sliding_window_moving_average:
          window_size: 12
          send_every: 12
  - platform: template 
    name: "coveropen timeout"
    lambda: |-
      static long count = 0;  

      // 실내에서는 4도 차이로 충분하지만 야외에서는 더 큰 차이가 남(예: 7도) 
      // 경험적으로 값을 정할 필요... 
      if(id(thermal_max).state - id(thermal_avg).state > 7) {
        count = 60;
      } else {
        count--;
      }

      if(count == 60 && id(cover_status) == 0) { 
        id(cover_status) = 1;

        auto call = id(my_cover).make_call();
        call.set_command_open();
        call.perform();        
      }

      if(count == 0 && id(cover_status) == 1) { 
        id(cover_status) = 0;    

        auto call = id(my_cover).make_call();
        call.set_command_close();
        call.perform(); 
      }

      return count; 
    update_interval: 1s 

binary_sensor:
  - platform: gpio
    pin: GPIO4
    name: "PIR Sensor"
    device_class: motion    
  - platform: template 
    name: "cover status"
    lambda: |- 
      if(id(cover_status) == 0) {
        return false; 
      } else {
        return true; 
      }    

output:
  - platform: ledc
    id: motor_forward_pin
    pin: GPIO9 
  - platform: ledc
    id: motor_reverse_pin
    pin: GPIO8 
  - platform: ledc 
    id: motor_enable 
    pin: GPIO10
  - platform: ledc
    pin: GPIO5
    id: gpio_d5    

light:
  - platform: monochromatic
    output: gpio_d5
    id: LED1
    name: "LED"

fan:
  - platform: hbridge
    id: my_fan
    name: "petfeeder motor"
    pin_a: motor_forward_pin
    pin_b: motor_reverse_pin
    enable_pin: motor_enable
    decay_mode: slow   # slow decay mode (braking) or fast decay (coasting).

cover:
  - platform: template
    name: "Cover"
    id: my_cover
    open_action: 
      - lambda: |- 
          auto call1 = id(my_fan).turn_on();
          id(my_fan).direction = FanDirection::FORWARD; 
          call1.perform();
      - delay: 0.15s 
      - lambda: |- 
          auto call2 = id(my_fan).turn_off();
          call2.perform(); 
          id(cover_status) = 1;
#      - deep_sleep.prevent: 
#          id: deep_sleep_1
    close_action: 
      - lambda: |- 
          auto call1 = id(my_fan).turn_on();
          id(my_fan).direction = FanDirection::REVERSE; 
          call1.perform();
      - delay: 0.1s 
      - lambda: |- 
          auto call2 = id(my_fan).turn_off();
          call2.perform(); 

          id(cover_status) = 0;       
#      - deep_sleep.allow: 
#          id: deep_sleep_1  

button:
  - platform: template
    name: autopetfeeder_sleep
    id: sleep_button
    on_press: 
      then:
        - lambda: |- 
            if(id(cover_status) == 1) { 
              auto call = id(my_cover).make_call();
              call.set_command_close();
              call.perform();   
            }

MCU8833.h 파일은 ESPHome addon을 사용하는 경우, config/esphome 밑에 파일을 만들어 주어야 합니다. 

 

#include "esphome.h"

class MCU8833Component : public PollingComponent, public UARTDevice {
 public:
  MCU8833Component(UARTComponent *parent) : PollingComponent(1000), UARTDevice(parent) {}

  Sensor *max_temperature = new Sensor();
  Sensor *min_temperature = new Sensor();
  Sensor *avg_temperature = new Sensor();
  Sensor *min_index = new Sensor();
  Sensor *max_index = new Sensor();

  // GY-MCU8833 serial protocol unknown 
  // HEADER = 6 bytes, BODY = 64 bytes, TAIL = 1 byte(checksum of 0 ~ 69th bytes)  
  const byte PACKET_LENGTH = 71;  
  const byte NUM_CELL = 64; 
  const byte START_BYTES[2] = {0xA4, 0x03};
  const int MAX_BUFFER = 128; 

  void setup() override 
  {
    // nothing to do here
  }

  // called 60 times per second
  void loop() override
  {
    // nothing to do here 
  }

  // for PollingComponent 
  void update() override
  {
    bool read_success = false; 
    byte packet[MAX_BUFFER];   
    byte read_index = 0; 

    while (available()) 
    {
      byte ch; 
      read_success = read_byte(&ch); 
      if (!read_success)
      {
        break; 
      }
      else
      {
        packet[read_index++] = ch; 
      }
      if (read_index > MAX_BUFFER - 1)
      { 
        break; 
      }
    }

    if(read_index >= PACKET_LENGTH)
    {
      // Variables to store calculations
      float sumTemp = 0;
      float maxTemp = -127; // Minimum possible 8-bit integer value
      float minTemp = 127;  // Maximum possible 8-bit integer value
      int maxIndex = -1; 
      int minIndex = -1; 
      byte packet_selected[PACKET_LENGTH]; 

        // Find keyword 
      int keyword = 0;
      byte data_start = 0; 
      byte data_index = 0; 
      byte checksum = 0; 

      for (byte i = 0; i < read_index; i++)
      {
        if (keyword == 0 && packet[i] == START_BYTES[0])
        {
          keyword = 1; 
          packet_selected[data_index++] = START_BYTES[0]; 
        }
        else if(keyword == 1 && packet[i] == START_BYTES[1]) 
        {
          keyword = 2; 
          packet_selected[data_index++] = START_BYTES[1]; 
        }
        else if(keyword == 2) 
        {
            if (data_index == (PACKET_LENGTH - 1))
            {
              checksum = packet[i];
              break; 
            }
            else
            {
              packet_selected[data_index++] = packet[i]; 
            }
        }
      }

      // ignore incomplete packet 
      if (data_index != (PACKET_LENGTH - 1)) return; 

      // verify packet : Checksum 8bits modulo 256 
      int checksum_calc = 0; 
      for (byte i = 0; i < PACKET_LENGTH - 1; i++)
      {
        checksum_calc += (int)packet_selected[i];
      }

      byte checksum_result = (byte)(checksum_calc % 256); 
      if (checksum_result != checksum)
      {
          return; 
      }

      // Find maximum, minimum, and sum of temperatures
      for (byte i = 6; i < PACKET_LENGTH - 1; i++) 
      {
        signed char temp = static_cast<signed char>(packet_selected[i]);
        float temperature = static_cast<float>(temp); 
        if (temperature > maxTemp) {
          maxTemp = temperature;
          maxIndex = i + 1; 
        }
        if (temperature < minTemp) {
          minTemp = temperature;
          minIndex = i + 1; 
        }
        sumTemp += temperature;
      }

      // Calculate average temperature
      float avgTemp = sumTemp / NUM_CELL;

      max_temperature->publish_state(maxTemp);
      min_temperature->publish_state(minTemp);
      min_index->publish_state(minIndex);
      max_index->publish_state(maxIndex);
      avg_temperature->publish_state(avgTemp);
    }
  }
};
 
 

 

 
 
 
 
 
 
 

열화상 센서 적용 시 발생한 문제점

태양 빛이 비추는 경우에는 위의 min - avg 값으로 판단할 수가 없었습니다. AMG8833 센서도 기대한 바와 다르게 낮은 온도로 표시됩니다. 물론 거리에 따라 온도 감지가 다른 것은 정상적이므로 넓은 공간의 야외에서 사용하는 것은 본래의 센서 목적과 맞지 않다는 생각이 듭니다. 

 

설치 높이와 각도만 변경했는데도 온도가 급변하는 것을 보면 통제되지 않은 환경에서 매직 넘버에 의존하여 사람이나 동물이 있다 없다 판단하는 것은 불가능하다고 봐야겠습니다.  

낮에는 평소에도 온도차이가 10도가 넘어감/설치 위치만 변경했는데 최저 온도가 영하로 떨어짐(기온은 영상18도)

최종 갱신되는 소스 코드는 sevengivings/autopetfeeder: Automated pet feeder made with ESPHome (github.com) 을 참고해 주시고, 향후 개선을 하게 되면 계속 이어서 내용을 업데이트 하겠습니다.