Fix Dropped Zigbee Cover Commands with This Zigbee2MQTT Extension Home Automation

Fix Dropped Zigbee Cover Commands with This Zigbee2MQTT Extension

by JPK.io · February 27, 2026

If you run Zigbee motorized shades or blinds, you’ve hit this: you send a command, the shade doesn’t move, and you have to send it again. Maybe it’s one shade out of twenty. Maybe it’s three. The point is, Zigbee cover commands get lost — and when you’re trying to close 26 shades at sunset, even a 5% failure rate means you’re babysitting your automations.

I got tired of it. So I wrote a Zigbee2MQTT extension that fixes it.

Why Zigbee Cover Commands Get Dropped

Zigbee is a mesh network, and it’s generally reliable — but covers are a special case. Most motorized shades and blinds are mains-powered, so they should be good mesh citizens. The problem is a combination of things:

  • Mesh congestion. When you send commands to a bunch of covers at once (like a “close all shades” automation), you’re flooding the mesh with traffic. Some messages get lost in the noise.
  • Timing issues. Cover motors take a moment to start, and the Zigbee radio might be busy processing while the motor controller is waking up.
  • Weak links in the mesh. If a shade routes through a device that’s barely in range, the command might not make it.
  • Device firmware quirks. Some cover devices just aren’t great at acknowledging commands reliably. Looking at you, budget Tuya motors.

The result: you press “Close” and nothing happens. The shade just sits there. In Home Assistant, the state might even show “closing” because HA sent the command — it just never arrived.

The Fix: Automatic Retry on No Response

The solution is simple: after sending a cover command, wait a couple seconds. If the device hasn’t reported a state change, send the command again. If it did respond, do nothing.

I built this as a Zigbee2MQTT external extension — a JavaScript class that hooks into Z2M’s event system. No Home Assistant automations, no Node-RED flows, no template sensors. It runs inside Zigbee2MQTT itself, which means it’s fast, doesn’t depend on anything else, and automatically covers every shade on the network — add a new one, remove an old one, it just works without touching the extension.

Here’s the full extension:

export default class CoverRetry {
  constructor(zigbee, mqtt, state, publishEntityState, eventBus,
              enableDisableExtension, restartCallback, addExtension,
              settings, logger) {
    this.mqtt = mqtt;
    this.state = state;
    this.eventBus = eventBus;
    this.logger = logger;
    this.retryDelay = 2000;
    this.pendingRetries = new Map();
  }

  start() {
    this.eventBus.onMQTTMessage(this, (data) => {
      const {topic, message} = data;
      if (!topic.endsWith('/set')) return;

      let payload;
      try { payload = JSON.parse(message); } catch { return; }

      const isCoverCmd =
        payload.state === 'OPEN' ||
        payload.state === 'CLOSE' ||
        payload.state === 'STOP' ||
        payload.position !== undefined;
      if (!isCoverCmd) return;

      const deviceTopic = topic.replace('/set', '');
      const key = `${deviceTopic}:${Date.now()}`;

      this.pendingRetries.set(key, {deviceTopic, message});

      setTimeout(() => {
        if (!this.pendingRetries.has(key)) return;
        this.pendingRetries.delete(key);
        this.logger.info(
          `CoverRetry: no state update from ${deviceTopic}, resending`
        );
        this.mqtt.publish(deviceTopic + '/set', message);
      }, this.retryDelay);
    });

    this.eventBus.onStateChange(this, (data) => {
      const entityId = data.entity?.name;
      if (!entityId) return;

      for (const [key, val] of this.pendingRetries) {
        if (val.deviceTopic.endsWith(entityId)) {
          this.logger.info(
            `CoverRetry: ${entityId} responded, skipping retry`
          );
          this.pendingRetries.delete(key);
        }
      }
    });

    this.logger.info('CoverRetry extension loaded');
  }

  stop() {
    this.eventBus.removeListeners(this);
  }
}

How It Works

Let me walk through what this actually does.

1. Intercept Cover Commands

The extension listens for every MQTT message that ends with /set — that’s how Zigbee2MQTT receives commands. It parses the payload and checks if it’s a cover command: OPEN, CLOSE, STOP, or a position value. Everything else gets ignored.

2. Start a Retry Timer

When it sees a cover command, it stores the command in a pendingRetries map with a unique key (device topic + timestamp) and starts a 2-second timer. That’s the retryDelay — you can adjust it if you want, but 2 seconds works well for me across all 26 of my shades.

3. Listen for State Changes

Simultaneously, the extension listens for state changes from Zigbee2MQTT. When a cover device reports back (its motor started, position changed, etc.), the extension checks if there’s a pending retry for that device. If there is, it cancels the retry — the command went through, no action needed.

4. Retry If No Response

If the 2-second timer fires and the pending retry is still in the map (meaning no state change was reported), it resends the original command. One retry. The Zigbee2MQTT log will show CoverRetry: no state update from <device>, resending so you can see exactly when it kicks in.

That’s it. No infinite retry loops, no complex logic. Send, wait, check, retry once if needed.

Installing the Extension

This takes about 30 seconds:

  1. Open the Zigbee2MQTT web UI
  2. Go to Settings (gear icon) → scroll down to External Extensions (under the “Dev Console” section, or directly at the Extensions tab depending on your Z2M version)
  3. Click Add Extension or the + button
  4. Name it something like cover-retry.js
  5. Paste the code above
  6. Click Save

