Обновлено 2026-09-22

Petkit Fresh Element Solo

Petkit, модель Fresh Element Solo. 4/5 · пайка. Периферия сидит на самом модуле, второго MCU нет.

1. Название и модель

Fresh Element Solo — автоматическая кормушка с облачным приложением. На основной плате стоит ESP32-WROOM-32D, и в опубликованном конфиге ESPHome мотор, кнопки, оптические датчики, светодиод и пищалка подключены к его выводам напрямую.

Отдельного «MCU протокола», как у Levoit или Duux, в этой плате нет. UART на GPIO1 и GPIO3 со скоростью 9600 в конфиге объявлен, но порции выдаются GPIO, а не пакетами второму чипу. Для отбора устройств это важная развилка: кормушка сложная из-за разборки и пайки, а не из-за реверса протокола.

Питание в американской поставке — блок 5 В и бочковый разъём. Подойдёт другой блок 5 В с таким же штекером.

Есть две известные ревизии платы. Силикон на лицевой стороне и площадки снизу — это ревизия B. Распиновка в карточке для них общая, картинка подключения к программатору — нет.

Кормушка Petkit Fresh Element Solo
Fresh Element Solo снаружи. Плата стоит в основании, винты под резиновыми ножками. Фото: esphome/devices.esphome.io, GPL-3.0

2. Что даёт перепрошивка

Конфиг n6ham, на который ссылается каталог, умеет:

  • выдавать порцию кнопкой на корпусе или выключателем в Home Assistant
  • отдельно задавать размер ручной порции
  • расписание по часам: сколько порций выдать в каждый час, ноль означает «не кормить»
  • событие низкого уровня корма esphome.feeder_food_low
  • звуковой сигнал о пустом бункере в дневные часы и отдельный выключатель без звука
  • световой индикатор, который быстро мигает, пока нет Wi-Fi

Расписание в этом конфиге выполняется на самом устройстве, поэтому краткий обрыв Home Assistant не отменяет уже загруженные часы. Часы всё равно нужно откуда-то взять: в описании карточки для расписания нужен SNTP, то есть время из сети.

3. Что теряется

  • Приложение Petkit, камера и пуши, если они были завязаны на аккаунт
  • История кормлений в облаке
  • Гарантия после снятия резиновых ножек и основания
  • Заводская калибровка «одна порция» в граммах. Порция здесь считается ковшами, а вес зависит от корма
  • Инфракрасные датчики легко остаются «мёртвыми», если не включить их питание на GPIO33. Это выглядит как поломка после прошивки, хотя это настройка

4. Что понадобится

  • Отвёртка и возможность частично отогнуть резиновые ножки: под ними четыре винта основания
  • Фен на малом нагреве, если красный фиксатор разъёмов приклеился к дну
  • USB–UART на 3,3 В и провода к площадкам своей ревизии
  • Терпение на кнопку Wi-Fi: её держат в момент подключения переходника, чтобы войти в загрузчик
  • ESPHome и конфиг из n6ham/esphome-configs, а не укороченный пример «только мотор»

5. Разборка и пины

Программатор USB–UART подключают к модулю, который прошивают, крест-накрест:

  • Земля переходника (GND) — на землю платы. Этот провод обязателен.
  • TX переходника — на RX модуля. На плате площадка часто подписана RX, RXD или RXD0.
  • RX переходника — на TX модуля. На плате это TX, TXD или TXD0.
  • Питание логики — 3,3 В. Пять вольт на эти площадки не подают. Если плату уже кормит её блок, линию 3,3 В с переходника не подключают вторым источником.
  • Для входа в загрузчик ESP8266 и ESP32 площадку GPIO0 (IO0, BOOT) замыкают на землю и в этот момент подают питание. После старта записи землю с GPIO0 снимают.

Если у этой модели в тексте ниже другие имена площадок, прямой порядок TX–TX или прошивка по USB, делайте как написано там. Общая схема выше — для обычного модульного UART.

