Taking a Tuya cat feeder camera off the cloud: Thingino, a sniffed MCU protocol and MQTT

13 min read

My cat gets her dry food from a Princess "Automatic Pet Feeder Camera 4L": a hopper, a motor, a bowl, and a camera looking at the bowl. Like most cheap smart devices it's a white-label Tuya product. It only works through the Smart Life app and Tuya's cloud, and its firmware is built to upload clips to cloud storage while it's at it.

I didn't want a company to have a camera in my living room, or a say in when (or whether) my cat eats while I'm away. So the goal was simple to describe: the video stays on my network, my NVR records it, and Home Assistant decides when food drops. No cloud, no app.

That took one long weekend, a cheap flash programmer, a soldering iron and a lot of staring at hex. The result is princess-feeder-thingino on GitHub: a Thingino camera profile, a small daemon called feederd that bridges the feeder to MQTT, a Home Assistant config and the docs. The first half of this post is how it got figured out. The second half is the short version of the guide, if you have the same feeder.


First try: stay on the stock firmware

Tuya devices speak a local protocol on TCP port 6668, and with the device's local key (pulled once through the same Smart Life QR login Home Assistant's Tuya integration uses) you can talk to them without the cloud. Feeding worked right away: data point 231 is "feed N portions", and 238 reports what was dispensed a second later. It even kept working with the feeder's internet access blocked.

The camera didn't. The Tuya app's "local" live view is Tuya's P2P protocol: signalling over port 6668, video as KCP + AES over UDP. Two problems killed it:

  • The feeder only answers local stream requests after it has logged in to Tuya's MQTT broker since boot. Cold boot without internet and the P2P service never starts. It ignores the DHCP DNS server and asks 8.8.8.8 directly, and redirecting all of that to my own router didn't wake it up.
  • The video needs an 8-character P2P password that the Smart Life web portal doesn't hand out.

A hybrid (open the internet after every boot until the P2P service is up, then close it again) would still give Tuya a say every boot and still lacks that password. Time for custom firmware.


What's inside

The feeder opened up on the table, main board at the bottom
Opened up. The board at the bottom is the main board with everything on it; the one on the white plastic is a daughter board.
Part Chip
SoC Ingenic T31L (MIPS, 64 MB RAM)
Image sensor SmartSens SC2336
Wi-Fi AltoBeam ATBM6012B-X, over USB
Flash Puya P25Q64, 8 MB SPI NOR
Feeder MCU Nuvoton N76E003 (an 8051)

Everything lives on one main board: SoC, sensor, Wi-Fi, flash and the feeder MCU. The small board mounted on the white plastic is just a daughter board: a ribbon cable in, connectors out, a few transistors and resistors, and the USB-C power input.

That's good news on every line. The T31 is Thingino's best-supported SoC, the sensor and Wi-Fi chip are both supported, and the Galayou Y4 camera uses the exact same T31L + SC2336 + ATBM6012B-X combination, so there was a profile to start from.

The second best news: the T31 doesn't drive the motor itself. The N76E003 does, through a motor driver chip. It counts portions with a limit switch that clicks once per portion, watches an IR beam across the hopper for the food level, and keeps the time with an RTC so the feeder can run its schedule on batteries. A separate MCU means the T31 talks to it over a serial line, which is much easier to reproduce than motor control on GPIOs.


Dumping the flash, and the 5 V trap

Before writing anything, I wanted a full dump of the original firmware: it's the way back, and it's where the interesting stuff lives. I ordered the classic black CH341A programmer with a SOIC-8 clip.

The classic black CH341A board (mine has the newer CH341B chip on it, same story) has a known flaw: it powers the chip with 3.3 V, but drives the data lines at 5 V. The P25Q64 is a 3.3 V part. With the programmer plugged in and nothing in the socket, the multimeter said 3.3 V on VCC and 5.1 V on chip select. Not something to clip onto a board you want to keep.

The CH341A with its 3.3 V mod: blue wires from the CH341B to the regulator
The 3.3 V mod: pin 28 of the CH341 lifted off the board, and pins 28 and 9 wired to the 3.3 V regulator.

