The Zemismart ZMP1 is a battery-powered Zigbee roller shade motor that pairs reliably and works well in the real world. The ZMP1 is the Zigbee sibling of Zemismart’s MTP1, which I’ve reviewed in its Matter over Thread version. Same hardware concept, different radio. The Zigbee variant is the more widely available option, but unlike the Matter version it ships with almost no support in either Zigbee2MQTT or ZHA.

The device has an ID of Zigbee Model TS0601 and Manufacturer _TZE284_6hrnp30w. I dug into the Tuya datapoints it uses and built full custom support from scratch for both Zigbee2MQTT and ZHA. The ZMP1 is now fully supported in both with my external converter and custom quirk. I’ve also opened PRs on GitHub for both integrations.
It also happens to be one of the best roller shade drivers of this type, here’s where you can get it:
What the Converter and Quirk Expose
Both the Zigbee2MQTT converter and the ZHA quirk were built from the ground up using the device’s Tuya datapoints, verified against the Tuya cloud and confirmed through live testing on the actual device. The result is complete functional parity between both platforms.
- Full cover control: open, close, stop
- Position control and position feedback
- Motor direction (forward/reverse)
- Motor limits: set upper limit, set lower limit, remove, clear
- Nudge up and nudge down buttons for fine adjustments
- Battery level
- Work state sensor (opening/closing)
- Travel time sensor (calibrated full travel time)
- Motor fault sensor
Zigbe2MQTT External Converter

To use the Z2M converter, create a new file inside /zigbee2mqtt/external_converters/ and name it zemismart_zmp1.js. Copy the entire converter into this file, save it and reboot Zigbee2MQTT. There is no need to add anything to Zigbee2MQTT’s configuration.yaml file, it loads all converters in this folder automatically on boot.
Zigbee2MQTT External Converter (click to expand)
const exposes = require('zigbee-herdsman-converters/lib/exposes');
const tuya = require('zigbee-herdsman-converters/lib/tuya');
const e = exposes.presets;
const ea = exposes.access;
const definition = {
fingerprint: tuya.fingerprint('TS0601', ['_TZE284_6hrnp30w']),
model: 'ZMP1',
vendor: 'Zemismart',
description: 'Roller shade driver',
fromZigbee: [tuya.fz.datapoints],
toZigbee: [tuya.tz.datapoints],
onEvent: tuya.onEventSetTime,
configure: async (device, coordinatorEndpoint) => {
await tuya.configureMagicPacket(device, coordinatorEndpoint);
},
exposes: [
// Core cover
e.cover_position().setAccess('position', ea.STATE_SET),
// Manual nudge
e.enum('click_control', ea.SET, ['up', 'down'])
.withDescription('Nudge the shade up or down by one step'),
// Motor direction
e.enum('motor_direction', ea.STATE_SET, ['forward', 'back'])
.withDescription('Reverse motor direction if open/close are inverted'),
// Mode
e.enum('mode', ea.STATE_SET, ['morning', 'night'])
.withDescription('Morning or night mode'),
// Live motion state (read only, event-driven)
e.enum('work_state', ea.STATE, ['opening', 'closing'])
.withDescription('Current motor motion state'),
// Calibrated full travel time (read only, set during limit calibration)
e.numeric('time_total', ea.STATE)
.withUnit('ms')
.withValueMin(0)
.withValueMax(120000)
.withDescription(
'Total calibrated travel time in milliseconds. ' +
'Measured by the motor when upper and lower limits are set.'
),
// Limit hit event (read only, fires when shade reaches a limit)
e.enum('situation_set', ea.STATE, ['fully_open', 'fully_close'])
.withDescription('Reports when the shade physically reaches a set limit'),
// Motor fault (read only, fires on fault condition)
e.binary('motor_fault', ea.STATE, true, false)
.withDescription('Motor fault detected'),
// Battery
e.battery(),
// Set travel limits
e.enum('set_limit', ea.SET, ['Set upper limit', 'Set lower limit'])
.withDescription(
'Save the current motor position as a travel limit. ' +
'Move the motor to the desired position first, then select and apply.'
),
// Remove travel limits
e.enum('remove_limit', ea.SET, ['Remove upper limit', 'Remove lower limit', 'Clear both limits'])
.withDescription(
'Remove one or both travel limits. ' +
'"Clear both limits" resets the motor fully — use before recalibrating from scratch.'
),
],
meta: {
tuyaDatapoints: [
// DP1 — primary open/stop/close command
[1, 'state', tuya.valueConverterBasic.lookup({
OPEN: tuya.enum(0),
STOP: tuya.enum(1),
CLOSE: tuya.enum(2),
})],
// DP2 — position setpoint (write only)
[2, 'position', {
from: null,
to: (value) => value,
}],
// DP3 — actual position state (read only)
[3, 'position', {
from: (value) => value,
to: null,
}],
// DP4 — mode
[4, 'mode', tuya.valueConverterBasic.lookup({
morning: tuya.enum(0),
night: tuya.enum(1),
})],
// DP5 — motor direction
[5, 'motor_direction', tuya.valueConverterBasic.lookup({
forward: tuya.enum(0),
back: tuya.enum(1),
})],
// DP7 — work state
[7, 'work_state', tuya.valueConverterBasic.lookup({
opening: tuya.enum(0),
closing: tuya.enum(1),
})],
// DP10 — total calibrated travel time
[10, 'time_total', tuya.valueConverter.raw],
// DP11 — situation set (limit reached event)
[11, 'situation_set', tuya.valueConverterBasic.lookup({
fully_open: tuya.enum(0),
fully_close: tuya.enum(1),
})],
// DP12 — fault bitmap
[12, 'motor_fault', {
from: (value) => value !== 0,
to: null,
}],
// DP13 — battery percentage
[13, 'battery', tuya.valueConverter.raw],
// DP16 — travel limit calibration (write only, from: null suppresses echo-back parse errors)
[16, 'set_limit', {
from: null,
to: (value) => {
const map = {
'Set upper limit': tuya.enum(0),
'Set lower limit': tuya.enum(1),
};
return map[value];
},
}],
[16, 'remove_limit', {
from: null,
to: (value) => {
const map = {
'Remove upper limit': tuya.enum(2),
'Remove lower limit': tuya.enum(3),
'Clear both limits': tuya.enum(4),
};
return map[value];
},
}],
// DP19 — position_best exists on device but has no recall DP,
// so it is intentionally not exposed to avoid a useless slider.
// DP20 — click control (nudge)
[20, 'click_control', tuya.valueConverterBasic.lookup({
up: tuya.enum(0),
down: tuya.enum(1),
})],
],
},
};
module.exports = definition;ZHA Custom Quirk

