Как известно, у каждого свой собственный груз ответственности. Так и у меня с котом свой контракт — я его гуляю и кормлю, а он остается великолепным. Честный размен, но моя умная автокормушка Petkit Fresh Element Mini (модель P530) начала очень странно работать — то терять связь со своим китайским отечеством, то перекармливать ложными срабатываниями. В общем, всячески подставлять меня под расторжение договора.
А что это значит?

Правильно — самое время привнести в неё непоправимые улучшения, все как мы любим :)
Первое знакомство
Признаюсь, я не проводил предоперационной подготовки — решил сохранить некое чувство новизны. И был сполна вознагражден, ведь отщелкнув защелки и открутив болты, я начал расплываться в улыбке:

Да-да, сложно представить подарок лучше, чем увидеть ESP8266. Теперь можно не копать плату в поисках плавающих глюков, вслепую тыкая мультиметром и осциллографом, а прошить туда ESPHome и нормально раздебажить и/или закостылить появившиеся бажульки. Заодно и отучить кормилку от облаков — это всегда правильно, это я одобряю.
Более подробно периферия (с точки зрения ESP8266) на материнской плате выглядит так:

ESP8266 общается с ISD91230 по UART (как оказалось позже), а также к нему подключен RTC PCF8563 по I2C: SDA на GPIO5, SCL на GPIO14. Для визуалов:

ISD91230 отвечает за работу с датчиками, лампочками, мотором и прочей механикой. ESP8266 нужен для высокоуровневой логики — расписание, интернеты, кнопка ручной кормежки и все в таком духе.
Подготовка
Дальше стало очевидно, что пора начинать гуглить. К сожалению, ничего “коробочного” я не нашел, зато наткнулся на отличную репу: https://github.com/earlynerd/petkit-serial-bus — где автор больмень разреверсил протокол и утверждал, что ESP8266 с ISD91230 общаются по UART.
Но, прежде чем погрузиться в это великолепие, я достал паяльник, взял немного МГТФ, припаялся к важным для прошивки пинам и подключил USB-TTL (не забыв про питание от лабораторника, но это уже за кадром).

GPIO15 замкнул на землю, чтобы перевести ESP8266 в режим прошивки по UART (см. Boot Mode Selection). Очень удобное решение инженеров Petkit — совместить GPIO15 ESP8266 с Reset пином ISD91230, чтобы последний не мешался.
Почему сразу USB-TTL? Потому что мне так больше нравится: сначала sanity-check прошиваемости, потом дамп прошивки, и только затем можно тратить время на игры с протоколом. К счастью, никаких опций безопасности не включено, ничего глитчить не нужно, и прошивка спокойно дампится (вместе с кредами в plaintext, офк).
% esptool.py -p /dev/serial/by-id/usb-1a86_USB_Single_Serial_5AE6065766-if00 read_flash 0 ALL esp8266.bin
esptool.py v4.8.1
Serial port /dev/serial/by-id/usb-1a86_USB_Single_Serial_5AE6065766-if00
Connecting...
Detecting chip type... ESP8266
Chip is ESP8266EX
Features: WiFi
Crystal is 26MHz
MAC: c8:c9:a3:03:d6:a3
Stub is already running. No upload is necessary.
Configuring flash size...
Detected flash size: 2MB
2097152 (100 %)
Read 2097152 bytes at 0x00000000 in 190.2 seconds (88.2 kbit/s)...
Hard resetting via RTS pin...
Про протокол
Пришла пора узнать, действительно ли ESP8266 общается с ISD91230 по UART и совпадает ли реальность с petkit-serial-bus. Автор описывает протокол так:
- есть пакет запроса от ESP8266
AA AA 07 01 01 59 9B, который состоит из:AA AA— заголовок07— длина пакета вместе с заголовком01— тип пакета, в данном случае это запрос состояния01— порядковый номер (будет использоваться в ответе)- опциональный
payload, которого тут нет 59 9B— CRC-16 с сидом0xFFFFвсего пакета
- на запрос ISD91230 всегда отвечает ACK пакетом вида
AA AA 08 01 01 01 94 13, где:AA AA— заголовок, как и в предыдущем случае08— длина пакета, как и в предыдущем случае01— тип пакета для которого предразначен ACK01— порядковый номер пакета запроса01— всегда таков, видимо значитOK94 13— CRC-16, как и в предыдущем случае
- и репортами
AA AA 12 02 FF 00 00 01 08 EC 02 3F 08 71 02 20 24 C5, где:AA AA— заголовок, как и в предыдущем случае07— длина пакета, как и в предыдущем случае02— тип пакета, в данном случае статусFF— порядковый номер пакета, для периодических (e.g. статуса, отправляемого раз в пару секунд) всегда равен0xFF, в остальных случаях — порядковый номер пакета запроса- опциональный
payload, в данном случае:00— статус еды (опрашивает оптический сенсор у бака):0x00— пуст,0x01— полон00— статус дверцы (опрашивает оптический сенсор дверцы):0x00— закрыта,0x01— открыта- дальше хз что за байтики
24 C5— CRC-16, как и в предыдущем случае
Довольно стройный и логичный формат для бинарного протокола — тут тебе и magic, и размер, и контрольная сумма, все как положено. Похожее можно встретить то тут, то там, потому что это база. Еще бы добавить версию протокола, но при общении внутри одной железки не так критично. Хотя возможно одна из AA — это именно она, кто знает.
Но теория теорией, а для практики я подключаю логический анализатор DSLogic Plus к TX/RX + GND:

