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 endless17ping-pong flood. Fix: never respond to incoming0x17. - The MCU acks your feed with
01 FF FF FF FFand1C 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 areFF, 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
0x02fault 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.
Comments