The fix is a well-known mod: lift pin 28 (VCC) of the CH341 chip and wire it, together with pin 9 (V3), to the output of the AMS1117 3.3 V regulator on the same board. After that the socket's data pins measured 3.3 V.

The flash clip on the P25Q64 next to the lens
The clip on the P25Q64, right next to the lens module.

The flash sits so close to the lens module that the clip doesn't fit as it comes. Anyone doing this will have to trim the clip's plastic on the lens side. A couple of minutes and a sharp Stanley knife did the job, but be patient and careful: you're cutting right next to the contacts.

The main board clipped and connected to the CH341A on the dining table
The whole setup: main board, clip, ribbon cable, programmer.

Two reads with flashrom -p ch341a_spi -r, two identical checksums, 8 MB of firmware.


Reading the dump

The U-Boot environment has the partition table: U-Boot, a kernel, a squashfs root, a squashfs "app" partition and a small jffs2 config partition. The config partition holds the device's Tuya keys and my Wi-Fi password.

The app partition contains /app/bin/ipc, the Tuya app that does everything: video, cloud, and the feeder. Its strings gave away the important part: it opens /dev/ttyS0 and speaks the Tuya MCU serial protocol (55 AA frames, DPID_MANUAL_FEED, FEED_REPORT). So the feeder MCU thinks the camera is a Tuya Wi-Fi module.

Reading the protocol from a binary is slow. Watching it is faster, and that needs a shell. To my surprise, the stock firmware ships with telnetd and a pretty complete BusyBox: wget, top, vi, watch, lsof, devmem, i2cdump, setserial, even whois. Yet there's no printf, stty, hexdump or wc. The root password is unknown, but the app partition runs a startup script, and the stock script even has a commented-out #telnetd & in it. So: unpack the squashfs, add /usr/sbin/telnetd -l /bin/sh &, repack it with the same compression and block size, splice it back into a copy of the dump at the partition's offset, and check that only bytes inside the app partition changed. Write it, boot, and telnet gives a root shell.


strace as a logic analyzer

I had a logic analyzer on order, but with a shell on the device there's a shortcut: watch the Tuya app's own reads and writes on the serial port. A static MIPS build of strace, attached to ipc and filtered to the file descriptor of /dev/ttyS0:

strace -f -tt -xx -e trace=read,write -e trace-fds=3 -p <ipc pid> -o /tmp/uart.log

Then feed from the app, press the buttons, cover the food sensor, and decode the log. What came out:

What On the wire
Feed N portions set DP 203 = N
Portions dispensed DP 215 = N, reported once when done
Food low DP 214, bit 1
Feed button pressed the MCU feeds by itself and reports 215 = 1
"Mic" button DP 240 (short press) and 241 (held)
Keepalive the MCU sends a heartbeat every second and asks for the time every ~30 s

The cloud's data points 231 and 238 from the first attempt are just the app's own numbering for 203 and 215. The proof was one line typed into the shell, with the motor out of the mechanism and sitting on its limit switch:

echo -ne '\x55\xaa\x13\x06\x00\x08\xcb\x02\x00\x04\x00\x00\x00\x01\xf2' > /dev/ttyS0

One rotation, and a 215 = 1 back from the MCU.

The LED that isn't a data point

The front LED wasn't on any GPIO I could toggle, and the MCU has no data point for it. It turned out to be the Tuya "network status" command: the Wi-Fi module tells the MCU its connection state (0 to 6), and the MCU turns that into a blink pattern. 0 is a slow blink, 2 fast, 4 steady on. 3 is the "not happy" double blink, except when it just goes dark and stays dark, so I don't use it.

And 7, which doesn't exist in the protocol, turns it off, cleanly and reliably. That's the one I wanted most: it's the only light on the device and it's annoying at night. Switching between 4 and 7 every 150 ms also gives crisp custom blinks, because the MCU acknowledges each command within ~160 ms.

One more find: the MCU falls back to its "no network" blink when nobody answers its heartbeat. Whatever runs on the camera has to keep answering, every second, forever.

Doom