Запускаю DSView, добавляю декодер UART (115200 baud 8n1) на оба канала, и всё выглядит правдоподобно:

Если призумить, виден первый пакет от ISD91230:

Затем — статус (seq 0xFF, потому что это не ответ на запрос):

Дальше просыпается ESP8266, который отправляет свое стандартное приветствие (внимание: он это делает на 74880 бод, а уже потом переключится на 115200):

и он же запрашивает статус:

На который ISD91230 отвечает ACK:

и возвращает статус (тут seq не 0xFF, потому что это ответ на запрос с seq 0x01):

Пока всё сходится. Но это только транспорт: команды и их значения могут отличаться чуть более, чем полностью. Вдруг разные ревизии, версии прошивки или вообще модель (хотя у автора тоже Petkit Fresh Element Mini), ну вы знаете как оно бывает. А потому нужно собрать пакетики именно из моей кормилочки.
Для сбора пакетиков можно было бы продолжать использовать логический анализатор. Или подключить двухканальный USB-TTL. Или, как мне, захотеть еще немного запаха припоя и спаять сниффер на RP2040-Zero:

В котором соединить GPIO0 с GPIO5 (ESP8266 TX), GPIO1 с GPIO4 (ISD91230 TX). Залить прошивку petkitBusMonitor. И чуть подсобрать все воедино (оптические сенсоры наличия корма, очевидно, можно не подключать):