Основание снимается после четырёх винтов под резиновыми ножками. Ножки отклеивают частично, целиком срывать не нужно. Дальше основание отделяют от корпуса. Красный клей на разъёмах может держать пластик, помогает слабый нагрев.

Прошивка:

  • Ревизия A — контакты по схеме с карточки каталога (она взята из комментария на Reddit)
  • Ревизия B — лицевая сторона под силиконом, паять нужно к тестовым площадкам снизу
  • На обеих держат правую кнопку (Wi-Fi), подключают переходник и через одну-две секунды отпускают

Внутренние линии, общие для описанных ревизий:

  • GPIO5 — светодиод статуса
  • GPIO16 — пищалка RTTTL
  • GPIO34 — кнопка кормления
  • GPIO27 — датчик мотора
  • GPIO14 — оптический датчик корма
  • GPIO19 — разрешение мотора
  • GPIO33 — разрешение датчиков
  • GPIO18 — подача вперёд
  • GPIO17 — реверс

Назначение части силовых линий автор карточки помечает как не до конца ясное. Не переназначайте их «на всякий случай» под реле.

Ревизия A платы Petkit: GND, TXD и RXD к USB-UART
Ревизия A, плата D4_MAIN_V1.2. Жёлтый — земля, оранжевый — TX модуля на RX переходника, синий — RX модуля на TX переходника. Красный провод на фото идёт к кнопке Wi-Fi: её держат при подключении. Фото: esphome/devices.esphome.io, GPL-3.0
Ревизия B платы Petkit: площадки WIFI_TX0 и WIFI_RX0 снизу
Ревизия B, площадки снизу. Справа WIFI_TX0 и WIFI_RX0, слева GND и 3V3. Лицевая сторона этой ревизии залита силиконом, паять нужно сюда. Фото: esphome/devices.esphome.io, GPL-3.0

6. Прошивка

Готового файла «прошивка именно этой модели» на этом сайте нет. Её собирает ESPHome из текстового конфига YAML: туда входят Wi-Fi, пины и команды второго чипа. Чужой .bin из интернета почти наверняка собран с чужим паролем сети.

Какую программу ставить

  • ESPHome Dashboard — дополнение в Home Assistant или программа на компьютере. В ней пишут YAML, нажимают Install, и она сама собирает прошивку. Установка: дополнение для Home Assistant или команда pip install esphome.
  • ESPHome Web, сайт web.esphome.io, — загрузчик в браузере Chrome или Edge. Им записывают уже собранный .bin на ESP8266 и ESP32 через USB–UART. На BK72xx он не рассчитан.
  • esptool — программа Espressif, чтобы снять заводскую копию флеша и записать .bin, если браузер порт не видит. Репозиторий: espressif/esptool.
  • ltchiptool — то же самое для чипов Beken BK72xx (модули WB3S, WBR3, CB3S и похожие). ESPHome для них собирается через LibreTiny. Программа: libretiny-eu/ltchiptool, описание платы: компонент bk72xx.

Как собрать конфиг

  1. В Dashboard нажмите New Device и выберите чип, который стоит на плате: ESP8266, ESP32 или BK72xx. Имя — латиницей, без пробелов.
  2. Пароль Wi-Fi уберите в файл secrets.yaml рядом с конфигом. В YAML оставьте ssid: !secret wifi_ssid и password: !secret wifi_password.
  3. Блок UART, модель и скорости из раздела ниже вставьте в этот файл. Полный пример, если он длинный, берите по ссылке в источниках внизу страницы, а не из гайда соседней модели.
  4. Строки api: и ota: оставьте. Без api: Home Assistant устройство не увидит. Ключ шифрования Dashboard предлагает сам, его потом спросит интеграция ESPHome.

