Hombli Petmo C1 (PMLB-0109) litter box — fully local in HA, plus the complete DP map

I bought a new cat litter box at Action for €129,- yesterday.

I spent an afternoon with my mate Claude Code getting a Hombli Petmo C1 automatic litter box (model PMLB-0109) working fully locally in Home Assistant. It isn’t supported by any existing integration and several of its data points are mislabelled or dead, so I’m writing down everything I found in case it saves someone else the same afternoon.

Short version: it works completely locally, but not with the LocalTuya most people have installed, and about a third of the data points don’t do what their names suggest.


1. It speaks Tuya protocol 3.5

This is the first wall you’ll hit. The widely installed rospogrigio/localtuya only supports up to protocol 3.4 and fails with empty_dps, which looks like a wrong local key but isn’t.

Use xZetsubou/hass-localtuya instead — the maintained fork, same localtuya domain, supports 3.5. It migrates the existing config entry: I had another Tuya device in the same entry and it came through untouched. Take a backup first anyway.

Determine the protocol version with tinytuya rather than guessing in the config flow:

import tinytuya
d = tinytuya.Device(DEVICE_ID, IP, LOCAL_KEY, version=3.5)
print(d.status())

Wrong version gives a distinctive error: Check device key or version for 3.4, Unexpected Payload for 3.1–3.3. Five seconds of testing beats twenty minutes of config-flow roulette.

:warning: A Tuya device accepts only one local connection at a time. Stop your diagnostic script before letting LocalTuya take over, or they’ll fight over the socket.

2. Auto-configure only works if you set up the Cloud API first

In the fork’s options flow, auto_configure_device silently bounces back to the menu if you haven’t configured Cloud API credentials. Without them it only sees bare DP numbers, not their codes, so it can’t map platforms.

Configure Cloud API (region, client id, client secret, user id) first, then auto-configure works in one go — and as a bonus it fills in proper model names for your other Tuya devices.

Note that auto-configure only creates the standard entities for the category (5, in my case). Everything else you add by hand via Edit device → Add entity.

3. The complete DP map

Pulled from the Tuya Cloud API and then verified one by one against the physical device.

:light_bulb: Tip: /v2.0/cloud/thing/{device_id}/shadow/properties returns dp_id + code + value for all data points in one call. That’s far more useful than /v1.0/iot-03/devices/{device_id}/specification, which only returns the category’s standard instruction set. And it doesn’t touch the local connection.

DP Code Type What it actually is
6 cat_weight int Cat weight in grams (spec says scale 3)
7 excretion_times_day int DEAD — always 0, never counts
8 excretion_time_day int Duration per visit in seconds (not per day, despite the name)
17 deodorization bool DEAD — never reports, not the ioniser
22 fault bitmap bit0 motor, bit1 program, bit2 g-sensor
101 Clean bool Start cleaning and a usable status: true while running, device clears it when done
102 empty bool Empty out — dumps everything including litter
103 trash_status bool DEAD — never reports, despite a FULL lamp on the panel
104 monitoring bool Cat present — only works if DP 111 is false
105 Induction_Clean bool Auto-clean setting. Dips to false during a cycle, restores itself
111 Clean_notice bool :warning: Actually the IR sensor setting, inverted. See below
114 child_lock bool Write-only — see below
117 induction_delay int Clumping time, minutes
118 induction_interval int Cleaning interval, minutes
120 odourless bool The ioniser (not DP 17). Auto-expires after a while
121 smart_clean bool Unused here
123 usage_times int Stuck at 1, does not increment on a visit
124 capacity_calibration int Calibration value
125 sand_surface_calibration int Calibration value
126 auto_deordrizer bool Auto-ioniser: runs DP 120 after every clean cycle
127 detection_sensitivity int Grams
128 number int Unknown, never moves
129 ibs int Weight in lbs × 100 — handy cross-check on DP 6
130 deport_mode bool Unused here
131 weight_correction int Load-cell calibration factor — not a fill level
132 reset bool :warning: Command. Do not add as a switch
133 restart bool :warning: Command. Do not add as a switch

4. The traps, in order of how much time they cost me

DP 111 is the IR sensor setting, with inverted logic. The cloud calls it Clean_notice, which is meaningless. In the app it’s “Disable the infrared sensor”. true = IR sensors off = the box does not stop when it detects movement during a cycle. Mine shipped with this enabled. With two kittens that’s worth checking today.

It also silently breaks your statistics: with IR off, DP 104 never reports, so you have no cat-presence signal at all.

DP 7 doesn’t count, so you have to count visits yourself. Detect them on DP 104 going on → off. Don’t rely on DP 6 or DP 8 changing value: if two cats weigh the same and take the same time, nothing changes and you miss the visit entirely. I learned this the hard way — three visits, one counted.

DP 6 and DP 8 do not arrive at the same moment. I measured 115 ms between them. If your automation reads both at trigger time, you’ll record the previous visit’s duration. Add a short delay and read the values afterwards — and in HA that means a mid-sequence variables: step, because a top-level variables: block renders when the run starts, not after your delay.