Подаем питание на кормилочку и вуаля — запуск:
% tio /dev/serial/by-id/usb-Raspberry_Pi_Pico_E6617C93E3301B2C-if00
[15:28:28.395] tio 62083fd
[15:28:28.395] Press ctrl-t q to quit
[15:28:28.396] Connected to /dev/serial/by-id/usb-Raspberry_Pi_Pico_E6617C93E3301B2C-if00
ISD91230: packet type: 0, length: 7, seq: 0, crc: 7A8B [Valid!],
ISD91230: packet type: 2, length: 18, seq: 255, crc: CCBC [Valid!], Data: 0x01010008F902420CCC0339
ESP8266: packet type: 1, length: 7, seq: 1, crc: 599B [Valid!],
ISD91230: packet type: 1, length: 8, seq: 1, crc: 9413 [Valid!], Data: 0x01
ISD91230: packet type: 2, length: 18, seq: 1, crc: 0ACF [Valid!], Data: 0x01010008D2023908420214
ESP8266: packet type: 1, length: 7, seq: 2, crc: 69F8 [Valid!],
ESP8266: packet type: 19, length: 9, seq: 1, crc: DF3E [Valid!], Data: 0x057E
ISD91230: packet type: 1, length: 8, seq: 2, crc: C140 [Valid!], Data: 0x01
ISD91230: packet type: 2, length: 18, seq: 2, crc: AE75 [Valid!], Data: 0x01010008D4023908420214
ESP8266: packet type: 3, length: 11, seq: 1, crc: 6CFA [Valid!], Data: 0x00050005
ISD91230: packet type: 19, length: 8, seq: 1, crc: B910 [Valid!], Data: 0x01
ISD91230: packet type: 20, length: 9, seq: 1, crc: AE3B [Valid!], Data: 0x0004
ISD91230: packet type: 3, length: 8, seq: 1, crc: FA73 [Valid!], Data: 0x01
ESP8266: packet type: 5, length: 9, seq: 1, crc: D309 [Valid!], Data: 0x0005
ISD91230: packet type: 5, length: 8, seq: 1, crc: 48D3 [Valid!], Data: 0x01
ESP8266: packet type: 1, length: 7, seq: 3, crc: 79D9 [Valid!],
ISD91230: packet type: 1, length: 8, seq: 3, crc: F271 [Valid!], Data: 0x01
ISD91230: packet type: 2, length: 18, seq: 3, crc: B444 [Valid!], Data: 0x01010008D3023908420214
ESP8266: packet type: 4, length: 11, seq: 1, crc: CE7D [Valid!], Data: 0x00FF00FF
ISD91230: packet type: 4, length: 8, seq: 1, crc: 7FE3 [Valid!], Data: 0x01
ESP8266: packet type: 6, length: 9, seq: 1, crc: 057F [Valid!], Data: 0xFFFF
ISD91230: packet type: 6, length: 8, seq: 1, crc: 1183 [Valid!], Data: 0x01
ESP8266: packet type: 13, length: 19, seq: 1, crc: 62BA [Valid!], Data: 0x003C0190F01222201F4F01
ESP8266: packet type: 1, length: 7, seq: 4, crc: 093E [Valid!],
ISD91230: packet type: 13, length: 8, seq: 1, crc: E172 [Valid!], Data: 0x01
ESP8266: packet type: 1, length: 7, seq: 5, crc: 191F [Valid!],
ISD91230: packet type: 1, length: 8, seq: 4, crc: 6BE6 [Valid!], Data: 0x01
ISD91230: packet type: 2, length: 18, seq: 4, crc: BC0F [Valid!], Data: 0x01010008D3023908420214
ISD91230: packet type: 1, length: 8, seq: 5, crc: 58D7 [Valid!], Data: 0x01
ISD91230: packet type: 2, length: 18, seq: 5, crc: A63E [Valid!], Data: 0x01010008D4023908420214
ESP8266: packet type: 14, length: 14, seq: 1, crc: 0767 [Valid!], Data: 0x0300C800C80001
ISD91230: packet type: 14, length: 8, seq: 1, crc: B822 [Valid!], Data: 0x01
ESP8266: packet type: 1, length: 7, seq: 6, crc: 297C [Valid!],
ISD91230: packet type: 1, length: 8, seq: 6, crc: 0D84 [Valid!], Data: 0x01
ISD91230: packet type: 2, length: 18, seq: 6, crc: 5BD4 [Valid!], Data: 0x01010008D2023908410214
ESP8266: packet type: 13, length: 19, seq: 2, crc: AD1F [Valid!], Data: 0x003C0190F01222201F4F01
ESP8266: packet type: 1, length: 7, seq: 7, crc: 395D [Valid!],
ISD91230: packet type: 13, length: 8, seq: 2, crc: B421 [Valid!], Data: 0x01
ISD91230: packet type: 1, length: 8, seq: 7, crc: 3EB5 [Valid!], Data: 0x01
ISD91230: packet type: 2, length: 18, seq: 7, crc: B990 [Valid!], Data: 0x01010008D3023908420214
ESP8266: packet type: 1, length: 7, seq: 8, crc: C8B2 [Valid!],
ISD91230: packet type: 1, length: 8, seq: 8, crc: 2E8B [Valid!], Data: 0x01
ISD91230: packet type: 2, length: 18, seq: 8, crc: 2C47 [Valid!], Data: 0x01010008D5023908430215
ESP8266: packet type: 1, length: 7, seq: 9, crc: D893 [Valid!],
ISD91230: packet type: 1, length: 8, seq: 9, crc: 1DBA [Valid!], Data: 0x01
ISD91230: packet type: 2, length: 18, seq: 9, crc: F056 [Valid!], Data: 0x01010008D3023908410214
ISD91230: packet type: 1, length: 8, seq: 10, crc: 48E9 [Valid!], Data: 0x01
ESP8266: packet type: 1, length: 7, seq: 10, crc: E8F0 [Valid!],
ISD91230: packet type: 2, length: 18, seq: 10, crc: B5DD [Valid!], Data: 0x01010008D4023908420214
ISD91230: packet type: 14, length: 8, seq: 2, crc: ED71 [Valid!], Data: 0x01
ESP8266: packet type: 14, length: 14, seq: 2, crc: 7991 [Valid!], Data: 0x0103E803E8FFFF
ESP8266: packet type: 14, length: 14, seq: 3, crc: E6C0 [Valid!], Data: 0x0203E803E8FFFF
ISD91230: packet type: 14, length: 8, seq: 3, crc: DE40 [Valid!], Data: 0x01
ISD91230: packet type: 2, length: 18, seq: 255, crc: 956D [Valid!], Data: 0x01010008D4023908420214
Очередность запросов/ответов порой немного плавает, но это не сильно мешает. Вот важные запросы инициализации (моргалки и пищалки не считаем важными):
type: 0x13 payload: 05 7E (настройки двигателя?)
type: 0x03 payload: 00 05 00 05
type: 0x05 payload: 00 05
type: 0x04 payload: 00 FF 00 FF
type: 0x06 payload: FF FF
type: 0x0D payload: 00 3C 01 90 0F 01 22 22 01 F4 0F 01
Затем, тыкаю во все, что тыкается, и составляю список нужных мне команд:
- статус:
- запрос:
type: 0x01 payload: <none>
- ответ:
type: 0x02 payload: 00 01 00 XX XX XX XX XX XX XX:- (0) — состояние дверцы:
0x00— закрыта,0x01— открыта - (1) — наличие еды:
0x00— пусто,0x01— есть, что покушать - (2) — ready (?): сначала он
0x00, затем меняется на0x01 - (3-9) — я хз
- (0) — состояние дверцы:
- запрос:
- открыть дверцу:
- запрос:
type: 0x07 payload: 1E - ответ:
type: 0x08 payload: 02 0E 14 00 22:- (0) — успешность:
0x02— успех,0x00— ошибка - (1-4) — я хз
- (0) — успешность:
- запрос:
- закрыть дверцу:
- запрос:
type: 0x09 payload: 1E - ответ:
type: 0x0A payload: 02 08 14 00 30:- (0) — успешность:
0x02— успех,0x00— ошибка - (1-4) — я хз
- (0) — успешность:
- запрос:
- насыпать еды (запуск двигателя):
- запрос:
type: 0x0B payload: 01 01 01 50:- (0) — количество порций (одна порция около 5 грамм)
- (1-3) — я хз
- ответ:
type: 0x0C payload: 02 01 01 01 42, где по байтам:- (0) — количество отсыпанных порций
- (1) — хз
- (2) — хз, но при нескольких запросах только в финале будет не
0x00 - (3-4) — хз
- иногда следом идет запрос
type: 0x0B payload: 00 02 01 50. Смысл не осознал
- запрос:
- управление лампочками и пищалкой:
- запрос —
type: 0x0E payload: 03 00 C8 00 C8 00 01:- (0) — цель:
0x01— верхний LED,0x02— нижний LED,0x03— бипер - (1-2) — “on” state ms,
0xFFFFзначит всегда - (3-4) — “off” state ms,
0xFFFFзначит всегда - (5-6) — количество,
0xFFFFзначит всегда
- (0) — цель:
- запрос —
Это база, без которой кота не накормить :) Если же сматчить с petkit-serial-bus, то совпадение не 100% (например, перепутаны местами статус еды и дверцы), но довольно хорошее. Как я и говорил ранее, так бывает, потому и проверял, потому и прикопал сэмплов со своего девайса ;)
ESPHome компонент pkt_p530
Разумеется, в качестве прошивки я решил взять ESPHome, к которому написал компонент pkt_p530 для общения с ISD91230. Долго думал, как бы сохранить баланс между программированием на ямлах (буэ) и возможностью гибко кастомизировать процесс кормления и/или реакцию на разные непредвиденные ситуации. В итоге, пришел к простой конструкции:
- есть
P530Component, который:- занят транспортом: отправить пакетик, сгенерить seq no, вычитать ответы
- минимально парсит пакеты, прилетающие от ISD91230
- позволяет подписаться на пакет нужного типа, с нужный seq no и таймаутом
- обрабатывает всякую мелочевку, в духе работы с пользовательскими сенсорами
- есть набор
PktAction, каждый из которых:- имеет опциональные
on_error/on_completeветки - для них кастомный control flow, т.к. в норме
Actionв ESPHome это блокирущие fire-and-forget действия :/ - отравляет нужный набор пакетов, вешает коллбэки на получение ACK и ответов
- по коллбэку решает, что делать дальше: передать управление, выполнить
on_error/on_completeветку и т.д.
- имеет опциональные
Таким образом, получилась прямая конструкция с асинхронным компонентом и набором действий к нему. Например, код действия выдачи еды:
template<typename... Ts> class DispenseAction : public PktAction<Ts...> {
public:
TEMPLATABLE_VALUE(uint8_t, portions)
protected:
ErrorCode do_action_(const Ts &...x) override {
if (!this->parent_->has_food()) {
return ErrorCode::NO_FOOD;
}
uint8_t portions = this->portions_.value(x...);
uint8_t payload[] = {portions, 0x01, 0x01, 0x50};
// 1 portion is about 1.65s
uint32_t timeout = portions * 3000;
return this->send_cmd_(ReqType::DISPENSE, payload, sizeof(payload), ReportType::DISPENSE_DONE, timeout);
}
ErrorCode handle_report_(const std::span<const uint8_t> payload) override {
if (payload.size() < 3 || payload[2] == 0x00) {
return ErrorCode::NOT_IMPLEMENTED; // not done yet
}
return ErrorCode::OK;
}
};
Его использование в скрипте ESPHome:
script:
- id: feed
mode: single
parameters:
portions: int
then:
- pkt_p530.dispense:
portions: 2
on_error:
- logger.log: "Shit happens!"
- pkt_p530.door_close:
- pkt_p530.beep: "short"
- script.stop: feed
- logger.log: "Done"
Под катопом:
- запустится
DispenseActionи проверит наличие еды - если еда есть, то отправит запрос
DISPENSE (0x0B)с нужным количеством порций - подпишется на ACK для этого запроса и отпустит выполнение
P530Componentпри вычитывании ответов сматчит нужный ACK и дернет коллбэкDispenseAction- тот подпишется на репорт
DISPENSE_DONE (0x0C)и отпустит выполнение P530Componentпри вычитывании ответов обнаружит нужный репорт и дернет коллбэкDispenseAction- тот проверит, что пища достигла миски, отпишется от репорта
DISPENSE_DONEи выполнит веткуon_completeили передаст выполнение следующему действию
Для визуалов:

