[External Converter]: TS0601 from _TZE284_gt5al3bl
Open
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 15.7k
- Forks
- 2k
- Avg merge
- 18h 55m
- Merged PRs (30d)
- 35
Description
Resolving issues caused by frequent invalid requests in the light strip controller MCU crash.
by Claude Code.
// External converter for Gledopto GL-SPI-206P (TS0601 / _TZE204_8fffc3kb, _TZE284_gt5al3bl)
// 目的:避免頻繁開關 / 拉亮度色彩滑桿時 Tuya MCU 被 DP 指令洪水打掛。
//
// 症狀 (見 logs/9336c2b0_zigbee2mqtt_2026-08-05T14-10-22.609Z.log):
// 22:09:26~22:09:30 HA 連續下 6 次 on/off,裝置開始補吐積壓的舊 DP 值
// (亮度回報在 12/201/20/18 之間亂跳),22:09:30 送出 genBasic appVersion
// report (MCU 重啟),之後 7 次指令 APS 層全部 status=OK 但裝置零回應 → MCU hang。
//
// 官方定義 (zigbee-herdsman-converters/src/devices/gledopto.ts) 的
// glspi206p_brightness_color + tuyaBase 的 tz.datapoints 有兩個放大問題:
// 1. 一個 HA 指令會被拆成多個 Tuya dataRequest frame:
// tz.datapoints 對 meta.message 逐一 sendDataPointX(),一個 DP 一包;
// glspi206p_brightness_color 另外會先補送 dp1 (state) 與 dp2 (work_mode)。
// 拉一次色彩滑桿 = 最多 3~4 包。
// 2. 它的去重判斷 `if (dp3 !== meta.state?.brightness)` 拿 10..1000 尺度的 dp3
// 去比對 meta.state.brightness (0..254 尺度),幾乎永遠不相等 → 從不去重。
//
// 這份檔案沿用官方定義本體 (exposes / meta.tuyaDatapoints / scene / music 全部不動),
// 只在 toZigbee 最前面插入一個接管 state/brightness/color/color_temp 的轉換器:
// 1. 合併 — 一個 HA 指令只送「一包」dataRequest,把 dp1/dp2/dp3/dp4/dp61 塞在同一個 frame
// 2. 限流 — 同一裝置的 frame 之間強制最小間隔,等待期間同一個 DP 只保留最新值
// (拉滑桿時中間值直接被丟掉,只送最後一個)
// 3. 去重 — 短時間內與上次送出「完全相同」的 DP payload 直接不送
//
// 注意:這只能移除觸發條件,無法修掉 MCU 韌體本身的缺陷。
// 直接快按實體開關 / 用 Tuya App 猛操作一樣可能掛。
//
// 另建議在 z2m configuration.yaml 這顆燈底下加 `debounce: 1`,
// 壓掉裝置補吐舊值造成的 MQTT publish 風暴與 HA 回饋迴圈(這部分刻意不在轉換器裡做,
// 因為過濾掉數值不同的回報會讓 HA 狀態長期失準)。
import {definitions} from 'zigbee-herdsman-converters/devices/gledopto';
import * as tuya from 'zigbee-herdsman-converters/lib/tuya';
import * as libColor from 'zigbee-herdsman-converters/lib/color';
import * as utils from 'zigbee-herdsman-converters/lib/utils';
// ---------------------------------------------------------------- 可調參數
// 兩個 Tuya frame 之間的最小間隔 (ms)。越大越安全、手感越鈍。
// 350ms 對應 log 裡 22 秒 12 次(約 550ms/次)那種操作已經足以合併。
const MIN_GAP_MS = 350;
// 去重視窗 (ms)。此時間內重複的相同 DP 值不再送出;
// 超過就允許重送,避免「第一次沒生效、第二次被吃掉」的死角。
const DEDUP_WINDOW_MS = 3000;
// ---------------------------------------------------------------- DP 定義
// 與官方 meta.tuyaDatapoints 一致
const DP_STATE = 1; // bool
const DP_WORK_MODE = 2; // enum white=0 colour=1 scene=2 music=3
const DP_BRIGHTNESS = 3; // value 10..1000
const DP_COLOR_TEMP = 4; // value 0..1000
const DP_COLOR = 61; // raw 11 bytes
const WORK_MODE_COLOUR = 1;
// 送出時的 dp 順序。MCU 照陣列順序處理,開關要在最前面、色彩在最後。
// 不在這個清單裡的 dp 會被排到最後(indexOf 回傳 -1,但本轉換器只產生上面這幾個)。
const DP_ORDER = [DP_STATE, DP_WORK_MODE, DP_BRIGHTNESS, DP_COLOR_TEMP, DP_COLOR];
// 關燈時要一併作廢的 dp:燈都要關了,還送色彩/亮度只是讓 MCU 多做白工。
const DP_DISCARD_ON_OFF = [DP_WORK_MODE, DP_BRIGHTNESS, DP_COLOR_TEMP, DP_COLOR];
// dpValueFromX() 在 lib/tuya 裡沒有 export,這裡照原始實作重建
const dpBool = (dp, value) => ({dp, datatype: tuya.dataTypes.bool, data: Buffer.from([value ? 1 : 0])});
const dpEnum = (dp, value) => ({dp, datatype: tuya.dataTypes.enum, data: Buffer.from([value])});
const dpRaw = (dp, buffer) => ({dp, datatype: tuya.dataTypes.raw, data: buffer});
const dpNumber = (dp, value) => {
const v = value < 0 ? 0x100000000 + value : value;
return {
dp,
datatype: tuya.dataTypes.number,
data: Buffer.from([(v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff]),
};
};
// ---------------------------------------------------------------- 限流佇列
/** @type {Map<string, {last: number, timer: any, promise: Promise<void>|null, pending: Map<number, object>, sent: Map<number, {sig: string, at: number}>}>} */
const queues = new Map();
const queueKey = (entity) => entity.deviceIeeeAddress ?? `group-${entity.groupID}`;
const getQueue = (key) => {
let q = queues.get(key);
if (!q) {
q = {last: 0, timer: null, promise: null, pending: new Map(), sent: new Map()};
queues.set(key, q);
}
return q;
};
const signature = (dpValue) => `${dpValue.datatype}:${dpValue.data.toString('hex')}`;
// sendDataPoints() 沒有 export,照原始實作直送。seq 用 1,與 tz.datapoints 一致
// (log 裡實際生效的那條路徑就是 seq:1)。
const sendFrame = async (entity, dpValues) =>
await entity.command('manuSpecificTuya', 'dataRequest', {seq: 1, dpValues}, {disableDefaultResponse: true});
/** 把佇列裡還沒送出的指定 dp 丟掉(例如關燈時作廢排隊中的色彩/亮度)。 */
const discardPending = (entity, dps) => {
const q = queues.get(queueKey(entity));
if (!q) return;
for (const dp of dps) q.pending.delete(dp);
};
/**
* 把 dpValues 排入該裝置的佇列。等待期間同一個 dp 只保留最後一次的值。
* 回傳的 Promise 在這一批真正送出(或失敗)後 settle。
*/
const enqueue = (entity, dpValues) => {
const key = queueKey(entity);
const q = getQueue(key);
const now = Date.now();
for (const dpValue of dpValues) {
const prev = q.sent.get(dpValue.dp);
// 去重:短時間內相同 payload 直接跳過
if (prev && prev.sig === signature(dpValue) && now - prev.at < DEDUP_WINDOW_MS) continue;
q.pending.set(dpValue.dp, dpValue); // 同 dp 後蓋前
}
if (q.pending.size === 0) return Promise.resolve();
if (q.timer) return q.promise; // 已有排程,合併進去即可
const wait = Math.max(0, MIN_GAP_MS - (now - q.last));
q.promise = new Promise((resolve, reject) => {
q.timer = setTimeout(async () => {
q.timer = null;
// 依 DP_ORDER 排序,不要用 Map 的插入順序。
// Tuya MCU 是照 dpValues 陣列順序處理的,插入順序會因為某個 dp 被去重跳過
// 而變得不可預期(實測 23:00:06 就送出過 [dp61, dp1] 這種顛倒的組合)。
const batch = [...q.pending.values()].sort((a, b) => DP_ORDER.indexOf(a.dp) - DP_ORDER.indexOf(b.dp));
q.pending.clear();
q.last = Date.now();
try {
await sendFrame(entity, batch);
const at = Date.now();
for (const dpValue of batch) q.sent.set(dpValue.dp, {sig: signature(dpValue), at});
resolve();
} catch (error) {
// 送失敗就讓下一次指令重送(清掉去重記錄)
for (const dpValue of batch) q.sent.delete(dpValue.dp);
reject(error);
}
}, wait);
});
return q.promise;
};
// ---------------------------------------------------------------- 轉換器
const throttledLight = {
key: ['state', 'brightness', 'color', 'color_temp'],
convertSet: async (entity, key, value, meta) => {
// 這個轉換器一則訊息只會被叫一次,所以要自己看完整個 meta.message
const message = meta.message;
const dpValues = [];
const state = {};
const wantsColour = 'brightness' in message || 'color' in message;
const turningOff = 'state' in message && String(message.state).toUpperCase() === 'OFF';
// dp1 — 開關。放在 frame 最前面,MCU 依 dpValues 順序處理。
if ('state' in message) {
const on = tuya.valueConverter.onOff.to(String(message.state).toUpperCase());
dpValues.push(dpBool(DP_STATE, on));
state.state = on ? 'ON' : 'OFF';
} else if (wantsColour && meta.state?.state !== 'ON') {
// 沿用官方行為:調亮度/色彩時若目前是關的,順便開燈
dpValues.push(dpBool(DP_STATE, true));
state.state = 'ON';
}
// 關燈就只送 dp1,不要再夾帶其他 DP。
// 也要把佇列裡還沒送出的色彩/亮度作廢 —— 使用者按了關燈,那些就是不要了。
// (實測 23:00:06 送出過 [dp61 色彩, dp1=關] 這種同一包,色彩是純白工)
if (turningOff) {
discardPending(entity, DP_DISCARD_ON_OFF);
await enqueue(entity, dpValues);
return {state};
}
// dp2 — work_mode。只有在真的要動亮度/色彩且目前不在 colour 模式時才切。
if (wantsColour && meta.state?.work_mode !== 'colour') {
dpValues.push(dpEnum(DP_WORK_MODE, WORK_MODE_COLOUR));
state.work_mode = 'colour';
}
// dp3 — 亮度。官方是 0..254 → 10..1000 (log: brightness 10 → dp3 49,吻合)。
if ('brightness' in message) {
const mapped = Math.round(utils.mapNumberRange(utils.toNumber(message.brightness, 'brightness'), 0, 254, 10, 1000));
dpValues.push(dpNumber(DP_BRIGHTNESS, Math.max(10, Math.min(1000, mapped))));
state.brightness = message.brightness;
}
// dp4 — 色溫 (0=暖 1000=冷,raw passthrough,與官方 datapoint 一致)
if ('color_temp' in message) {
const ct = Math.max(0, Math.min(1000, Math.round(utils.toNumber(message.color_temp, 'color_temp'))));
dpValues.push(dpNumber(DP_COLOR_TEMP, ct));
state.color_temp = ct;
}
// dp61 — 色彩。payload 格式完全照官方 glspi206p_brightness_color。
if ('color' in message) {
const colorData = message.color;
const c = libColor.Color.fromConverterArg(colorData);
// 官方寫死 `c.isRGB() ? c.rgb.toHSV() : c.hsv`,傳 xy 進來時 c.hsv 是 null 會炸;
// 這裡多補一條 xy 的路。
const hsv = c.isRGB() ? c.rgb.toHSV() : c.isXY() ? c.xy.toHSV() : c.hsv;
const h = Math.max(0, Math.min(360, Math.round(hsv.hue)));
const sat1000 = Math.max(0, Math.min(1000, Math.round((hsv.saturation / 100) * 1000)));
const val1000 = 1000; // 亮度走 dp3,這裡固定滿值
dpValues.push(
dpRaw(
DP_COLOR,
Buffer.from([
0x00,
0x01,
0x01,
0x14,
0x00,
(h >> 8) & 0xff,
h & 0xff,
(sat1000 >> 8) & 0xff,
sat1000 & 0xff,
(val1000 >> 8) & 0xff,
val1000 & 0xff,
]),
),
);
state.color = colorData;
}
if (dpValues.length === 0) return {state};
await enqueue(entity, dpValues);
return {state};
},
};
// ---------------------------------------------------------------- 定義
const original = definitions.find((d) => d.model === 'GL-SPI-206P');
if (!original) {
throw new Error("gl-spi-206p-stable.mjs: 找不到官方 'GL-SPI-206P' 定義,zigbee-herdsman-converters 版本可能已變更");
}
export default {
...original,
description: `${original.description} (throttled)`,
// 官方是 tuyaBase({dp: true}),timeStart 預設 "off" (lib/tuya.ts:4386,4421)
// → z2m 從來不回答裝置的 commandMcuSyncTime,只回一個 defaultRsp。
// 這顆燈每 ~30 秒就問一次時間(所有 log 皆然),而它有 countdown (dp7) 需要時鐘。
// Tuya MCU 討不到時間而卡死是已知模式,所以這裡補上時間同步。
//
// 注意:必須「取代」整個 extend 陣列,不能再 append 一個 tuyaBase —— 否則
// processExtensions 會把兩份 fromZigbee 都併進去,DP 處理與時間同步都會跑兩次。
// 官方 extend 就只有這一個項目。
//
// 若無效,把 timeStart 改成 '2000' 再試(上游用量 1970:39、2000:15)。
extend: [tuya.modernExtend.tuyaBase({dp: true, timeStart: '1970'})],
// 官方 fingerprint / exposes / meta.tuyaDatapoints / extend 全部沿用,model 也不改,
// 這樣 HA 的 entity id 不會變,自動化不會斷。
//
// processExtensions() 的合併順序是「definition.toZigbee 先、extend (tuyaBase) 的後面接」,
// 而 z2m 找轉換器是 toZigbee.find(c => c.key.includes(key)) 取第一個命中的,
// 所以放在陣列最前面就能接管 state/brightness/color/color_temp。
// 後面保留官方的原陣列,scene / music / work_mode / chip_type 等其他 DP 行為完全不變。
toZigbee: [throttledLight, ...original.toZigbee],
};
Contributor 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.
Research direction
Start by comparing the proposed external converter with the official definition in zigbee-herdsman-converters/src/devices/gledopto.ts and the Tuya implementation in lib/tuya.ts. Review logs/9336c2b0_zigbee2mqtt_2026-08-05T14-10-22.609Z.log and verify the reported MCU hangs and command behavior. Done should mean the invalid-request pattern is addressed without changing the documented exposes or unrelated DP behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- embedded-iot
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100