This is a condensed field guide from reverse-engineering a Cleverio pet feeder so it runs fully local under Home Assistant, with no Tuya cloud. The original Tuya WBR3 WiFi module was removed and replaced by an ESP8266 (NodeMCU) running a small custom ESPHome component that impersonates the module on the serial bus.

The goal here is not to hand you finished firmware (that would be too specific to one setup), but to give you the parts that transfer: the wire protocol, the things that cost real time to discover, and the capture method that made it all tractable.

This document is based on observations made from my own hardware. All protocol descriptions were derived through analysis of serial traffic between components and are provided for interoperability purposes.

This document was developed with the assistance of AI tools.


1. The single most important realisation

These feeders are a two-chip design. A dedicated appliance MCU on the main board (silkscreened "JL" here) drives the motor, sensors, buttons and LEDs. The WiFi module is just a bridge that talks to that MCU over a 3.3 V UART and translates to/from the Tuya cloud. So:

  • You do not reflash or replace the appliance MCU. You replace (or impersonate) only the WiFi module.
  • Your ESP sits exactly where the module sat, speaks the module's side of the UART, and exposes whatever you like to Home Assistant. Scheduling, automations and notifications all move into HA, which does them better than the stock device anyway.

2. It is a Tuya device, but NOT the standard Tuya serial protocol

This is the trap that wastes the most time. ESPHome's built-in tuya component, and most Tuya MCU documentation, assume the standard serial format (header 55 AA, additive checksum, variable length). This feeder does not use that. On the wire it is a proprietary fixed 8-byte frame:

AA 55 | CMD | D0 D1 D2 D3 | CHECKSUM
  • Header is AA 55 (reversed from standard Tuya).
  • Fixed 8 bytes per frame.
  • Checksum is XOR(CMD, D0, D1, D2, D3), not an additive sum.
  • 9600 baud, 8N1.

Because of this, the tuya component cannot drive it. You need a small custom UART component (or equivalent) that frames, validates and responds to these packets yourself. The upside is the protocol is simple enough that this is not hard.

3. The Tuya cloud datapoint (DP) table helps you read, not skip, the wire

If you can pull the device's DP definitions from the Tuya developer platform, keep them. But understand what they are: they describe the cloud-facing interface the original module presented upward. They do not map 1:1 to the wire commands below. The module translated between the two. For example, manual feed is DP 3 in the cloud table but command 0x01 on the wire, and several DP IDs never appear as command bytes at all.

What the DP table is genuinely good for is semantics: knowing that "fault" is a bitmap, that "power_state" is an enum, that "battery" is a percentage, tells you what to look for and how to interpret a value once you find the right frame. It is a decoding key, not a shortcut.

4. Decoded wire protocol reference

Direction below is from the appliance MCU's point of view. "ESP" is the side your firmware plays (the former WiFi module). Dxx are the four data bytes.