Как залить первый раз

  1. Подключите USB–UART, как в разделе «Разборка и пины»: земля переходника на землю платы, TX переходника на RX модуля, RX переходника на TX модуля. Логика 3,3 В. Имена площадок на конкретной плате — там же.
  2. Для ESP8266 и ESP32 замкните GPIO0 (IO0, BOOT) на землю и в этот момент подайте питание. Для BK72xx ltchiptool сам просит перезапустить чип, обычно кратким отключением земли.
  3. В Dashboard: Install → Plug into this computer, выберите порт переходника. Либо Install → Manual download, сохраните .bin и откройте его на web.esphome.io. Из терминала то же самое делает esphome run имя-устройства.yaml.
  4. Когда в логе появится IP-адрес, GPIO0 от земли отпустите, провода можно отпаять. Следующие обновления идут по Wi-Fi, пункт OTA, паяльник больше не нужен.

Дальше — только то, чем эта модель отличается от общей схемы.

Полный YAML длинный: в нём почасовое расписание, счётчик ковшей и сирена. Его берут файлом из репозитория n6ham или с карточки devices.esphome.io. Ниже только пины, без которых мотор не поедет.

feed_forward и feed_reverse в полном файле стоят в одной interlock-группе, чтобы не включиться вместе. Этот кусок без скрипта выдачи не заменяет конфиг: скрипт считает срабатывания GPIO27 и сам выключает мотор, когда ковшей достаточно.

Пищалка — выход LEDC на GPIO16 и компонент rtttl. Светодиод — GPIO5.

Базовый конфиг этой модели с карточки или из репозитория компонента. Wi-Fi, пароль и OTA задайте как в общем разделе выше. Пароли из исходника сюда не перенесены.

substitutions:
  name: fresh-element-solo
  friendly_name: Petkit Fresh Element Solo
  device_description: "Petkit Fresh Element Solo Pet Feeder"
  default_scoops: "1"
  min_scoops: "0"
  max_scoops: "6"
  name_add_mac_suffix: "false"

esphome:
  name: $name
  friendly_name: $friendly_name
  name_add_mac_suffix: $name_add_mac_suffix
  comment: $device_description
  on_boot:
    - light.turn_on:
        id: led
        effect: fast_blink

esp32:
  variant: esp32
  framework:
    type: arduino

logger:
  level: INFO
  baud_rate: 0

globals:
  - id: scoops_count
    type: int
  - id: max_scoops
    type: int
  - id: food_sensor_count
    type: int
  - id: last_food_sensor_count
    type: int
    initial_value: "0"
    restore_value: True
  - id: last_food_scoops_count
    type: int
    initial_value: "0"
    restore_value: True

script:
  - id: play_rtttl
    parameters:
      song_str: string
    then:
      - if:
          condition:
            lambda: return !(id(mute_sounds).state);
          then:
            - rtttl.play:
                rtttl: !lambda "return song_str;"
  - id: actuate_feeder
    parameters:
      scoops: int
    then:
      - if:
          condition:
            lambda: return scoops > 0;
          then:
            - logger.log:
                level: INFO
                format: "Serving %d scoops"
                args: [scoops]
            - lambda: |-
                id(play_rtttl)->execute("two_shorts:d=4,o=5,b=100:16e6,16e6");
                id(scoops_count) = 0;
                id(food_sensor_count) = 0;
                id(max_scoops) = scoops;
            - switch.turn_on: feed_forward
  - id: dispatch_feeder_food_dispensed_event
    parameters:
      event_message: string
    then:
      - homeassistant.event:
          event: esphome.feeder_food_dispensed
          data:
            message: !lambda return event_message;
  - id: dispatch_feeder_food_low_event
    parameters:
      event_message: string
    then:
      - homeassistant.event:
          event: esphome.feeder_food_low
          data:
            message: !lambda return event_message;
  - id: check_food_level
    parameters:
      food_dispensed: bool
      play_sound: bool
      send_event: bool
    then:
      lambda: |-
        if (id(last_food_scoops_count) > 0 && id(last_food_sensor_count) / id(last_food_scoops_count) < id(low_food_threshold).state) {
          if (play_sound) {
            id(play_rtttl)->execute("siren:d=8,o=5,b=100:d,e,d,e,d,e,d,e");  
          }
          if (send_event) {
            id(dispatch_feeder_food_low_event)->execute("${friendly_name} food level is low. Check the hopper.");
          }
          id(feeder_state).publish_state("Food level is low");
          ESP_LOGI("main", "Food level is low");
        } else if (food_dispensed) {
          id(feeder_state).publish_state("Food dispensed");
          id(play_rtttl)->execute("one_short:d=4,o=5,b=100:16e6");
          if (id(last_food_scoops_count) == 1) {
            id(dispatch_feeder_food_dispensed_event)->execute("${friendly_name} dispensed 1 scoop of food.");
          } else {
            id(dispatch_feeder_food_dispensed_event)->execute("${friendly_name} dispensed " + to_string(id(scoops_count)) + " scoops of food.");
          }
        }