Zigbee2MQTT will load the extension immediately — no restart required. You should see CoverRetry extension loaded in the Z2M logs.

If you’re running Zigbee2MQTT via the Home Assistant add-on, the same UI is available. If you’re running Z2M standalone, the extensions UI is at your Z2M web interface under the same path.

Does It Actually Work?

Yes. I’ve been running this for weeks across 26 shades and it catches dropped commands regularly — especially during “close all” automations at sunset when I’m hitting every shade at once. The retry rate is maybe 5-10% of commands, which lines up with what I’d expect from mesh congestion during bulk operations.

The key insight is that it’s invisible. When it works (which is most of the time), you never notice it. When a command gets dropped, the shade just moves a couple seconds later instead of not at all. No more walking around the house checking which shades didn’t close.

Works With Any Cover Device

This extension doesn’t care what brand or model your covers are. It operates at the MQTT level, so it works with:

  • Roller shades (IKEA FYRTUR/KADRILJ, Zemismart, etc.)
  • Roller shutters
  • Motorized blinds
  • Curtain motors (Aqara, Tuya, etc.)
  • Any Zigbee device that responds to cover commands through Zigbee2MQTT

If it shows up as a cover in Zigbee2MQTT and accepts OPEN/CLOSE/STOP/position commands, this extension handles it.

Why Not Zigbee Groups?

The obvious question: “Why not use Zigbee groups to send one command to all shades at once?” In theory, Zigbee groups are the right answer for bulk operations — one multicast command hits every device simultaneously instead of sequentially, which reduces mesh congestion.

In practice, my Tuya-based shades don’t play well with Zigbee groups. They either ignore group commands entirely, respond inconsistently, or report incorrect state after a group command. This is a known issue with many Tuya cover devices — their Zigbee implementation is just flaky enough that group membership works on paper but fails in the real world.

So I’m stuck sending individual commands to each shade. Which means 26 sequential MQTT messages during a “close all” automation, which means mesh congestion, which means dropped commands. The retry extension solves the symptom of that constraint without requiring me to replace 26 perfectly functional motors with ones that handle groups properly.

If your covers support Zigbee groups reliably — great, use them. You probably won’t need this extension at all. But if you’re running Tuya shades (or any cover that’s unreliable with groups), this is the pragmatic fix.

Tweaking the Retry Delay

The retryDelay is set to 2000ms (2 seconds). That’s a sweet spot — long enough for most devices to report a state change, short enough that you barely notice the retry when it happens.

If your covers are slow to respond (some cheaper motors take a beat to start), bump it to 3000ms. If your mesh is fast and tight, you could drop it to 1500ms. I wouldn’t go below 1000ms — you’ll get false retries on devices that are actually responding, just slowly.

Important: HA Integration vs. MQTT Commands

There’s a catch worth knowing about. This extension listens for MQTT messages on /set topics — that’s how Zigbee2MQTT receives commands when you publish directly to MQTT. But if you’re controlling covers through Home Assistant’s built-in Zigbee2MQTT integration (which most people are), HA sends commands through an internal API path, not MQTT /set topics. The extension never sees those commands.

In practice, this means:

  • Commands sent via MQTT (Node-RED, manual MQTT publish, other MQTT clients): the extension catches and retries these. ✅
  • Commands sent via Home Assistant (automations using cover.close_cover, cover.set_cover_position, dashboard buttons, scripts): the extension does not see these. ❌

If your automations go through Home Assistant — and most people’s do — you’ll want the HA-level retry below as a complement (or replacement).

Alternative: HA Automation Retry

If your cover commands come from Home Assistant automations, you can add retry logic directly in the automation. This works regardless of how HA talks to Zigbee2MQTT.

Here’s the pattern — add this right after your close/position action:

# Close the shades
- action: cover.set_cover_position
  target:
    entity_id: cover.living_room_shades
  data:
    position: 0

# Wait for shades to respond
- delay: "00:00:05"

# Retry any that didn't close
- action: cover.set_cover_position
  target:
    entity_id: >
      {{ expand('cover.living_room_shades')
         | selectattr('attributes.current_position', 'greaterthan', 5)
         | map(attribute='entity_id') | list }}
  data:
    position: 0

This waits 5 seconds after the initial command, then checks which shades in the group still have a position above 5 (i.e., didn’t actually close). Only those get the retry. If everything closed fine, the retry targets an empty list and does nothing.

You can use this same pattern for any cover group — just swap the entity ID. Works for OPEN too (flip the logic to selectattr('attributes.current_position', 'lessthan', 95)).

The Z2M extension and the HA retry approach solve the same problem from different layers. Use whichever fits your setup, or both if you want belt-and-suspenders reliability.


Zigbee cover reliability is one of those things that’s almost good enough out of the box — which makes it more annoying than if it were obviously broken. These fixes close the gap. A small amount of code that saves real frustration, especially if you have more than a handful of motorized shades. Set it up, forget about it, and stop babysitting your blinds.

Where to Buy

Sonoff Zigbee 3.0 Dongle Plus-E on Amazon Buy on Amazon → Home Assistant Connect ZBT-2 (Official Zigbee Coordinator) Buy Now →

As an Amazon Associate, we earn from qualifying purchases. Prices and availability are subject to change.