CMD Direction Meaning Notes
0x00 both Handshake MCU repeats 00 6F 6F 01 01. ESP must reply 00 FF FF FF FF.
0x17 ESP -> MCU Module announce ESP sends 17 24 00 00 00 once after the first handshake. The MCU's own 17 00 00 00 00 is just its ack of that. Do not respond to the MCU's 17 or you get an infinite ping-pong.
0x0F / 0x10 ESP -> MCU Init queries MCU answers with date/time-like data. Not required for feeding to work.
0x0D / 0x0E ESP -> MCU Time sync MCU acks. Optional.
0x19 ESP -> MCU Init config ESP 19 05 05 05 05, MCU answers 19 06 06 06 06. Optional.
0x16 ESP polls Battery / keepalive ESP sends 16 00 00 00 00; MCU replies 16 LO HI 00 00. 16-bit little-endian raw value in D0(low)/D1(high).
0x18 ESP polls Keepalive (other sensors) ESP sends 18 96 00 00 00; MCU replies two 16-bit values (~950). Not battery; ignored.
0x01 ESP -> MCU Feed N portions 01 47 63 00 NN, where NN is the portion count. 47 63 00 are constant. Always followed immediately by the companion frame below.
0x1C ESP -> MCU Feed companion Constant 1C 00 00 00 03, sent right after every feed command.
0x0A MCU -> ESP Magazine / food sensor D3 = 0 means food present, D3 = 1 means empty. Pushed only on change, never at boot.
0x12 MCU -> ESP Power source D3 = 1 battery only, D3 = 2 mains/AC, D3 = 0 transient at boot.
0x05 MCU -> ESP Physical button event D3 = 01 is the feed button, D3 = 06 is the unlock button. See gotcha 5.2.
0x02 MCU -> ESP Fault / status Pushed ~8-9 s after a feed. D3 = 1 roughly means "nothing dispensed / empty" (does not block feeding). D2 = 2 roughly means jam. Full bitmap not pinned down.
0x06 both Multiplexed status (D2 selects the category) D2 = 00 food echo (D3 0/1), D2 = 02 lock state (D3 1 unlocked / 0 locked), D2 = 01 WiFi/connection status that drives the front LED. 06 00 00 01 01 (D3 = 01) gives a solid LED (connected), confirmed on hardware.
0x0C MCU -> ESP Unknown status Ack only.
any MCU -> ESP Acknowledgement <cmd> FF FF FF FF is the MCU acking a frame the ESP just sent. Treat all-FF data as an ack and ignore it. See gotcha 5.1.

Battery calibration (1S Li-ion on this unit): raw 609 corresponded to about 4.00 V, so volt = raw * (4.00 / 609). No-battery floor sat around raw 8-10. Verified at roughly 79 % at 3.95 V and 71 % at 3.89 V against a standard 1S discharge curve. Treat the exact constant as device-specific and recalibrate against your own cells.

Charge state was not a discrete frame. It was derived: charging if mains is present (cmd 12 D3 = 2) and a battery is present (measured voltage above a floor). You cannot distinguish "actively charging" from "full and on mains" this way.

5. Gotchas that cost real time

5.1 Do not blindly ack everything

A naive "reply to every frame with an ack" loop bites twice:

  • The MCU's 17 00 00 00 00 (its ack of your announce) gets acked back, producing an endless 17 ping-pong flood. Fix: never respond to incoming 0x17.
  • The MCU acks your feed with 01 FF FF FF FF and 1C FF FF FF FF. If you ack those in turn, you ping-pong and the motor feeds continuously. Fix: at the very top of your frame handler, if all four data bytes are FF, return immediately. It is an ack.

5.2 The physical buttons route through the module

This is non-obvious and important. The manual feed button is not handled locally by the MCU. When pressed, the MCU sends 05 00 00 00 01 to the module, and the module is expected to send the feed command back. If your ESP does not respond to 05 D3=01 by issuing a feed, the physical button stops working. So you must handle it. The unlock button (05 D3=06) by contrast is handled locally by the MCU; you only observe the resulting lock state via 06 D2=02.

5.3 Power and flashing

Feed the board's own 3.3 V rail into the ESP's 3V3 pin (not VIN, which expects ~5 V through the onboard regulator). Never power from USB and the board rail at the same time; pick one. The rail that fed the WiFi module handles the ESP8266's ~300-400 mA WiFi peaks without browning out. For initial flashing over USB, disconnect the board's UART (and its 3.3 V feed) first; after that, flash over WiFi/OTA so USB is never needed in place.

5.4 Boot-time "unknown" states are honest, not a bug

Power source resolves within a second or two of boot (the MCU pushes 0x12 early). The food sensor is change-only, so it legitimately has no value until the food state next changes. Leaving it unknown until then is more honest than fabricating a default; HA automations that key on it will not misfire on unknown. If you want a value at boot, the only clean route is to get the MCU to report current status, which means replaying the module's init burst and seeing whether it triggers a status dump. Optional.

5.5 The WiFi LED needs you to report "connected"