The weight scaling lives in the LocalTuya entity, so fix it there. The device sends grams (1800). With the entity’s correction factor at 0.1 you get 180 labelled “kg”. Set the factor to 1 and the unit to grams. Note that this factor also determines the raw_state attribute, so don’t try to compensate for it in a template sensor — fix it at the source.

DP 114 (child lock) is write-only. It never reports its state, not on push and not even when explicitly requested with localtuya.update_dps. Worse: switch.turn_on worked but switch.turn_off did nothing, twice — I suspect because the entity has no known baseline state. localtuya.set_dp works in both directions. I ended up driving it from an input_boolean with an automation calling set_dp, plus a homeassistant.start trigger to re-sync after a restart. Optimistic state, but this box’s app is the only other way to change it.

DP 101 looks stuck if you measure too early. It stays true for the whole cycle, which is several minutes. I nearly concluded it was a latching command; it clears itself the moment the cycle ends and is genuinely useful as a “cleaning in progress” indicator.

DP 105 dips during a cycle. Twice I thought something had switched my auto-clean off. It goes false while cleaning and restores itself afterwards.

5. What I built on top

Because DP 7 and DP 103 are dead, HA does the bookkeeping:

  • Visit counting from DP 104, with DP 6/DP 8 changes as a fallback and a 60-second dedup window (several triggers fire for one visit)
  • Waste drawer level estimated from visits since the last emptying, with a button to press when you actually empty it. It records how many visits that cycle lasted and keeps the longest cycle seen — that’s a better capacity estimate than the last one, because you sometimes empty early and that tells you nothing
  • Per-cat attribution by weight, with a deliberate dead zone: if a visit falls within a margin of the threshold it’s recorded as “unassigned” rather than guessed wrong
  • Notifications with two independent layers: per person and per alert type

6. Things I still don’t know

  • What usage_times (123) and number (128) are for
  • Whether DP 103 ever fires — the panel has a FULL lamp, so the hardware detection exists; it just isn’t pushed locally
  • Whether the app’s schedules (DP 106/107/110/122) can be driven locally — those DPs exist in the cloud but my unit never pushes them

Happy to answer questions. If you have the same box or a rebadged sibling (these OEM chassis show up under a lot of brand names), I’d be curious whether your DP 7 and DP 103 are as dead as mine.

How did you get the litter box from the Hombli app connected in the Tuya Dev platform?
When i try to scan the QR code it tells me to use the designated app … then i get stuck …

I didn’t install the Hombli app. I paired it with the Tuya App directly. Using this app: ‎Tuya: Smart Life, Smart Living App - App Store

Hey thanks for the great post and guide.
I don’t have a smart cat litter box yet and i am actually thinking of buying it, so i was wondering if you had any issues with it untill now? Anything like urin leaking, cleaning problems or litter spilling would be nice to know.

Once again thanks for your work :slight_smile:

Moved mine to the Tuya app.
Because i have a weather station manualy configured in LocalTuya i tried another approach. (Hinted by Claude :grin:)

It seems i have a different Hombli model … Hombli HBLB-0208.
Does yours look like this?


It has the following data points …
“1”: false,
“4”: true,
“5”: 120,
“6”: 0,
“9”: false,
“16”: false,
“22”: 0,
“23”: false,
“24”: “standby”,
“101”: false,
“102”: false,
“103”: false,
“108”: false,
“109”: 14,
“110”: true

I have installed Tuyo Local (3.5 support)
I could choose a Device (zedar_k1200_litterbox) that seems to match my Hombli litterbox …

The only weirdt thing is that DP6 never changes, it stays 0 …

I have it like about 6 months and i can not imagine to go without it anymore.
I only have a little spill from his paws, same as a regular box :woman_shrugging:

Thanks for sharing your findings @bogus!

One question, which I couldn’t find an answer to from reading your post.
Did you make an integration yourself to connect the litter box to HA?

I ask because I use the regular online/hosted Tuya integration instead of local Tuya, so if I can just pair it through the regular integration I’d be fine with that. Otherwise I’d like to try to use what you’ve built.

(PS: Gotta love Action for stocking Tuya-compatible devices :smile:)

Yes, if you move the box from the Hombli app to the Tuya app, it will appear in HA. I could, for instance, control the light from there … But i think i have a different model though …

Hi @xBigGx,

I ran into the exact same issue with my litter box using the zedar_k1200_litterbox profile where DP 6 stayed permanently locked at 0.

With the help of an AI assistant, I dug into the raw data packets from the Tuya Developer IoT Platform logs, reversed the binary payload, and unmasked why this happens. Here is the technical breakdown and a fully working local fix.

The Problem

The manufacturer does not write numeric weight values directly to the standard Tuya schema data point (cat_weight / DP 6). Instead, they use a hidden, custom telemetry property called work_record (DP 104), which transmits a dynamic, Base64-encoded text string payload (e.g., GgoBFQYmAw88).

The standard integration profile completely misses DP 104, and because it is an encoded text string rather than a clean number, Home Assistant ignores it. Depending on the event (Cat entered vs. Cat left vs. Cleaning), the length of the string bytes shifts. An Event Type 3 packet signifies an active mass measurement step, where the final two bytes hold the actual weight integer in high/low byte format.