With a root shell, an MCU feed command and a free evening, there was really only one thing left to do. Doom runs on the cat feeder: doom-ascii cross-compiled for MIPS, played over telnet, and every real kill in the game feeds a portion. Demo kills don't count. The cat was not consulted.


A Thingino profile

The Galayou Y4 profile was the starting point, minus the parts that don't exist on a feeder: its pan/tilt motor GPIOs clash with pins this board uses for other things, and its buttons and LEDs are MCU-driven here. The SoC variant was read straight from registers with devmem, because Thingino's soc script needs printf and the stock BusyBox doesn't have it.

The first Thingino image booted fine, and had no /dev/ttyS0: only the console UART was enabled. Two lines in the profile fixed that:

BR2_PACKAGE_THINGINO_KOPT_UART=y
BR2_PACKAGE_THINGINO_KOPT_UART_UART0=y

Worth knowing: Thingino doesn't use the stock MAC address, it derives one starting with 02:. So look for the feeder by its hostname in your DHCP leases, not by its old MAC.

Flashing over usbipd is slow

My laptop runs Windows, so the first write went through WSL with the programmer passed in over usbipd. Reading was fine. Writing crawled at about 0.65 MB per minute, because every 256-byte page means several USB round trips over a virtual network, and I gave up after 75 minutes. The same programmer passed through to a Linux VM on one of my servers did the full write and verify in about 10 minutes. After that one write, every update goes over the network with Thingino's make ota, and the clip is only there to unbrick.

Finding the night vision

The board has IR LEDs and an IR-cut filter, but which GPIOs? The stock app held a handful of pins as outputs, so the method was to toggle them one by one while watching. GPIO 59 gave a faint red glow I could just see with my own eyes, and through a phone camera (which picks up 850 nm infrared) the IR LEDs were bright and obvious. The IR-cut filter is a little coil that moves a filter in front of the sensor, and it makes an audible tick when pulsed: GPIOs 10 and 11, one for each direction.


feederd

The last piece is the job the Tuya app did: talk to the MCU, and give the rest of the house a way to feed. That's feederd, a single C++ file and a single poll() loop over the serial port and an MQTT socket. It answers the heartbeat and time requests, sends feed commands, and turns the MCU's reports into MQTT topics. I considered Rust, but MIPS is a tier 3 target that needs nightly, and the binary would likely have been two to four times the ~130 KB the C++ version is. It's linked against the image's own uClibc and libmosquitto, with libstdc++ linked statically.

The topics follow the pattern of my other MQTT bridges: every topic is two-way. feederd publishes the real state, retained, and listens on the same topic for changes. Write 5 to .../feed and it shows 5 while dispensing, then 0. That pattern needs a few rules to not shoot itself in the foot:

  • feederd remembers what it published and skips the echo, so its own status update never becomes a command.
  • Anything that arrives with the retain flag (replayed by the broker on subscribe) is old state, never a command. Otherwise a reboot during a feed would feed again.
  • An invalid write, or a write to a read-only topic, gets the real state published back. The topic never lies.

The rest: food (ok/low), led (off, on, slow_blink, fast_blink, and idle for a configurable default), aux-button for the "mic" button, last-feed as JSON with the source (mqtt or button), lifetime and daily portion counters, and an online topic with a last will. The daily counter resets at 04:00, so the cat's after-midnight complaints count towards the day they belong to.

Counters live in a state file on the flash, written only when food drops. If the file is missing, which happens after a full reflash, feederd adopts the broker's retained lifetime total before publishing anything, so the Grafana graph never dips to zero.


What I didn't touch: schedules, speaker and mic

The Tuya app lets you set a feeding schedule, and I haven't decoded how that works. My guess is that the MCU runs it. It has its own real-time clock and an EEPROM for settings, the host sends it the local time every 30 seconds, and the base has room for three D cells (I don't use them). That points at a feeder that keeps feeding on schedule during a power cut, while the T31, the camera and feederd are off. I haven't verified that, though, and there's a counter-argument: the stock app also keeps the schedule itself, in /conf/tuya/gv_feeder_schedule.json on the camera side, and when queried, the MCU only reported the button, food-sensor and dispense data points, nothing that looks like a schedule.