light:
  - platform: binary
    id: led
    output: led_output
    effects:
      - strobe:
          name: fast_blink
          colors:
            - state: True
              duration: 125ms
            - state: False
              duration: 125ms
    internal: True

output:
  - id: led_output
    platform: gpio
    pin: GPIO5
  - platform: ledc
    pin: GPIO16
    id: rtttl_output

rtttl:
  output: rtttl_output

interval:
  - interval: 1s
    then:
      if:
        condition:
          wifi.connected:
        then:
          - light.turn_on:
              id: led
              effect: None
        else:
          - light.turn_on:
              id: led
              effect: fast_blink

uart:
  tx_pin: GPIO1
  rx_pin: GPIO3
  baud_rate: 9600

number:
  - platform: template
    id: default_scoops
    name: "Manual dispense scoops"
    icon: mdi:cup
    entity_category: config
    min_value: 1
    max_value: $max_scoops
    initial_value: 1
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: low_food_threshold
    # Minimum food quantity per scoop (specific to a particular dry food).
    name: "Low food threshold"
    icon: mdi:cup-outline
    entity_category: config
    min_value: 1
    max_value: 10
    initial_value: 5
    optimistic: true
    step: 1
    restore_value: true
    mode: slider
  - platform: template
    id: schedule_cups_0000
    name: "00:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_0100
    name: "01:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_0200
    name: "02:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_0300
    name: "03:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_0400
    name: "04:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_0500
    name: "05:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_0600
    name: "06:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_0700
    name: "07:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_0800
    name: "08:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_0900
    name: "09:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_1000
    name: "10:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_1100
    name: "11:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_1200
    name: "12:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_1300
    name: "13:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_1400
    name: "14:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_1500
    name: "15:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_1600
    name: "16:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_1700
    name: "17:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_1800
    name: "18:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_1900
    name: "19:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_2000
    name: "20:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_2100
    name: "21:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_2200
    name: "22:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider
  - platform: template
    id: schedule_cups_2300
    name: "23:00 cups"
    icon: mdi:cup
    entity_category: config
    min_value: $min_scoops
    max_value: $max_scoops
    initial_value: 0
    optimistic: true
    step: 1
    restore_value: true
    unit_of_measurement: scoops
    mode: slider

