[External Converter]: Maledro ZD201001B - double Zigbee touch LED dimmer
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 48/100
- Issue type
- Feature
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- typescript
- Domain
- backend
Research direction
Read the supplied external converter and device database entry to understand the Tuya datapoints for channels l1 and l2. Then check the repository’s existing Tuya device definitions and contribution guidance for the expected file and tests. Done means the Maledro ZD201001B is supported in the built-in converters with the reported two-channel controls and reports; verify behavior against the described installation evidence.
Written by the indexing model from the issue text.
Description
Link
Database entry
{"id":7,"type":"Router","ieeeAddr":"0xa4c1382bbcafcbe5","nwkAddr":17430,"manufId":4417,"manufName":"_TZE204_slqdlbjb","powerSource":"Mains (single phase)","modelId":"TS0601","epList":[1,242],"endpoints":{"1":{"profId":260,"epId":1,"devId":81,"inClusterList":[4,5,61184,0],"outClusterList":[25,10],"clusters":{"genBasic":{"attributes":{"65503":"�\n\u0000\u0000i\u0001\u000b\u0000\u0000i\f\u000b\u0000\u0000i\u0017\u000b\u0000\u0000i"\u000b\u0000\u0000i-\u000b\u0000\u0000i8\u000b\u0000\u0000iC\u000b\u0000\u0000i","65506":56,"65508":0,"65534":0,"modelId":"TS0601","manufacturerName":"_TZE204_slqdlbjb","powerSource":1,"zclVersion":3,"appVersion":74,"stackVersion":0,"hwVersion":1,"dateCode":""}}},"binds":[],"configuredReportings":[],"meta":{}},"242":{"profId":41440,"epId":242,"devId":97,"inClusterList":[],"outClusterList":[33],"clusters":{},"binds":[],"configuredReportings":[],"meta":{}}},"appVersion":74,"stackVersion":0,"hwVersion":1,"dateCode":"","zclVersion":3,"interviewCompleted":true,"interviewState":"SUCCESSFUL","meta":{"configured":"0.0.0","maledroSoftwareLimitsV2":{"l1":{"min":145,"max":254},"l2":{"min":14,"max":246}}},"lastSeen":1794143148489}
Zigbee2MQTT version
2.14.3
External converter
/**
* Maledro ZD201001B - Zigbee2MQTT external converter
* Release 1.0.0 | 2026-11-08
* Fingerprint: TS0601 / _TZE204_slqdlbjb
* Based on the V5 behavior tested in the Maledro installation.
* See the supplied PDF manual for installation, behavior and limitations.
*/
const tuya = require('zigbee-herdsman-converters/lib/tuya');
const exposes = require('zigbee-herdsman-converters/lib/exposes');
const e = exposes.presets;
const ea = exposes.access;
const channels = ['l1', 'l2'];
const {logger} = require('zigbee-herdsman-converters/lib/logger');
const NS = 'zhc:maledro:physical-limit';
const runtime = new WeakMap();
function bounds(options, endpoint) {
const minimum = options[`minimum_percent_${endpoint}`] ?? 0;
const maximum = options[`maximum_percent_${endpoint}`] ?? 100;
for (const value of [minimum, maximum]) {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 100) {
throw new Error(`Grenzen voor ${endpoint}: gebruik 0 t/m 100 procent`);
}
}
if (minimum > maximum) throw new Error(`Minimum is hoger dan maximum voor ${endpoint}`);
return [Math.round(minimum * 254 / 100), Math.round(maximum * 254 / 100)];
}
// Correct physical brightness reports above the software maximum.
// DP5/11 are not written: hardware maximum support has not been verified.
function channelRuntime(device, endpoint) {
if (!runtime.has(device)) runtime.set(device, {});
const deviceState = runtime.get(device);
if (!deviceState[endpoint]) {
deviceState[endpoint] = {on: undefined, raw: undefined, timer: undefined,
sending: false, pending: false, lastAttempt: 0, options: {}};
}
return deviceState[endpoint];
}
function cancelCorrection(state) {
if (state.timer !== undefined) clearTimeout(state.timer);
state.timer = undefined;
state.pending = false;
}
function correctionTarget(state, endpoint) {
const maximumPercent = state.options[`maximum_percent_${endpoint}`] ?? 100;
if (maximumPercent >= 100 || state.on === false || !Number.isFinite(state.raw)) return undefined;
const [, maximum] = bounds(state.options, endpoint);
const rawMaximum = Math.round(maximum * 1000 / 254);
return state.raw > rawMaximum ? rawMaximum : undefined;
}
function queueCorrection(device, endpoint, state) {
if (correctionTarget(state, endpoint) === undefined) {
cancelCorrection(state);
return;
}
if (state.timer !== undefined) return;
// Coalesce rapid touch reports, but do not postpone forever while holding.
const delay = Math.max(100, 600 - (Date.now() - state.lastAttempt));
state.timer = setTimeout(async () => {
state.timer = undefined;
if (state.sending) { state.pending = true; return; }
let target;
try { target = correctionTarget(state, endpoint); }
catch (error) { logger.error('Invalid physical maximum: ' + error.message, NS); return; }
if (target === undefined) return;
state.sending = true;
state.lastAttempt = Date.now();
const dp = endpoint === 'l1' ? 2 : 8;
try {
logger.info(`Correcting physical maximum ${endpoint}: raw ${state.raw} -> ${target}`, NS);
// Use the standard Tuya dataRequest command and transaction sequence.
const command = device.meta?.tuyaSendCommand || 'dataRequest';
await tuya.sendDataPointValue(device.getEndpoint(1), dp, target, command, 1);
} catch (error) {
logger.error(`Physical maximum correction ${endpoint} failed: ${error.message}`, NS);
} finally {
state.sending = false;
if (state.pending) {
state.pending = false;
try { queueCorrection(device, endpoint, state); }
catch (error) { cancelCorrection(state); logger.error('Invalid physical maximum: ' + error.message, NS); }
}
}
}, delay);
}
const physicalMaximum = {
...tuya.fz.datapoints,
convert: (model, msg, publish, options, meta) => {
// Preserve the standard, observed state. Do not pretend a correction
// has succeeded: the dimmer's next report confirms its actual level.
const result = tuya.fz.datapoints.convert(model, msg, publish, options, meta);
const datapoints = msg.data?.dpValues || [];
const device = msg.device || meta.device;
if (!device) return result;
for (const endpoint of channels) {
const state = channelRuntime(device, endpoint);
state.options = options || {};
const stateDp = endpoint === 'l1' ? 1 : 7;
const brightnessDp = endpoint === 'l1' ? 2 : 8;
const switchReport = datapoints.find((dp) => dp.dp === stateDp && dp.datatype === 1);
const dimReport = datapoints.find((dp) => dp.dp === brightnessDp && dp.datatype === 2);
if (state.on === undefined) {
const cached = meta.state?.[`state_${endpoint}`];
if (cached === 'OFF' || cached === 'ON') state.on = cached === 'ON';
}
if (switchReport) state.on = Boolean(switchReport.data[0]);
if (dimReport && dimReport.data.length === 4) {
state.raw = Buffer.from(dimReport.data).readUInt32BE(0);
}
// ON can follow a brightness report: evaluate then as well.
if (switchReport || dimReport) {
try { queueCorrection(device, endpoint, state); }
catch (error) { cancelCorrection(state); logger.error(`Invalid physical limit ${endpoint}: ${error.message}`, NS); }
}
}
return result;
},
};
const control = {
// Like the standard Tuya datapoints converter, handle the whole message once.
convertSet: async (entity, key, value, meta) => {
const message = {...meta.message};
// Cancel queued touch corrections before a requested OFF is transmitted.
for (const endpoint of channels) {
const requested = message[`state_${endpoint}`] ??
(meta.endpoint_name === endpoint ? message.state : undefined);
if (requested === undefined || !meta.device) continue;
const state = channelRuntime(meta.device, endpoint);
state.options = meta.options || {};
if (String(requested).toUpperCase() === 'OFF') {
state.on = false;
cancelCorrection(state);
} else if (String(requested).toUpperCase() === 'ON') {
state.on = true;
}
}
for (const [property, requested] of Object.entries(message)) {
const match = /^brightness(?:_(l[12]))?$/.exec(property);
if (!match) continue;
const endpoint = match[1] || meta.endpoint_name;
if (!channels.includes(endpoint)) {
throw new Error('Selecteer kanaal l1 of l2 voor de dimopdracht');
}
if (typeof requested !== 'number' || !Number.isFinite(requested)) {
throw new Error('Brightness moet een getal zijn');
}
const [minimum, maximum] = bounds(meta.options || {}, endpoint);
const state = message[`state_${endpoint}`] ??
(meta.endpoint_name === endpoint ? message.state : undefined);
const off = String(state || '').toUpperCase() === 'OFF';
// Preserve an explicit OFF command, including brightness 0 from HA.
message[property] = off ? 0 : Math.max(minimum, Math.min(maximum, Math.round(requested)));
}
// IMPORTANT: keep endpoint_name and the local property names unchanged.
// Zigbee2MQTT adds the endpoint suffix to optimistic results itself.
return tuya.tz.datapoints.convertSet(entity, key, value, {...meta, message});
},
};
module.exports = {
fingerprint: [{modelID: 'TS0601', manufacturerName: '_TZE204_slqdlbjb'}],
model: 'ZD201001B',
vendor: 'Maledro',
description: 'Zigbee touch LED dimmer dubbel',
fromZigbee: [physicalMaximum],
toZigbee: [control],
onEvent: tuya.onEventSetTime,
configure: tuya.configureMagicPacket,
exposes: channels.map((endpoint) => tuya.exposes.lightBrightness().withEndpoint(endpoint)),
options: channels.flatMap((endpoint) => [
e.numeric(`minimum_percent_${endpoint}`, ea.SET)
.withLabel(`Minimum kanaal ${endpoint === 'l1' ? 1 : 2}`)
.withValueMin(0).withValueMax(100).withValueStep(1).withUnit('%')
.withDescription(`Minimum voor opdrachten via HA/Zigbee2MQTT. Standaard 0%. Geen gegarandeerde fysieke ondergrens. Bestaande opgeslagen instellingen blijven geldig.`),
e.numeric(`maximum_percent_${endpoint}`, ea.SET)
.withLabel(`Maximum kanaal ${endpoint === 'l1' ? 1 : 2}`)
.withValueMin(0).withValueMax(100).withValueStep(1).withUnit('%')
.withDescription(`Maximum voor opdrachten via HA/Zigbee2MQTT. Standaard 100%. Fysieke overschrijding wordt na een statusmelding teruggezet; tijdens vasthouden kan de lamp hoger staan. Vereist een werkende Zigbee2MQTT-verbinding.`),
]),
meta: {
multiEndpoint: true,
tuyaDatapoints: [
[1, 'state_l1', tuya.valueConverter.onOff, {skip: tuya.skip.stateOnAndBrightnessPresent}],
[2, 'brightness_l1', tuya.valueConverter.scale0_254to0_1000],
[7, 'state_l2', tuya.valueConverter.onOff, {skip: tuya.skip.stateOnAndBrightnessPresent}],
[8, 'brightness_l2', tuya.valueConverter.scale0_254to0_1000],
],
},
endpoint: () => ({l1: 1, l2: 1}),
};
What does/doesn't work with the external definition?
We would like to contribute support for the Maledro ZD201001B double Zigbee touch LED dimmer to zigbee-herdsman-converters, so users can use it without installing an external converter.
Device identification:
- Vendor: Maledro
- Model: ZD201001B
- Desired customer-facing description:
Zigbee touch LED dimmer dubbel - Zigbee modelID:
TS0601 - Zigbee manufacturerName:
_TZE204_slqdlbjb - Two logical channels (
l1/l2) on physical endpoint 1, using the Tuya manufacturer-specific cluster 0xEF00.
Reported working in one Home Assistant / Zigbee2MQTT installation:
- Independent ON/OFF control of both channels.
- Brightness control and brightness/state reports for both channels.
- Per-channel software minimum and maximum settings for commands from HA/Zigbee2MQTT, with defaults of 0% and 100%.
- Correction of reported brightness above the software maximum after physical touch dimming.
Important limitations:
- The physical maximum is NOT a hard firmware limit. While holding a touch button, the lamp can exceed the configured maximum. In the tested installation it returns to the maximum after the button is released and the device reports its level.
- This correction requires Zigbee2MQTT and a working connection. It is not a safety limit.
- The converter does not program the internal physical minimum. Existing device minimum settings are retained, but physical minimum behavior on a factory-new unit is not verified.
- DP5 and DP11 have not been verified as internal maximum-setting datapoints and are deliberately not written by this converter.
- This is tested on one installation, not a broad hardware/firmware compatibility certification.
Confirmed brightness/control datapoints:
| Channel | State (bool) | Brightness (value, 0-1000) |
|---|---|---|
| l1 | 1 | 2 |
| l2 | 7 | 8 |
The related Homey driver uses DP3 and DP9 for internal minimum brightness. Those are not exposed/written as hardware settings by the submitted external converter.
Notes
Test environment: Zigbee2MQTT 2.14.3 with SONOFF ZBDongle-E, Zigbee NCP firmware 9.1.1, adapter: ember, 115200 baud and hardware flow control disabled. This describes the test environment, not a requirement to use this exact coordinator.
The database entry below was captured from a Home Assistant backup made on 2026-11-08 before the coordinator firmware migration. It records the same model/manufacturer fingerprint and appVersion 74; the short network address and other transient fields may differ from the current installation. A fresh entry can be supplied if needed.
The external converter passes JavaScript syntax and mocked regression checks covering both channels, endpoint status naming, outgoing limits, physical maximum correction, duplicate-report coalescing, cancellation of a queued correction on OFF, and send-error handling. Zigbee I/O in those checks is mocked. The repository build/lint/test suite has NOT been run because this is an external-converter submission rather than a repository-ready TypeScript pull request.
The existing Tuya TS0601_dimmer_2 definition has a similar basic datapoint layout, but exposes additional features that have not all been verified on this Maledro variant. We would appreciate guidance on whether to add a separate Maledro definition, extend an existing definition with the fingerprint/white-label, or separate basic device support from the optional software-limit behavior. We understand an upstream PR should follow the current TypeScript/modernExtend conventions.
Please review this converter for inclusion in zigbee-herdsman-converters. The requested visible description is Zigbee touch LED dimmer dubbel; model ZD201001B, vendor Maledro.
No raw network keys, MQTT passwords, or full Home Assistant logs/backups are included with this request.
- Dominant language
- TypeScript
- Stars
- 15.8k
- Forks
- 2.1k
- Avg merge
- 1d 6h
- Merged PRs (30d)
- 38
Getting set up
Starts the project's dev container in your browser, under your own GitHub account.
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from Koenkk/zigbee2mqtt
-
[Feature request]: OTA for generated definitions of devices with an OTA client clusterPossibly taken @jabla claimed this 1 day ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 80/100
Koenkk/zigbee2mqtt#33684 · 1 comment ·
Maintainers usually reply within 1 day
-
new device support
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
Koenkk/zigbee2mqtt#33674 · 1 comment ·
Maintainers usually reply within 1 day
-
[Feature request]: Support espzb adapter (ESP Zigbee SDK v2, ESP32-S31)Possibly taken A pull request linked to this issue is open or already merged. Open
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
Koenkk/zigbee2mqtt#33668 ·
Maintainers usually reply within 1 day
-
problem
Difficulty 1/5 Under an hour Newbie friendliness 76/100
Koenkk/zigbee2mqtt#33645 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 83/100
Koenkk/zigbee2mqtt#33613 · 2 comments ·
Maintainers usually reply within 1 day
All issues in Koenkk/zigbee2mqtt
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
vercel-labs/ui#540 ·
Maintainers usually reply within 1 day
-
accepted ai-dev ai-implementing
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
livesport/homer#218 · 1 comment ·
Maintainers usually reply within 1 day
-
normalizePropName strips `#` characters from Figma property names instead of only the `#id` suffixOpen
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
figma/code-connect#419 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
Negative values skip k/M abbreviation and medium decimal format on line, stacked-area, and tooltipOpen
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
britecharts/britecharts#1255 ·
Maintainers usually reply within 1 day