The original module reported its connection state to the MCU, which is what drove the LED solid instead of blinking. If your ESP never sends that, the LED stays in "searching" (blinking). The carrier is 06 D2=01, with the connection state in D3. Sending 06 00 00 01 01 (CK 06) makes the LED solid, confirmed on hardware. Tie it to your network-connected state so it goes solid once the ESP is online. The "not connected" value (D3 = 00) is a reasonable guess but was not separately verified; if it misbehaves, just keep sending the solid value. These are status frames the MCU only acks, with no mechanical action, so experimenting here is safe.

6. You do not need to decode everything

Scope down to what you actually want in HA. The hardest DP to reverse is the meal schedule (a raw schedule blob), and that is exactly the one you can skip, because HA does scheduling better. A practical minimal set is: feed (with portion count), food-empty notification, power source, and optionally lock state and a derived battery level.

7. The method that made it tractable: man-in-the-middle with the real module

The reliable way to get ground truth, including which side sent each frame, is to sit a fast 3.3 V microcontroller with two hardware UARTs (a Teensy works well; an RP2040 or ESP32 also works) between the appliance MCU and the still-working original WiFi module. It forwards bytes both ways and logs each frame with a direction tag. Then you drive the device normally through the stock app and watch exactly what goes over the wire for each action.

Why this beats guessing:

  • You see direction, which a single-wire passive sniff cannot give you.
  • You capture the real handshake, status frames and the exact feed command, including how the portion count is encoded.
  • It is your only window. Once the module is removed and replaced by your ESP, there is no original module left to observe. Capture battery/charge behaviour and anything else you are unsure about before you commit to the swap.

Minimal bridge sketch (Teensy-style), grouping bytes into frames on inter-frame silence:

const uint32_t GAP_US = 3000;   // 3 ms silence = frame boundary at 9600 baud
uint8_t bA[64]; int nA = 0; uint32_t tA = 0;
uint8_t bB[64]; int nB = 0; uint32_t tB = 0;

void setup(){ Serial.begin(115200); Serial1.begin(9600); Serial2.begin(9600); }

void flush(const char* tag, uint8_t* b, int& n){
  if(!n) return;
  Serial.print(tag);
  for(int i=0;i<n;i++){ if(b[i]<16) Serial.print('0'); Serial.print(b[i],HEX); Serial.print(' '); }
  Serial.println();  n = 0;
}

void loop(){
  while(Serial1.available()){ uint8_t x=Serial1.read(); Serial2.write(x); if(nA<64)bA[nA++]=x; tA=micros(); } // MCU -> module
  while(Serial2.available()){ uint8_t x=Serial2.read(); Serial1.write(x); if(nB<64)bB[nB++]=x; tB=micros(); } // module -> MCU
  if(nA && micros()-tA>GAP_US) flush("MCU->MOD ", bA, nA);
  if(nB && micros()-tB>GAP_US) flush("MOD->MCU ", bB, nB);
}

Power the real module from a proper 3.3 V source (the board rail or a bench supply), not from the microcontroller's 3.3 V pin, since the module's WiFi peaks exceed what that pin supplies. Common ground across all three.

To find a specific command, capture an idle baseline, trigger one action with a distinctive value (for example feed 6, then 2, then 4 portions with pauses between), and diff. The byte that tracks your input is the one you want. Adding millisecond timestamps and suppressing the 0x16/0x18 keepalive noise makes the meaningful frames jump out.

8. Confidence notes

  • Confirmed on hardware: frame format and checksum, handshake, feed command and companion, food-empty (0x0A), power source (0x12), manual-feed-button relay (0x05), the WiFi/LED status frame (0x06 D2=01 D3=01 = solid), the two ack/ping-pong pitfalls, battery raw value and rough calibration.
  • Partially mapped: the 0x02 fault bitmap. "Nothing dispensed" (D3=1) and "jam" (D2=2) were seen, but the full bitmap was not provoked exhaustively. The "not connected" value for the LED status (0x06 D2=01 D3=00) is a reasonable guess, not separately verified.
  • Not on the wire as clean values: battery percentage and an explicit charging flag. Battery is a raw ADC value in keepalive telemetry that you convert yourself; charge state is derived.

Values such as the 47 63 00 feed prefix, the battery constant, and the exact command set are specific to this board. Re-verify against your own hardware with the capture method above before trusting them.