time:
  - id: sntp_time
    platform: sntp
    on_time:
      # Hourly
      - hours: 7-22
        minutes: 0
        seconds: 0
        then:
          - lambda: |-
              id(check_food_level)->execute(/* food_dispensed = */ false, /* play_sound = */ true, /* send_event = */ false);
      - hours: "*"
        minutes: 0
        seconds: 0
        then:
          - lambda: |-
              auto hour = id(sntp_time).now().hour;
              switch (hour) {
                case 0: id(actuate_feeder)->execute((int) id(schedule_cups_0000).state); break;
                case 1: id(actuate_feeder)->execute((int) id(schedule_cups_0100).state); break;
                case 2: id(actuate_feeder)->execute((int) id(schedule_cups_0200).state); break;
                case 3: id(actuate_feeder)->execute((int) id(schedule_cups_0300).state); break;
                case 4: id(actuate_feeder)->execute((int) id(schedule_cups_0400).state); break;
                case 5: id(actuate_feeder)->execute((int) id(schedule_cups_0500).state); break;
                case 6: id(actuate_feeder)->execute((int) id(schedule_cups_0600).state); break;
                case 7: id(actuate_feeder)->execute((int) id(schedule_cups_0700).state); break;
                case 8: id(actuate_feeder)->execute((int) id(schedule_cups_0800).state); break;
                case 9: id(actuate_feeder)->execute((int) id(schedule_cups_0900).state); break;
                case 10: id(actuate_feeder)->execute((int) id(schedule_cups_1000).state); break;
                case 11: id(actuate_feeder)->execute((int) id(schedule_cups_1100).state); break;
                case 12: id(actuate_feeder)->execute((int) id(schedule_cups_1200).state); break;
                case 13: id(actuate_feeder)->execute((int) id(schedule_cups_1300).state); break;
                case 14: id(actuate_feeder)->execute((int) id(schedule_cups_1400).state); break;
                case 15: id(actuate_feeder)->execute((int) id(schedule_cups_1500).state); break;
                case 16: id(actuate_feeder)->execute((int) id(schedule_cups_1600).state); break;
                case 17: id(actuate_feeder)->execute((int) id(schedule_cups_1700).state); break;
                case 18: id(actuate_feeder)->execute((int) id(schedule_cups_1800).state); break;
                case 19: id(actuate_feeder)->execute((int) id(schedule_cups_1900).state); break;
                case 20: id(actuate_feeder)->execute((int) id(schedule_cups_2000).state); break;
                case 21: id(actuate_feeder)->execute((int) id(schedule_cups_2100).state); break;
                case 22: id(actuate_feeder)->execute((int) id(schedule_cups_2200).state); break;
                case 23: id(actuate_feeder)->execute((int) id(schedule_cups_2300).state); break;
              }

binary_sensor:
  - id: manual_feed_button
    internal: true
    platform: gpio
    pin:
      number: GPIO34
      inverted: true
    on_press:
      then:
        - lambda: id(actuate_feeder)->execute((int) id(default_scoops).state);
  - id: motor_sensor
    internal: true
    platform: gpio
    pin:
      number: GPIO27
      inverted: true
    on_press:
      then:
        - lambda: |-
            id(scoops_count) += 1;
            if (id(scoops_count) >= id(max_scoops)) {
              id(feed_forward).turn_off();
              id(last_food_sensor_count) = id(food_sensor_count);
              id(last_food_scoops_count) = id(scoops_count);
              id(check_food_level)->execute(/* food_dispensed = */ true, /* play_sound = */ true, /* send_event = */ true);
            }
        - logger.log:
            level: INFO
            format: "%d/%d scoops served"
            args: [id(scoops_count), id(max_scoops)]

  - id: feed_sensor
    internal: true
    platform: gpio
    pin:
      number: GPIO14
    on_press:
      then:
        - lambda: |-
            id(food_sensor_count) += 1;

text_sensor:
  - platform: template
    name: "State"
    id: feeder_state
    entity_category: diagnostic

switch:
  - id: enable_sensors
    internal: true
    platform: gpio
    pin:
      number: GPIO33
    restore_mode: ALWAYS_ON
    disabled_by_default: true

  - id: enable_feeder_motor
    internal: true
    platform: gpio
    pin:
      number: GPIO19
    restore_mode: ALWAYS_OFF
    disabled_by_default: true

  - id: feed_forward
    internal: true
    interlock: &interlock_group [feed_forward, feed_reverse]
    platform: gpio
    pin:
      number: GPIO18
    restore_mode: ALWAYS_OFF
    on_turn_on:
      then:
        - switch.turn_on: enable_feeder_motor
    on_turn_off:
      then:
        - switch.turn_off: enable_feeder_motor

  - id: feed_reverse
    internal: true
    interlock: *interlock_group
    platform: gpio
    pin:
      number: GPIO17
    restore_mode: ALWAYS_OFF

  - id: mute_sounds
    name: Mute sounds
    icon: mdi:volume-off
    optimistic: true
    platform: template