По мне выглядит неплохо. Никакой мешанины из wait_until/if + condition в скрипте ESPHome, минимум блокировок main loop, с таймаутами и прочей гигиеной, в духе возможности обработать ошибки. Из понятных улучшений — можно было бы не блокировать loop у P530Component при вызове коллбэков, но тогда все становится неприятно и путано.
Прошивка
Фуф, все запчасти готовы! Самое время опять подкинуть USB-TTL, прошить ESP8266 в ESPHome и заняться написанием конфига.
Сначала просим ESPHome запустить скрипт инициализации при запуске (рестарты не важны - его можно запускать сколь угодно много раз, я проверял), подключить нужные компоненты и не забывать отключить UART логгер:
esphome:
name: petkitp530
friendly_name: PetkitP530
on_boot:
then:
- script.execute: bootup
esp8266:
board: esp01_1m
external_components:
- source: github://buglloc/esphome-components
components: [pkt_p530]
refresh: 1h
logger:
hardware_uart: UART1
baud_rate: 0 # disable UART logger
uart:
id: isd91230
tx_pin: GPIO1
rx_pin: GPIO3
baud_rate: 115200
i2c:
- id: pcf8563_i2c
scl: GPIO14
sda: GPIO5
pkt_p530:
id: feeder
uart_id: isd91230
script:
- id: bootup
mode: single
then:
- pkt_p530.init:
on_error:
- script.execute: "boot_failed"
- pkt_p530.beep:
preset: "short"
count: 1
- pkt_p530.door_open:
- delay: 2s
- pkt_p530.door_close:
on_error:
- script.execute: "boot_failed"
- pkt_p530.led_upper: "steady_on"
- id: boot_failed
mode: restart
then:
- logger.log:
format: "Ooops, init failed!"
level: "INFO"
- pkt_p530.beep: "short"
- pkt_p530.door_close:
- pkt_p530.led_upper: "blink_fast"
- script.stop: "bootup"
Тут мы:
- запускаем действие инициализации
- затем короткий пик
- открываем дверцу
- ждем чутка
- закрываем дверцу
- включаем верхнюю лампочку
Открывание дверцы добавил, чтобы вернуть кормилку в ожидаемое состояние, мало ли чего. В случае ошибки реагируем большим количеством пиков, закрытием дверцы и начинаем быстро моргать верхней лампочкой.
Затем добавляем Binary Sensor для реакции на кнопки и скрипт кормления:
binary_sensor:
- platform: gpio
pin:
number: GPIO13
inverted: true
mode:
input: true
pullup: true
name: "Manual feed"
filters:
- delayed_on_off: 500ms
on_press:
then:
- script.execute:
id: feed
portions: 5
- platform: pkt_p530
id: feeder
door_opened:
name: "Door opened"
food_low_issue:
name: "Low food issue"
door_open_issue:
name: "Door issue (open)"
door_close_issue:
name: "Door issue (close)"
script:
- id: feed
mode: single
parameters:
portions: int
then:
- logger.log:
format: "Feeding..."
level: "INFO"
- if:
condition:
pkt_p530.has_food
then:
- pkt_p530.led_lower: "blink"
- pkt_p530.door_open:
on_error:
- logger.log:
format: "Can't open door: abort feeding"
level: "ERROR"
- pkt_p530.door_close:
- pkt_p530.beep: "short"
- script.stop: feed
- pkt_p530.dispense:
portions: !lambda return portions;
on_error:
- logger.log:
format: "Dispensing failed"
level: "ERROR"
- pkt_p530.door_close:
- pkt_p530.beep: "short"
- script.stop: feed
- pkt_p530.door_close:
on_error:
- logger.log:
format: "Door close door: abort feeding"
level: "ERROR"
- pkt_p530.beep: "short"
- script.stop: feed
- pkt_p530.led_lower: "steady_off"
- logger.log:
format: "Feeding complete, meow"
level: "INFO"
else:
- logger.log:
format: "NO FOOD!"
level: "ERROR"
- pkt_p530.beep: "long"
Тут мы при нажатии на кнопку ручного кормления запускаем скрипт feed с нужным количеством порций, который:
- проверит наличие еды, не обязательно, но чем раньше, тем проще
- начнет моргать нижней лампочкой
- откроет дверцу
- насыпет нужное количество порций еды
- закроет дверцу
- выключит нижнюю лампочку
В случае каких-то проблем — реагирует закрытием дверцы, пиком и морганием нижнего диода (продолжает моргать со старта). Отдельно binary sensor door_open_issue/door_close_issue сообщит о проблеме, на что можно отреагировать уже из Home Assistant (нотификашки, сообщенька в tg чатик и тд).
Кормушка почти готова, осталось завести кронячку кормления (не круто оставлять кота голодным при недоступности Home Assistant, потому все локально):
time:
- platform: pcf8563
id: pcf8563_time
address: 0x51
- platform: homeassistant
id: ha_time
on_time_sync:
then:
pcf8563.write_time
on_time:
- cron: '00 00 09 * * *'
then:
- script.execute:
id: feed
portions: 8
- cron: '00 00 21 * * *'
then:
- script.execute:
id: feed
portions: 4
Вот и все, дальше остается только тюнить и настраивать разные свистелки :) Полную версию конфига можно поштырить на github.
Результат
Самое время опробовать вживую:
На видео я:
- включил эту чудо-машину
- “накормил” котана из Home Assistant и хардварной кнопки
- напоследнок проверил, что не пропущу проблемы с дверцой
Имхо, в текущем виде его нельзя назвать production-ready, и какие-то мелочи нужно еще потюнить, но базовая функциональность работает, и это уже отлично :) Надеюсь, не оставлю кота голодным :D
А на сегодня у меня всё. Всем (づ˶•༝•˶)づ♡