To use the ZHA custom quirk, create a new file inside /config/custom_zha_quirks/ and name it zemismart_zmp1.py. Copy the entire quirk into this file, add the following to your Home Assistant configuration.yaml, and restart Home Assistant:
zha:
custom_quirks_path: /config/custom_zha_quirksZHA Custom Quirk (click to expand)
"""Zemismart ZMP1 Roller Shade Driver - ZHA Custom Quirk.
TS0601 / _TZE284_6hrnp30w
Pure v2. Place in /config/custom_zha_quirks/zemismart_zmp1.py
"""
from __future__ import annotations
import zigpy.types as t
from zigpy.quirks.v2 import CustomDeviceV2, EntityType, EntityPlatform
from zigpy.zcl import foundation
from zhaquirks.tuya.mcu import TuyaMCUCluster, TuyaWindowCovering
from zhaquirks.tuya.builder import TuyaQuirkBuilder
class ZMP1ManufCluster(TuyaMCUCluster):
"""TuyaMCUCluster subclass — no dp_to_attribute here.
The builder populates _dp_to_attributes via tuya_cover()/tuya_enum().
Limit and nudge methods send raw Tuya commands directly.
"""
def _send_dp_enum(self, dp_id: int, value: int) -> None:
from zhaquirks.tuya import TUYA_SET_DATA, TuyaManufCluster
cmd_payload = TuyaManufCluster.Command()
cmd_payload.status = 0
cmd_payload.tsn = self.endpoint.device.application.get_sequence()
cmd_payload.command_id = 0x0400 | dp_id
cmd_payload.function = 0
cmd_payload.data = t.List[t.uint8_t]([1, value])
self.create_catching_task(
self.command(TUYA_SET_DATA, cmd_payload, expect_reply=False)
)
def set_upper_limit(self) -> None: self._send_dp_enum(0x10, 0x00)
def set_lower_limit(self) -> None: self._send_dp_enum(0x10, 0x01)
def remove_upper_limit(self) -> None: self._send_dp_enum(0x10, 0x02)
def remove_lower_limit(self) -> None: self._send_dp_enum(0x10, 0x03)
def clear_limits(self) -> None: self._send_dp_enum(0x10, 0x04)
def click_up(self) -> None: self._send_dp_enum(0x14, 0x00)
def click_down(self) -> None: self._send_dp_enum(0x14, 0x01)
class ZMP1DeviceV2(CustomDeviceV2):
"""CustomDeviceV2 — TuyaMCUCluster.__init__ creates command_bus automatically."""
pass
class MotorDirectionEnum(t.enum8):
Forward = 0x00
Back = 0x01
class WorkStateEnum(t.enum8):
Opening = 0x00
Closing = 0x01
class MotorLimitsEnum(t.enum8):
Set_upper_limit = 0x00
Set_lower_limit = 0x01
Remove_upper_limit = 0x02
Remove_lower_limit = 0x03
Clear_both_limits = 0x04
class ClickControlEnum(t.enum8):
Up = 0x00
Down = 0x01
(
TuyaQuirkBuilder("_TZE284_6hrnp30w", "TS0601")
.device_class(ZMP1DeviceV2)
# tuya_cover() maps DP1->control, DP3->position state, DP2->position setpoint
# adds TuyaWindowCovering and sets endpoint device_type to WINDOW_COVERING_DEVICE
.tuya_cover(
control_dp=1,
position_state_dp=3,
position_control_dp=2,
invert=False,
)
.tuya_enum(
dp_id=16,
attribute_name="motor_limits",
enum_class=MotorLimitsEnum,
translation_key="motor_limits",
fallback_name="Motor Limits",
entity_type=EntityType.CONFIG,
entity_platform=EntityPlatform.SELECT,
)
.tuya_dp_attribute(
dp_id=20,
attribute_name="click_up",
type=ClickControlEnum,
)
.write_attr_button(
attribute_name="click_up",
attribute_value=0x00,
cluster_id=0xEF00,
translation_key="click_up",
fallback_name="Nudge Up",
entity_type=EntityType.STANDARD,
)
.tuya_attribute(
dp_id=20,
attribute_name="click_down",
type=ClickControlEnum,
)
.write_attr_button(
attribute_name="click_down",
attribute_value=0x01,
cluster_id=0xEF00,
translation_key="click_down",
fallback_name="Nudge Down",
entity_type=EntityType.STANDARD,
)
# DP5 — motor direction
.tuya_enum(
dp_id=5,
attribute_name="motor_direction",
enum_class=MotorDirectionEnum,
translation_key="motor_direction",
fallback_name="Motor Direction",
entity_type=EntityType.CONFIG,
entity_platform=EntityPlatform.SELECT,
)
# DP7 — work state (opening/closing), read-only sensor
.tuya_dp_attribute(
dp_id=7,
attribute_name="work_state",
type=t.CharacterString,
converter=lambda x: WorkStateEnum(x).name,
access=foundation.ZCLAttributeAccess.Read,
)
.sensor(
attribute_name="work_state",
cluster_id=0xEF00,
translation_key="work_state",
fallback_name="Work State",
entity_type=EntityType.DIAGNOSTIC,
)
# DP10 — calibrated travel time in ms, read-only sensor
.tuya_dp_attribute(
dp_id=10,
attribute_name="time_total",
type=t.uint32_t,
access=foundation.ZCLAttributeAccess.Read,
)
.sensor(
attribute_name="time_total",
cluster_id=0xEF00,
translation_key="time_total",
fallback_name="Travel Time",
entity_type=EntityType.DIAGNOSTIC,
)
# DP12 — motor fault bitmap, read-only sensor
.tuya_dp_attribute(
dp_id=12,
attribute_name="motor_fault",
type=t.uint8_t,
access=foundation.ZCLAttributeAccess.Read,
)
.sensor(
attribute_name="motor_fault",
cluster_id=0xEF00,
translation_key="motor_fault",
fallback_name="Motor Fault",
entity_type=EntityType.DIAGNOSTIC,
)
# DP13 — battery percentage
.tuya_battery(dp_id=13)
.skip_configuration()
.add_to_registry(replacement_cluster=ZMP1ManufCluster)
)









