Koenkk / Koenkk/zigbee2mqtt

[External Converter]: TS0601 from _TZE284_gt5al3bl

Open
#32,754 3 comments 0 reactions 0 assignees View on GitHub

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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.