sensor:
  - platform: wifi_signal
    name: "Signal"
    update_interval: 60s
  - platform: template
    id: dispensed_food_quantity
    name: "Dispensed food quantity"
    icon: mdi:cup
    entity_category: diagnostic
    state_class: "measurement"
    accuracy_decimals: 0
    lambda: |-
      return id(last_food_sensor_count);
  - platform: template
    id: dispensed_food_scoops
    name: "Dispensed food scoops"
    icon: mdi:cup
    entity_category: diagnostic
    state_class: "measurement"
    accuracy_decimals: 0
    lambda: |-
      return id(last_food_scoops_count);

button:
  - name: "Dispense food"
    id: dispense_food
    icon: mdi:food-turkey
    platform: template
    on_press:
      - lambda: id(actuate_feeder)->execute((int) id(default_scoops).state);
  - platform: restart
    name: "Restart"
    disabled_by_default: true

alias: Pet feeder notifications
description: ""
trigger:
  - platform: event
    event_type: esphome.feeder_food_low
    id: food_low
  - platform: event
    event_type: esphome.feeder_food_dispensed
    id: food_dispensed
condition: []
action:
  - if:
      - condition: trigger
        id:
          - food_low
    then:
      - service: notify.notify
        metadata: {}
        data:
          message: "{{ trigger.event.data.message }}"
      - service: notify.persistent_notification
        metadata: {}
        data:
          message: "{{ trigger.event.data.message }}"
mode: single

7. Настройка в Home Assistant

После обнаружения не включайте мотор ползунком скорости. Управление — это выдача N ковшей и 24 числа расписания. На панель хватает:

  • ручная выдача
  • размер порции
  • порог «мало корма»
  • выключатель звука
  • сенсор последнего числа ковшей

Событие низкого корма ловят автоматизацией на esphome.feeder_food_low. В данных события есть имя кормушки, это удобно, если их несколько.

Расписание живёт в числах ESPHome. Если правите его с телефона, убедитесь, что значения восстановились после перезагрузки (restore_value в конфиге как раз про это). Время для почасовой выдачи берётся из сети, поэтому роутер кормушке нужен даже когда Home Assistant лежит.

8. Известные баги и как их обойти

  • Датчики корма и мотора молчат, пока GPIO33 не включён. В конфиге выключатель датчиков — ALWAYS_ON и внутренний. Если собрать YAML без него, оптика «сломана»
  • Ревизии путают схемы пайки. Силиконовая заливка — повод перевернуть плату, а не ковырять лицевую сторону
  • На части плат с USB–UART нет напряжения на площадке 3,3 В, пока кормушка питается своим блоком. Люди на Reddit видели живые RX/TX от штатного адаптера и пустые 3,3 В от программатора. Питайте плату её блоком 5 В, а с переходника берите землю и UART
  • Плата D4-2_MAIN и другие ревизии требуют своих подписей WIFI_RX0 / WIFI_TX0. Если подписей нет, остановитесь и найдите фото своей ревизии, а не прозванивайте наугад
  • Мотор вперёд и назад без взаимоблокировки могут включиться вместе. Берите interlock из полного конфига
  • Порог «мало корма» зависит от размера гранул. Заводского числа граммов конфиг не знает
  • Это не Tuya-кормушка. Компонент tuya: здесь ничего не включит
  • В issue 3 устройство появлялось в Home Assistant без сущностей, пока в конфиге не было API и полного набора сенсоров из yaml репозитория
  • Питание от батареи или от блока на этой плате отдельно не снято: issue 2 спрашивает, есть ли для этого дорожка, и ответа с номером GPIO нет. Не добавляйте сенсор батареи наугад

9. Источники