Some pointers for anyone who wants to dig into it:

  • Sniff it. With the telnet image and strace from earlier, change the schedule in the Tuya app and see what goes over /dev/ttyS0. None of my captures contain schedule traffic, because I never touched the schedule while sniffing. In the cloud API it's data point 236 ("meal plan"), and the stock app has handlers called DPID_MEAL_PLAN and handle_DP_feeder_schedule_new.
  • Read the EEPROM. The stock BusyBox has i2cdump, and the P24C02A is only 256 bytes. Dump it before and after changing the schedule.
  • Test it. Set a schedule on stock firmware, unplug the power with batteries in, and watch whether food drops at the scheduled time.

On the Thingino side that has one practical consequence: if the MCU does store a schedule, a schedule you set before flashing could keep firing. Clear it in the Tuya app before you flash, or keep an eye on the last-feed topic for feeds with the button source that nobody pressed for.

The speaker and microphone are mostly untested with Thingino. I don't need them: when I closed everything back up I left the microphone out and disconnected the speaker. The microphone was never tried. The speaker played one test sound with GPIO 50 (the amplifier enable) driven high, and I stopped there, so it's probably not far off if you want it.


Running it yourself

Everything is in the repository, with a step-by-step flashing guide. The short version:

  1. Check your board. T31L, SC2336, ATBM6012B-X, P25Q64, N76E003. Different chips need a different profile.

  2. Check your programmer. Measure chip select on the empty socket. 5 V means the 3.3 V mod first.

  3. Dump twice, compare the checksums, and keep the dump private.

  4. Build Thingino with the profile from the repo. Copy the repo's thingino/ folder into the Thingino checkout and fill in the overlay with your own values:

    user/princess_feeder_t31l_sc2336_atbm6012bx/overlay/
      etc/wpa_supplicant.conf     your SSID + wpa_passphrase output (2.4 GHz)
      root/.ssh/authorized_keys   your public key
      etc/hostname                pet-feeder, or whatever you like
      etc/timezone, etc/TZ        your time zone
      etc/feederd.conf            your MQTT broker and topic prefix
    CAMERA=princess_feeder_t31l_sc2336_atbm6012bx make       # first build, builds the toolchain too
    make -C /path/to/repo/feederd THINGINO=$PWD overlay      # build feederd, put it in the overlay
    CAMERA=princess_feeder_t31l_sc2336_atbm6012bx make repack
  5. Write it with flashrom -p ch341a_spi -w, from a real Linux USB port.

  6. First boot: SSH in with your key, set a root password, and flip the image (the camera is mounted upside down in the assembled feeder):

    raptorctl config set image hflip 1
    raptorctl config set image vflip 1
    raptorctl config save
  7. Video: rtsp://thingino:thingino@<feeder-ip>:554/ch0 (main) and /ch1 (sub). Change that password.

  8. Home Assistant: paste home-assistant/pet-feeder.yaml into the mqtt: section of your configuration. You get a Feed number, a Feed 1 Portion button, an LED select, Food Low and Feeding sensors, portion counters and the last feed.

Things that will bite you:

  • Deactivate any Python virtualenv before building Thingino. U-Boot's build wants the system Python's setuptools and fails on pylibfdt otherwise.
  • make does nothing once the image exists. Overlay changes need make repack; a change to the profile's thingino.json needs make cleanbuild.
  • A full OTA wipes the writable overlay. Redo the image flip afterwards. feederd and your overlay files are baked into the image, so those survive.

Final thoughts

The feeder now lives on an isolated camera network with no internet access at all. The video stays on my own network, ready for my NVR, Home Assistant feeds the cat with a button or an automation, and there's a "food low" sensor that Home Assistant never had while the feeder was on Tuya.

The parts that made this go quickly weren't clever: a dump before touching anything, a separate MCU instead of motor control on GPIOs, and watching a working implementation (the stock app with strace) instead of reversing it from the binary. The undocumented LED-off value and the voltage on that programmer were the only real surprises.


Sources

Comments