With the help of AI, we managed to write a custom Jinja template that decodes this Base64 stream using standard arithmetic (avoiding the bitwise operators that the native HA Jinja engine doesn’t support by default).


Step 1: Add the Raw Sensor to your YAML

To make this text payload appear locally in Home Assistant, you need to append DP 104 to your local tuya-local configuration file.

Open your config/custom_components/tuya_local/devices/zedar_k1200_litterbox.yaml file and add this block at the very bottom of your entities:

  - entity: sensor
    name: Work Record Raw
    category: diagnostic
    dps:
      - id: 104
        type: string
        name: sensor

Save the file and Reload the Tuya Local integration under Settings > Devices & Services. This will expose a new entity called sensor.work_record_raw.


Step 2: Create a Template Helper to Decode the Weight

Go to Settings > Devices & Services > Helpers > + Create Helper > Template > Template a sensor. Name it something like Cat Weight, set the unit of measurement to g, the device class to Weight, and paste the following code into the State template field:

{% set raw_string = states('sensor.work_record_raw') %}
{% set current_sensor_value = states('sensor.cat_weight') %}

{# Define the fallback value to remember the last recorded weight #}
{% set fallback = current_sensor_value if current_sensor_value not in ['unknown', 'unavailable', 'None', '0'] else 3790 %}

{% if raw_string and raw_string not in ['unknown', 'unavailable', 'None'] and raw_string|length >= 8 %}
  {# Safe Base64 character matrix #}
  {% set b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' %}
  
  {# Clean the string using regex to strip spaces, equals padding, or weird artifacts #}
  {% set clean_string = raw_string | regex_findall('[A-Za-z0-9+/]') | join('') %}
  {% set len = clean_string|length %}
  
  {% if len >= 4 %}
    {# Convert base64 text stream to a raw byte array via standard arithmetic #}
    {% set ns = namespace(bytes=[], bit_buf=0, bit_cnt=0) %}
    {% for i in range(len) %}
      {% set char = clean_string[i] %}
      {% if char in b64 %}
        {% set val = b64.index(char) %}
        {% set ns.bit_buf = (ns.bit_buf * 64) + val %}
        {% set ns.bit_cnt = ns.bit_cnt + 6 %}
        
        {# Whenever we accumulate 8 bits, pull out a full byte #}
        {% if ns.bit_cnt >= 8 %}
          {% set ns.bit_cnt = ns.bit_cnt - 8 %}
          {% set divisor = 2 ** ns.bit_cnt %}
          {% set byte = (ns.bit_buf / divisor) | int % 256 %}
          {% set ns.bytes = ns.bytes + [byte] %}
        {% endif %}
      {% endif %}
    {% endfor %}
    
    {% set bytes = ns.bytes %}
    
    {# Process packet if it meets the minimum 9-byte sequence requirements #}
    {% if bytes|length >= 9 %}
      {# Extract final parameters by reading backwards from the tail end #}
      {% set low_byte = bytes|last %}
      {% set high_byte = bytes|reverse|slice(2)|first|last %}
      {% set event_type = bytes|reverse|slice(3)|first|last %}
      
      {# Verify that Event Type is exactly code 3 (Cat Weight Event) #}
      {% if event_type == 3 %}
        {# Combine the high byte and low byte into a single 16-bit integer #}
        {% set weight = (high_byte * 256) + low_byte %}
        
        {# Validate if the mass represents a logical cat range #}
        {% if weight > 500 and weight < 15000 %}
          {{ weight }}
        {% else %}
          {{ fallback }}
        {% endif %}
      {% else %}
        {{ fallback }}
      {% endif %}
    {% else %}
      {{ fallback }}
    {% endif %}
  {% else %}
    {{ fallback }}
  {% endif %}
{% else %}
  {{ fallback }}
{% endif %}

This decoder filters out the interim cleaning updates and utilizes a sample-and-hold logic, ensuring your weight dashboard graph stays steady at the last known cat visit value instead of dropping back down to zero when the box goes to standby.

Contributing this Upstream

This confirms that multiple rebranded variants (Zedar, Hombli, etc.) share this exact behavior on DP 104. I would love to contribute this fix to the main make-all/tuya-local integration so it works out-of-the-box for everyone automatically.

However, I am completely new to code contributions and GitHub repository workflows and have no clue how to submit a Pull Request or create a device modifier module. If anyone here with a bit more development experience knows how to convert this logic into a native python parser mapping for tuya-local, please feel free to take this data and open an issue/PR on the repository, or if someone can point me in the right direction on how to do this properly, that would be greatly appreciated!

@jeroen-meijer I use localtuya for connecting it.

It looks like this: Hombli Petmo C1 zelfreinigende kattenbak | Action Webshop NL

Nice! :wink: Good to know if mine gets broken.

Thx alot! Got it working to on DP 104 with the help of some AI also!!
Thx for sharing!!

I can now expose this data on my totaly useless website :tada: (If you understand dutch)