From 71e16527be2eed1e6b64147a11c7977b7926d945 Mon Sep 17 00:00:00 2001 From: zemismart-dev <275927643+zemismart-dev@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:49:43 +0800 Subject: [PATCH 1/7] Add Zemismart KES606 four-gang relay and scene support --- .../zigbee-switch/fingerprints.yml | 5 + .../profiles/ts0601-scene-switch-4-gang.yml | 69 ++++ .../ts0601-scene-switch-child-scene.yml | 19 + .../profiles/ts0601-scene-switch-child.yml | 30 ++ .../zigbee-switch/src/sub_drivers.lua | 1 + .../src/test/test_zemismart_kes606.lua | 285 ++++++++++++++ .../src/tuya-multi/can_handle.lua | 4 + .../src/zemismart-kes606/can_handle.lua | 21 ++ .../src/zemismart-kes606/init.lua | 350 ++++++++++++++++++ 9 files changed, 784 insertions(+) create mode 100644 drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-4-gang.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-child-scene.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-child.yml create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zemismart-kes606/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua diff --git a/drivers/SmartThings/zigbee-switch/fingerprints.yml b/drivers/SmartThings/zigbee-switch/fingerprints.yml index bb2ce5f027..1413610af9 100644 --- a/drivers/SmartThings/zigbee-switch/fingerprints.yml +++ b/drivers/SmartThings/zigbee-switch/fingerprints.yml @@ -1,4 +1,9 @@ zigbeeManufacturer: + - id: "_TZ3000_hurauima/TS0726" + deviceLabel: Zemismart KES606 4 Gang + manufacturer: _TZ3000_hurauima + model: TS0726 + deviceProfileName: ts0601-scene-switch-4-gang #SMARTvill - id: "SMARTvill/SLA06" deviceLabel: "SMARTvill Switch 1" diff --git a/drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-4-gang.yml b/drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-4-gang.yml new file mode 100644 index 0000000000..71dec937a8 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-4-gang.yml @@ -0,0 +1,69 @@ +name: ts0601-scene-switch-4-gang +components: + - id: main + capabilities: + - id: refresh + version: 1 + categories: + - name: RemoteController +preferences: + - title: Indicator Light + name: lightMode + required: false + preferenceType: enumeration + definition: + options: + none: Off + relay: Follow Relay + pos: Position Indicator + default: relay + - title: Global Power-on State + name: relayStatus + required: false + preferenceType: enumeration + definition: + options: + off: Off + on: On + memory: Memory + default: memory + - title: Gang 1 Power-on State + name: relayStatus1 + required: false + preferenceType: enumeration + definition: + options: + off: Off + on: On + memory: Memory + default: memory + - title: Gang 2 Power-on State + name: relayStatus2 + required: false + preferenceType: enumeration + definition: + options: + off: Off + on: On + memory: Memory + default: memory + - title: Gang 3 Power-on State + name: relayStatus3 + required: false + preferenceType: enumeration + definition: + options: + off: Off + on: On + memory: Memory + default: memory + - title: Gang 4 Power-on State + name: relayStatus4 + required: false + preferenceType: enumeration + definition: + options: + off: Off + on: On + memory: Memory + default: memory diff --git a/drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-child-scene.yml b/drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-child-scene.yml new file mode 100644 index 0000000000..ff850314bf --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-child-scene.yml @@ -0,0 +1,19 @@ +name: ts0601-scene-switch-child-scene +components: + - id: main + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +preferences: + - title: Mode + name: switchMode + description: Switch mode shows a controllable relay; scene mode shows a wireless button. + required: true + preferenceType: enumeration + definition: + options: + switch: Switch + scene: Scene + default: scene diff --git a/drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-child.yml b/drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-child.yml new file mode 100644 index 0000000000..ba07ad28d1 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-child.yml @@ -0,0 +1,30 @@ +name: ts0601-scene-switch-child +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +preferences: + - title: Mode + name: switchMode + description: Switch mode shows a controllable relay; scene mode shows a wireless button. + required: true + preferenceType: enumeration + definition: + options: + switch: Switch + scene: Scene + default: switch + - title: Countdown Seconds + name: countdownSeconds + description: Set the relay countdown in seconds; 0 disables the countdown. + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 43200 + default: 0 diff --git a/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua b/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua index 60390d8074..95da3d18e7 100644 --- a/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua @@ -32,6 +32,7 @@ return { lazy_load_if_possible("wallhero"), lazy_load_if_possible("inovelli"), -- Combined driver for both VZM31-SN and VZM32-SN lazy_load_if_possible("laisiao"), + lazy_load_if_possible("zemismart-kes606"), lazy_load_if_possible("tuya-multi"), lazy_load_if_possible("frient"), lazy_load_if_possible("frient-IO"), diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua new file mode 100644 index 0000000000..f53a64bf48 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua @@ -0,0 +1,285 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zb_utils = require "integration_test.zigbee_test_utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local utils = require "st.utils" +local json = require "st.json" +local OnOff = clusters.OnOff + +local PARENT_PROFILE = "ts0601-scene-switch-4-gang.yml" +local SWITCH_PROFILE = "ts0601-scene-switch-child.yml" +local SCENE_PROFILE = "ts0601-scene-switch-child-scene.yml" + +local function make_parent(label) + local endpoints = {} + for ep = 1, 4 do + endpoints[ep] = { + id = ep, manufacturer = "_TZ3000_hurauima", model = "TS0726", + server_clusters = { 0x0000, 0x0006, 0xE001 }, + } + end + return test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition(PARENT_PROFILE), + manufacturer = "_TZ3000_hurauima", model = "TS0726", label = label, + fingerprinted_endpoint_id = 1, zigbee_endpoints = endpoints, + }) +end + +local function make_children(parent, profile) + local children = {} + for ep = 1, 4 do + children[ep] = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(profile), + parent_device_id = parent.id, parent_assigned_child_key = "gang" .. ep, + label = parent.label .. " L" .. ep, + }) + end + return children +end + +local parent = make_parent("Relay sample") +local children = make_children(parent, SWITCH_PROFILE) +local scene_parent = make_parent("Scene sample") +local scene_children = make_children(scene_parent, SCENE_PROFILE) + +zb_utils.prepare_zigbee_env_info() + + +local function expect_button_metadata(child) + test.socket.capability:__expect_send(child:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(child:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }))) +end + +test.set_test_init_function(function() + test.mock_device.add_test_device(parent) + test.mock_device.add_test_device(scene_parent) + test.socket.capability:__set_channel_ordering("relaxed") + for ep = 1, 4 do + test.mock_device.add_test_device(children[ep]) + test.mock_device.add_test_device(scene_children[ep]) + expect_button_metadata(scene_children[ep]) + end +end) + +local function enum_write(attribute, value, ep, cluster) + return cluster_base.write_attribute(parent, data_types.ClusterId(cluster or 0xE001), + data_types.AttributeId(attribute), data_types.Enum8(value)):to_endpoint(ep) +end + +test.register_coroutine_test("Only the exact four-gang fingerprint uses the composite handler", function() + local matches = require "zemismart-kes606.can_handle" + assert(matches({}, nil, parent)) + for ep = 1, 4 do assert(matches({}, nil, children[ep])) end + local other = test.mock_device.build_test_zigbee_device({ + manufacturer = "_TZ3000_other", model = "TS0726", + profile = t_utils.get_profile_definition(PARENT_PROFILE), + }) + assert(not matches({}, nil, other)) + local other_model = test.mock_device.build_test_zigbee_device({ + manufacturer = "_TZ3000_hurauima", model = "TS0004", + profile = t_utils.get_profile_definition(PARENT_PROFILE), + }) + assert(not matches({}, nil, other_model)) + assert(not matches({}, nil, nil)) + assert(not require("tuya-multi.can_handle")({}, nil, parent)) +end) + +test.register_coroutine_test("Repeated parent initialization does not recreate children", function() + test.socket.device_lifecycle:__queue_receive({ parent.id, "init" }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ parent.id, "init" }) +end) + +test.register_coroutine_test("Restored scene children initialize metadata without button presses", function() + for ep = 1, 4 do + expect_button_metadata(scene_children[ep]) + test.socket.device_lifecycle:__queue_receive({ scene_children[ep].id, "init" }) + end +end) + +for ep = 1, 4 do + for _, on in ipairs({ true, false }) do + local command_name = on and "on" or "off" + test.register_message_test("Relay " .. ep .. " " .. command_name .. " routes to its endpoint", { + { channel = "capability", direction = "receive", message = { + children[ep].id, { capability = "switch", component = "main", + command = command_name, args = {} } + } }, + { channel = "zigbee", direction = "send", message = { + parent.id, (on and OnOff.server.commands.On(parent) + or OnOff.server.commands.Off(parent)):to_endpoint(ep) + } }, + }) + test.register_message_test("Relay report " .. ep .. " " .. command_name .. " reaches only its child", { + { channel = "zigbee", direction = "receive", message = { + parent.id, OnOff.attributes.OnOff:build_test_attr_report(parent, on):from_endpoint(ep) + } }, + { channel = "capability", direction = "send", message = + children[ep]:generate_test_message("main", capabilities.switch.switch(command_name)) }, + }) + end + + test.register_coroutine_test("Scene " .. ep .. " ignores relay attributes and relay commands", function() + test.socket.zigbee:__queue_receive({ scene_parent.id, + OnOff.attributes.OnOff:build_test_attr_report(scene_parent, false):from_endpoint(ep) }) + test.socket.capability:__queue_receive({ scene_children[ep].id, + { capability = "switch", component = "main", command = "on", args = {} } }) + end) + + test.register_coroutine_test("Scene command 0xFD on endpoint " .. ep .. " emits one pushed event", function() + expect_button_metadata(scene_children[ep]) + test.socket.capability:__expect_send(scene_children[ep]:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + test.socket.zigbee:__queue_receive({ scene_parent.id, + zb_utils.build_custom_command_id(scene_parent, 0x0006, 0xFD, nil, "", ep) }) + end) + + test.register_coroutine_test("Relay " .. ep .. " ignores scene commands", function() + test.socket.zigbee:__queue_receive({ parent.id, + zb_utils.build_custom_command_id(parent, 0x0006, 0xFD, nil, "", ep) }) + end) + + test.register_coroutine_test("Mode setting for child " .. ep .. " targets only that gang", function() + children[ep]:expect_metadata_update({ profile = "ts0601-scene-switch-child-scene" }) + test.socket.zigbee:__expect_send({ parent.id, enum_write(0xD020, 1, ep) }) + test.socket.device_lifecycle:__queue_receive(children[ep]:generate_info_changed({ + preferences = { switchMode = "scene" } + })) + end) + + test.register_coroutine_test("Device mode report for gang " .. ep .. " updates the correct profile", function() + children[ep]:expect_metadata_update({ profile = "ts0601-scene-switch-child-scene" }) + test.socket.zigbee:__queue_receive({ parent.id, zb_utils.build_attribute_report(parent, + 0xE001, { { 0xD020, data_types.Enum8.ID, 1 } }):from_endpoint(ep) }) + end) + + test.register_coroutine_test("Scene child " .. ep .. " can return to relay mode", function() + expect_button_metadata(scene_children[ep]) + scene_children[ep]:expect_metadata_update({ profile = "ts0601-scene-switch-child" }) + test.socket.zigbee:__expect_send({ scene_parent.id, cluster_base.write_attribute(scene_parent, + data_types.ClusterId(0xE001), data_types.AttributeId(0xD020), data_types.Enum8(0)):to_endpoint(ep) }) + test.socket.device_lifecycle:__queue_receive(scene_children[ep]:generate_info_changed({ + preferences = { switchMode = "switch" } + })) + end) + + test.register_coroutine_test("Countdown for gang " .. ep .. " uses the selected endpoint", function() + test.socket.zigbee:__expect_send({ parent.id, OnOff.server.commands.OnWithTimedOff(parent, + data_types.Uint8(0), data_types.Uint16(30), data_types.Uint16(30)):to_endpoint(ep) }) + test.socket.device_lifecycle:__queue_receive(children[ep]:generate_info_changed({ + preferences = { countdownSeconds = 30 } + })) + end) +end + +test.register_coroutine_test("Invalid mode report cannot select an unsupported profile", function() + test.socket.zigbee:__queue_receive({ parent.id, zb_utils.build_attribute_report(parent, + 0xE001, { { 0xD020, data_types.Enum8.ID, 2 } }):from_endpoint(1) }) +end) + +test.register_coroutine_test("Indicator and power-on settings use their documented attributes", function() + test.socket.zigbee:__expect_send({ parent.id, enum_write(0x8001, 2, 1, 0x0006) }) + test.socket.zigbee:__expect_send({ parent.id, enum_write(0x8002, 0, 1, 0x0006) }) + test.socket.zigbee:__expect_send({ parent.id, enum_write(0xD010, 1, 3) }) + test.socket.device_lifecycle:__queue_receive(parent:generate_info_changed({ + preferences = { lightMode = "pos", relayStatus = "off", relayStatus3 = "on" } + })) +end) + +test.register_coroutine_test("Switch profile completion refreshes a report lost during mode transition", function() + local child = scene_children[2] + expect_button_metadata(child) + child:expect_metadata_update({ profile = "ts0601-scene-switch-child" }) + test.socket.zigbee:__expect_send({ scene_parent.id, cluster_base.write_attribute(scene_parent, + data_types.ClusterId(0xE001), data_types.AttributeId(0xD020), data_types.Enum8(0)):to_endpoint(2) }) + test.socket.device_lifecycle:__queue_receive(child:generate_info_changed({ + preferences = { switchMode = "switch" } + })) + test.wait_for_events() + test.socket.zigbee:__queue_receive({ scene_parent.id, + OnOff.attributes.OnOff:build_test_attr_report(scene_parent, false):from_endpoint(2) }) + test.wait_for_events() + test.socket.zigbee:__expect_send({ scene_parent.id, + OnOff.attributes.OnOff:read(scene_parent):to_endpoint(2) }) + -- Replace the profile, rather than deep-merging its capability map with the old button profile. + local device_info = utils.deep_copy(child.raw_st_data) + device_info.preferences.switchMode = "switch" + device_info.profile = utils.deep_copy(children[2].raw_st_data.profile) + test.socket.device_lifecycle:__queue_receive({ child.id, "infoChanged", json.encode(device_info) }) + test.wait_for_events() + test.socket.capability:__expect_send(child:generate_test_message("main", + capabilities.switch.switch.off())) + test.socket.zigbee:__queue_receive({ scene_parent.id, + OnOff.attributes.OnOff:build_test_attr_report(scene_parent, false):from_endpoint(2) }) +end) + +local function expect_reads(device, scene) + for ep = 1, 4 do + if not scene then + test.socket.zigbee:__expect_send({ device.id, OnOff.attributes.OnOff:read(device):to_endpoint(ep) }) + end + for _, attribute in ipairs({ 0xD020, 0xD010 }) do + test.socket.zigbee:__expect_send({ device.id, cluster_base.read_attribute(device, + data_types.ClusterId(0xE001), data_types.AttributeId(attribute)):to_endpoint(ep) }) + end + end + for _, attribute in ipairs({ 0x8001, 0x8002 }) do + test.socket.zigbee:__expect_send({ device.id, cluster_base.read_attribute(device, + data_types.ClusterId(0x0006), data_types.AttributeId(attribute)):to_endpoint(1) }) + end +end + +test.register_coroutine_test("Configure binds all four endpoints without generic Tuya duplication", function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ parent.id, zb_utils.build_attribute_read(parent, 0x0000, + { 0x0004, 0x0000, 0x0001, 0x0005, 0x0007, 0xFFFE }) }) + for ep = 1, 4 do + test.socket.zigbee:__expect_send({ parent.id, + zb_utils.build_bind_request(parent, zb_utils.mock_hub_eui, OnOff.ID, ep) }) + test.socket.zigbee:__expect_send({ parent.id, + OnOff.attributes.OnOff:configure_reporting(parent, 0, 300, 1):to_endpoint(ep) }) + for _, attribute in ipairs({ 0xD020, 0xD010 }) do + test.socket.zigbee:__expect_send({ parent.id, cluster_base.configure_reporting(parent, + data_types.ClusterId(0xE001), data_types.AttributeId(attribute), + data_types.Enum8.ID, 1, 3600, 1):to_endpoint(ep) }) + end + end + for _, attribute in ipairs({ 0x8001, 0x8002 }) do + test.socket.zigbee:__expect_send({ parent.id, cluster_base.configure_reporting(parent, + data_types.ClusterId(0x0006), data_types.AttributeId(attribute), + data_types.Enum8.ID, 1, 3600, 1):to_endpoint(1) }) + end + expect_reads(parent, false) + parent:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ parent.id, "doConfigure" }) +end) + +test.register_coroutine_test("Scene refresh reads settings without relay reads or synthetic presses", function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ scene_parent.id, zb_utils.build_attribute_read(scene_parent, + 0x0000, { 0x0004, 0x0000, 0x0001, 0x0005, 0x0007, 0xFFFE }) }) + expect_reads(scene_parent, true) + test.socket.capability:__queue_receive({ scene_parent.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } }) +end) + +test.register_coroutine_test("Initialization creates exactly four relay children", function() end, { + test_init = function() + test.mock_device.add_test_device(parent) + for ep = 1, 4 do + parent:expect_device_create({ type = "EDGE_CHILD", parent_device_id = parent.id, + parent_assigned_child_key = "gang" .. ep, label = parent.label .. " L" .. ep, + profile = "ts0601-scene-switch-child" }) + end + end, +}) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/tuya-multi/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/tuya-multi/can_handle.lua index c4878c95c2..34884e07f4 100644 --- a/drivers/SmartThings/zigbee-switch/src/tuya-multi/can_handle.lua +++ b/drivers/SmartThings/zigbee-switch/src/tuya-multi/can_handle.lua @@ -12,6 +12,10 @@ local function is_multi_endpoint(device) end return function(opts, driver, device) + -- The composite relay/scene device has its own configure and refresh handlers. + if device:get_manufacturer() == "_TZ3000_hurauima" and device:get_model() == "TS0726" then + return false + end local TUYA_MFR_HEADER = "_TZ" if string.sub(device:get_manufacturer(),1,3) == TUYA_MFR_HEADER and is_multi_endpoint(device) then -- if it is a tuya device, then send the magic packet local subdriver = require("tuya-multi") diff --git a/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/can_handle.lua new file mode 100644 index 0000000000..d09709ba87 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/can_handle.lua @@ -0,0 +1,21 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local device_lib = require "st.device" + +return function(_, _, device) + if device == nil then return false end + local parent = device + if device.network_type == device_lib.NETWORK_TYPE_CHILD then + local gang = tonumber((device.parent_assigned_child_key or ""):match("^gang([1-4])$")) + if gang == nil then return false end + parent = device:get_parent_device() + elseif device.network_type ~= device_lib.NETWORK_TYPE_ZIGBEE then + return false + end + if parent ~= nil and parent:get_manufacturer() == "_TZ3000_hurauima" + and parent:get_model() == "TS0726" then + return true, require("zemismart-kes606") + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua new file mode 100644 index 0000000000..3edf61ae46 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua @@ -0,0 +1,350 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local device_lib = require "st.device" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local device_management = require "st.zigbee.device_management" +local zcl_messages = require "st.zigbee.zcl" +local messages = require "st.zigbee.messages" +local zb_const = require "st.zigbee.constants" +local read_attribute = require "st.zigbee.zcl.global_commands.read_attribute" + +local OnOff = clusters.OnOff +local SWITCH_PROFILE = "ts0601-scene-switch-child" +local SCENE_PROFILE = "ts0601-scene-switch-child-scene" +local MODE_CLUSTER = 0xE001 +local MODE_ATTRIBUTE = 0xD020 +local POWER_ON_ATTRIBUTE = 0xD010 +local LIGHT_ATTRIBUTE = 0x8001 +local GLOBAL_POWER_ON_ATTRIBUTE = 0x8002 +local SCENE_PRESS_COMMAND = 0xFD +local LIGHT_VALUES = { none = 0, relay = 1, pos = 2 } +local POWER_ON_VALUES = { off = 0, on = 1, memory = 2 } + +local function parent_from_device(device) + if device.network_type == device_lib.NETWORK_TYPE_CHILD then + return device:get_parent_device() + end + return device +end + +local function child_gang(device) + if device.network_type ~= device_lib.NETWORK_TYPE_CHILD then return nil end + return tonumber((device.parent_assigned_child_key or ""):match("^gang([1-4])$")) +end + +local function get_child(parent, gang) + return parent:get_child_by_parent_assigned_key(string.format("gang%d", gang)) +end + +local function mode_field(gang) + return string.format("mode_gang_%d", gang) +end + +local function gang_mode(parent, gang) + local mode = parent:get_field(mode_field(gang)) + if mode ~= nil then return mode end + local child = get_child(parent, gang) + if child ~= nil and child:supports_capability(capabilities.button) + and not child:supports_capability(capabilities.switch) then + return "scene" + end + return "switch" +end + +local function profile_for_mode(mode) + return mode == "scene" and SCENE_PROFILE or SWITCH_PROFILE +end + +local function initialize_button(child) + if child:supports_capability(capabilities.button) then + child:emit_event(capabilities.button.supportedButtonValues( + { "pushed" }, { visibility = { displayed = false } })) + child:emit_event(capabilities.button.numberOfButtons( + { value = 1 }, { visibility = { displayed = false } })) + end + -- Initialization and profile transitions must never synthesize a button press. +end + +local function set_child_profile(child, mode) + local capability = mode == "scene" and capabilities.button or capabilities.switch + if not child:supports_capability(capability) then + child:try_update_metadata({ profile = profile_for_mode(mode) }) + end +end + +local function ensure_children(driver, parent) + for gang = 1, 4 do + local child = get_child(parent, gang) + local mode = gang_mode(parent, gang) + if child == nil then + driver:try_create_device({ + type = "EDGE_CHILD", + parent_device_id = parent.id, + parent_assigned_child_key = string.format("gang%d", gang), + label = string.format("%s L%d", parent.label or "Zemismart KES606", gang), + profile = profile_for_mode(mode), + }) + else + parent:set_field(mode_field(gang), mode, { persist = true }) + set_child_profile(child, mode) + end + end +end + +local function endpoint_from_rx(zb_rx) + local header = zb_rx and zb_rx.address_header + local endpoint = header and header.src_endpoint and header.src_endpoint.value + if endpoint ~= nil and endpoint >= 1 and endpoint <= 4 then return endpoint end +end + +local function send_magic_spell(parent) + local zclh = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(read_attribute.ReadAttribute.ID) + }) + parent:send(messages.ZigbeeMessageTx({ + address_header = messages.AddressHeader( + zb_const.HUB.ADDR, zb_const.HUB.ENDPOINT, parent:get_short_address(), + parent:get_endpoint(clusters.Basic.ID) or 1, zb_const.HA_PROFILE_ID, clusters.Basic.ID + ), + body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = read_attribute.ReadAttribute({ 0x0004, 0x0000, 0x0001, 0x0005, 0x0007, 0xFFFE }) + }) + })) +end + +local function read_enum(parent, cluster_id, attribute_id, gang) + parent:send(cluster_base.read_attribute( + parent, data_types.ClusterId(cluster_id), data_types.AttributeId(attribute_id) + ):to_endpoint(gang)) +end + +local function write_enum(parent, cluster_id, attribute_id, gang, value) + parent:send(cluster_base.write_attribute( + parent, data_types.ClusterId(cluster_id), data_types.AttributeId(attribute_id), + data_types.Enum8(value) + ):to_endpoint(gang)) +end + +local function configure_enum(parent, cluster_id, attribute_id, gang) + parent:send(cluster_base.configure_reporting( + parent, data_types.ClusterId(cluster_id), data_types.AttributeId(attribute_id), + data_types.Enum8.ID, 1, 3600, 1 + ):to_endpoint(gang)) +end + +local function read_gang(parent, gang) + if gang_mode(parent, gang) ~= "scene" then + parent:send(OnOff.attributes.OnOff:read(parent):to_endpoint(gang)) + end + read_enum(parent, MODE_CLUSTER, MODE_ATTRIBUTE, gang) + read_enum(parent, MODE_CLUSTER, POWER_ON_ATTRIBUTE, gang) + if gang == 1 then + read_enum(parent, OnOff.ID, LIGHT_ATTRIBUTE, 1) + read_enum(parent, OnOff.ID, GLOBAL_POWER_ON_ATTRIBUTE, 1) + end +end + +local function refresh(driver, device) + local parent = parent_from_device(device) + if parent == nil then return end + send_magic_spell(parent) + ensure_children(driver, parent) + for gang = 1, 4 do read_gang(parent, gang) end +end + +local function relay_command(device, on) + local gang = child_gang(device) + local parent = parent_from_device(device) + if gang == nil or parent == nil or gang_mode(parent, gang) == "scene" then return end + local command = on and OnOff.server.commands.On(parent) or OnOff.server.commands.Off(parent) + parent:send(command:to_endpoint(gang)) +end + +local function onoff_report(_, device, value, zb_rx) + local gang = endpoint_from_rx(zb_rx) + local parent = parent_from_device(device) + if gang == nil or parent == nil or gang_mode(parent, gang) == "scene" then return end + local child = get_child(parent, gang) + if child ~= nil and child:supports_capability(capabilities.switch) then + local state = (value.value == true or value.value == 1) and "on" or "off" + child:emit_event(capabilities.switch.switch(state)) + end +end + +local function mode_report(_, device, value, zb_rx) + local gang = endpoint_from_rx(zb_rx) + local parent = parent_from_device(device) + if gang == nil or parent == nil or (value.value ~= 0 and value.value ~= 1) then return end + local mode = value.value == 1 and "scene" or "switch" + parent:set_field(mode_field(gang), mode, { persist = true }) + local child = get_child(parent, gang) + if child ~= nil then + set_child_profile(child, mode) + initialize_button(child) + end +end + +local function power_on_report(_, device, value, zb_rx) + local gang = endpoint_from_rx(zb_rx) + local parent = parent_from_device(device) + if gang ~= nil and parent ~= nil then + parent:set_field(string.format("relay_status_gang_%d", gang), value.value, { persist = true }) + end +end + +local function light_report(_, device, value) + local parent = parent_from_device(device) + if parent ~= nil then parent:set_field("light_mode", value.value, { persist = true }) end +end + +local function global_power_on_report(_, device, value) + local parent = parent_from_device(device) + if parent ~= nil then parent:set_field("relay_status", value.value, { persist = true }) end +end + +local function scene_command(_, device, zb_rx) + local gang = endpoint_from_rx(zb_rx) + local parent = parent_from_device(device) + if gang == nil or parent == nil or gang_mode(parent, gang) ~= "scene" then return end + local child = get_child(parent, gang) + if child ~= nil and child:supports_capability(capabilities.button) then + initialize_button(child) + child:emit_event(capabilities.button.button.pushed({ state_change = true })) + end +end + +local function initialize(driver, device) + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + ensure_children(driver, device) + else + initialize_button(device) + end +end + +local function do_configure(driver, device) + if device.network_type ~= device_lib.NETWORK_TYPE_ZIGBEE then return end + send_magic_spell(device) + ensure_children(driver, device) + for gang = 1, 4 do + device:send(device_management.build_bind_request( + device, OnOff.ID, driver.environment_info.hub_zigbee_eui, gang)) + device:send(OnOff.attributes.OnOff:configure_reporting(device, 0, 300, 1):to_endpoint(gang)) + configure_enum(device, MODE_CLUSTER, MODE_ATTRIBUTE, gang) + configure_enum(device, MODE_CLUSTER, POWER_ON_ATTRIBUTE, gang) + if gang == 1 then + configure_enum(device, OnOff.ID, LIGHT_ATTRIBUTE, 1) + configure_enum(device, OnOff.ID, GLOBAL_POWER_ON_ATTRIBUTE, 1) + end + read_gang(device, gang) + end +end + +local function changed(preferences, old_preferences, name) + return preferences[name] ~= nil and preferences[name] ~= old_preferences[name] +end + +local function update_parent_preferences(device, old_preferences) + local preferences = device.preferences or {} + if changed(preferences, old_preferences, "lightMode") then + local value = LIGHT_VALUES[preferences.lightMode] + if value ~= nil then write_enum(device, OnOff.ID, LIGHT_ATTRIBUTE, 1, value) end + end + if changed(preferences, old_preferences, "relayStatus") then + local value = POWER_ON_VALUES[preferences.relayStatus] + if value ~= nil then write_enum(device, OnOff.ID, GLOBAL_POWER_ON_ATTRIBUTE, 1, value) end + end + for gang = 1, 4 do + local name = string.format("relayStatus%d", gang) + if changed(preferences, old_preferences, name) then + local value = POWER_ON_VALUES[preferences[name]] + if value ~= nil then write_enum(device, MODE_CLUSTER, POWER_ON_ATTRIBUTE, gang, value) end + end + end +end + +local function info_changed(_, device, _, args) + local old_preferences = args and args.old_st_store and args.old_st_store.preferences or {} + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + update_parent_preferences(device, old_preferences) + return + end + local gang = child_gang(device) + local parent = parent_from_device(device) + if gang == nil or parent == nil then return end + local preferences = device.preferences or {} + -- A profile update is asynchronous; initialize its button metadata when it arrives. + initialize_button(device) + local old_profile = args and args.old_st_store and args.old_st_store.profile + local old_main = old_profile and old_profile.components and old_profile.components.main + local old_switch = old_main and old_main.capabilities and old_main.capabilities.switch + if old_main ~= nil and old_switch == nil and device:supports_capability(capabilities.switch) then + -- A relay report may have arrived while the previous button profile was still active. + parent:send(OnOff.attributes.OnOff:read(parent):to_endpoint(gang)) + end + local mode = preferences.switchMode + if changed(preferences, old_preferences, "switchMode") + and (mode == "switch" or mode == "scene") then + parent:set_field(mode_field(gang), mode, { persist = true }) + set_child_profile(device, mode) + write_enum(parent, MODE_CLUSTER, MODE_ATTRIBUTE, gang, mode == "scene" and 1 or 0) + end + if changed(preferences, old_preferences, "countdownSeconds") then + local seconds = tonumber(preferences.countdownSeconds) + if seconds ~= nil and seconds == seconds then + seconds = math.max(0, math.min(43200, math.floor(seconds))) + parent:send(OnOff.server.commands.OnWithTimedOff( + parent, data_types.Uint8(0), data_types.Uint16(seconds), data_types.Uint16(seconds) + ):to_endpoint(gang)) + parent:set_field(string.format("countdown_gang_%d", gang), seconds, { persist = true }) + end + end +end + +return { + NAME = "Zemismart KES606 Four Gang", + supported_capabilities = { capabilities.refresh, capabilities.switch, capabilities.button }, + lifecycle_handlers = { + added = initialize, + init = initialize, + doConfigure = do_configure, + infoChanged = info_changed, + }, + capability_handlers = { + [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = refresh }, + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = function(_, device) + relay_command(device, true) + end, + [capabilities.switch.commands.off.NAME] = function(_, device) + relay_command(device, false) + end, + }, + }, + zigbee_handlers = { + attr = { + [OnOff.ID] = { + [OnOff.attributes.OnOff.ID] = onoff_report, + [LIGHT_ATTRIBUTE] = light_report, + [GLOBAL_POWER_ON_ATTRIBUTE] = global_power_on_report, + }, + [MODE_CLUSTER] = { + [MODE_ATTRIBUTE] = mode_report, + [POWER_ON_ATTRIBUTE] = power_on_report, + }, + }, + cluster = { + [OnOff.ID] = { + [OnOff.server.commands.On.ID] = scene_command, + [OnOff.server.commands.Off.ID] = scene_command, + [OnOff.server.commands.Toggle.ID] = scene_command, + [SCENE_PRESS_COMMAND] = scene_command, + }, + }, + }, + can_handle = require("zemismart-kes606.can_handle"), +} From bc2cd13a92d23b0502040a07028aa462348cfa1a Mon Sep 17 00:00:00 2001 From: zemismart-dev <275927643+zemismart-dev@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:09:34 +0800 Subject: [PATCH 2/7] Handle composite device driver switching explicitly --- .../src/test/test_zemismart_kes606.lua | 23 ++++++++++++++++++- .../src/zemismart-kes606/init.lua | 11 +++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua index f53a64bf48..22b33b26ef 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua @@ -237,7 +237,7 @@ local function expect_reads(device, scene) end end -test.register_coroutine_test("Configure binds all four endpoints without generic Tuya duplication", function() +local function expect_configuration() test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.zigbee:__expect_send({ parent.id, zb_utils.build_attribute_read(parent, 0x0000, { 0x0004, 0x0000, 0x0001, 0x0005, 0x0007, 0xFFFE }) }) @@ -259,9 +259,30 @@ test.register_coroutine_test("Configure binds all four endpoints without generic end expect_reads(parent, false) parent:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.register_coroutine_test("Configure binds all four endpoints without generic Tuya duplication", function() + expect_configuration() test.socket.device_lifecycle:__queue_receive({ parent.id, "doConfigure" }) end) +test.register_coroutine_test("Driver switch provisions the refresh-only parent using its own configuration", function() + expect_configuration() + test.socket.device_lifecycle:__queue_receive({ parent.id, "driverSwitched" }) +end) + +for ep = 1, 4 do + test.register_coroutine_test("Driver switch provisions relay child " .. ep, function() + children[ep]:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ children[ep].id, "driverSwitched" }) + end) + test.register_coroutine_test("Driver switch provisions scene child " .. ep .. " without a press", function() + expect_button_metadata(scene_children[ep]) + scene_children[ep]:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ scene_children[ep].id, "driverSwitched" }) + end) +end + test.register_coroutine_test("Scene refresh reads settings without relay reads or synthetic presses", function() test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.zigbee:__expect_send({ scene_parent.id, zb_utils.build_attribute_read(scene_parent, diff --git a/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua index 3edf61ae46..0b8f24017e 100644 --- a/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua @@ -244,6 +244,16 @@ local function do_configure(driver, device) end end +local function driver_switched(driver, device) + -- The root driver's capability check does not include this sub-driver's refresh/button pair. + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + do_configure(driver, device) + else + initialize_button(device) + end + device:try_update_metadata({ provisioning_state = "PROVISIONED" }) +end + local function changed(preferences, old_preferences, name) return preferences[name] ~= nil and preferences[name] ~= old_preferences[name] end @@ -312,6 +322,7 @@ return { added = initialize, init = initialize, doConfigure = do_configure, + driverSwitched = driver_switched, infoChanged = info_changed, }, capability_handlers = { From eedb8f566af83487142b12b3a685c6e7f4b96212 Mon Sep 17 00:00:00 2001 From: zemismart-dev <275927643+zemismart-dev@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:21:36 +0800 Subject: [PATCH 3/7] Support both profile capability representations during transitions --- .../zigbee-switch/src/zemismart-kes606/init.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua index 0b8f24017e..e29c2600ff 100644 --- a/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua @@ -292,6 +292,14 @@ local function info_changed(_, device, _, args) local old_profile = args and args.old_st_store and args.old_st_store.profile local old_main = old_profile and old_profile.components and old_profile.components.main local old_switch = old_main and old_main.capabilities and old_main.capabilities.switch + if old_switch == nil and old_main ~= nil then + for _, capability in pairs(old_main.capabilities or {}) do + if capability.id == capabilities.switch.ID then + old_switch = capability + break + end + end + end if old_main ~= nil and old_switch == nil and device:supports_capability(capabilities.switch) then -- A relay report may have arrived while the previous button profile was still active. parent:send(OnOff.attributes.OnOff:read(parent):to_endpoint(gang)) From e1c0c3cbf5f1feaec67f634dd462dd59bbec2485 Mon Sep 17 00:00:00 2001 From: zemismart-dev <275927643+zemismart-dev@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:55:53 +0800 Subject: [PATCH 4/7] Prevent KES606 acknowledgements from targeting display parent --- .../src/test/test_zemismart_kes606.lua | 67 +++++++++++++++++++ .../src/zemismart-kes606/init.lua | 9 +++ 2 files changed, 76 insertions(+) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua index 22b33b26ef..b5879dcd19 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua @@ -10,6 +10,11 @@ local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" local utils = require "st.utils" local json = require "st.json" +local messages = require "st.zigbee.messages" +local constants = require "st.zigbee.constants" +local zcl_messages = require "st.zigbee.zcl" +local default_response = require "st.zigbee.zcl.global_commands.default_response" +local Status = require "st.zigbee.generated.types.ZclStatus" local OnOff = clusters.OnOff local PARENT_PROFILE = "ts0601-scene-switch-4-gang.yml" @@ -303,4 +308,66 @@ test.register_coroutine_test("Initialization creates exactly four relay children end, }) +local function build_onoff_default_response(device, endpoint, command_id, status) + local body = default_response.DefaultResponse(command_id, status) + return messages.ZigbeeMessageRx({ + address_header = messages.AddressHeader(device:get_short_address(), endpoint, + constants.HUB.ADDR, constants.HUB.ENDPOINT, constants.HA_PROFILE_ID, OnOff.ID), + body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_messages.ZclHeader({ cmd = data_types.ZCLCommandId(body.ID) }), + zcl_body = body, + }), + }) +end + +for _, scene in ipairs({ false, true }) do + local sample = scene and scene_parent or parent + local mode_name = scene and "scene" or "relay" + for ep = 1, 4 do + for _, on in ipairs({ true, false }) do + local command_id = on and OnOff.server.commands.On.ID or OnOff.server.commands.Off.ID + local command_name = on and "on" or "off" + for _, success in ipairs({ true, false }) do + local status = success and Status.SUCCESS or Status.FAILURE + local status_name = success and "success" or "failure" + test.register_coroutine_test("DefaultResponse " .. mode_name .. " " .. ep .. " " + .. command_name .. " " .. status_name .. " never claims parent state or native routing", function() + -- Unsupported parent events are silently discarded by the SDK. Observe only this test's + -- real device instance before that guard, so a generic parent handler cannot pass unnoticed. + local actual = rawget(sample, "wrapped_device") + local emit = actual.emit_component_event + local raw_emit = rawget(actual, "emit_component_event") + local raw_register = rawget(actual, "register_native_capability_attr_handler") + local parent_events, native_registrations = 0, 0 + rawset(actual, "emit_component_event", function(self, ...) + parent_events = parent_events + 1 + return emit(self, ...) + end) + rawset(actual, "register_native_capability_attr_handler", function() + native_registrations = native_registrations + 1 + end) + + test.socket.zigbee:__queue_receive({ sample.id, + build_onoff_default_response(sample, ep, command_id, status) }) + test.wait_for_events() + -- A real report remains authoritative, even after a failed command response. + if not scene then + test.socket.capability:__expect_send(children[ep]:generate_test_message("main", + capabilities.switch.switch(command_name))) + end + test.socket.zigbee:__queue_receive({ sample.id, + OnOff.attributes.OnOff:build_test_attr_report(sample, on):from_endpoint(ep) }) + test.wait_for_events() + + rawset(actual, "emit_component_event", raw_emit) + rawset(actual, "register_native_capability_attr_handler", raw_register) + assert(parent_events == 0 and native_registrations == 0, + string.format("DefaultResponse leaked to generic parent handling: %d parent events, %d native registrations", + parent_events, native_registrations)) + end) + end + end + end +end + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua index e29c2600ff..7217e318d3 100644 --- a/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua @@ -11,6 +11,7 @@ local zcl_messages = require "st.zigbee.zcl" local messages = require "st.zigbee.messages" local zb_const = require "st.zigbee.constants" local read_attribute = require "st.zigbee.zcl.global_commands.read_attribute" +local default_response = require "st.zigbee.zcl.global_commands.default_response" local OnOff = clusters.OnOff local SWITCH_PROFILE = "ts0601-scene-switch-child" @@ -345,6 +346,14 @@ return { }, }, zigbee_handlers = { + global = { + [OnOff.ID] = { + [default_response.DefaultResponse.ID] = function() + -- Relay state comes from attribute reports, not command acknowledgements. + -- The generic handler targets the display-only parent and registers native handling. + end, + }, + }, attr = { [OnOff.ID] = { [OnOff.attributes.OnOff.ID] = onoff_report, From 192595d7af85bb0d2b56c4aeeedc07b323bc6877 Mon Sep 17 00:00:00 2001 From: zemismart-dev <275927643+zemismart-dev@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:17:27 +0800 Subject: [PATCH 5/7] Add aggregate switch and scene button to KES606 parent --- .../profiles/ts0601-scene-switch-4-gang.yml | 4 + .../src/test/test_zemismart_kes606.lua | 388 +++++++++++++++++- .../src/zemismart-kes606/init.lua | 158 +++++-- 3 files changed, 512 insertions(+), 38 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-4-gang.yml b/drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-4-gang.yml index 71dec937a8..66fcc97fa6 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-4-gang.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/ts0601-scene-switch-4-gang.yml @@ -2,6 +2,10 @@ name: ts0601-scene-switch-4-gang components: - id: main capabilities: + - id: switch + version: 1 + - id: button + version: 1 - id: refresh version: 1 categories: diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua index b5879dcd19..7d79295e13 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua @@ -21,7 +21,7 @@ local PARENT_PROFILE = "ts0601-scene-switch-4-gang.yml" local SWITCH_PROFILE = "ts0601-scene-switch-child.yml" local SCENE_PROFILE = "ts0601-scene-switch-child-scene.yml" -local function make_parent(label) +local function make_parent(label, profile) local endpoints = {} for ep = 1, 4 do endpoints[ep] = { @@ -30,7 +30,7 @@ local function make_parent(label) } end return test.mock_device.build_test_zigbee_device({ - profile = t_utils.get_profile_definition(PARENT_PROFILE), + profile = profile or t_utils.get_profile_definition(PARENT_PROFILE), manufacturer = "_TZ3000_hurauima", model = "TS0726", label = label, fingerprinted_endpoint_id = 1, zigbee_endpoints = endpoints, }) @@ -66,6 +66,8 @@ end test.set_test_init_function(function() test.mock_device.add_test_device(parent) test.mock_device.add_test_device(scene_parent) + expect_button_metadata(parent) + expect_button_metadata(scene_parent) test.socket.capability:__set_channel_ordering("relaxed") for ep = 1, 4 do test.mock_device.add_test_device(children[ep]) @@ -98,8 +100,10 @@ test.register_coroutine_test("Only the exact four-gang fingerprint uses the comp end) test.register_coroutine_test("Repeated parent initialization does not recreate children", function() + expect_button_metadata(parent) test.socket.device_lifecycle:__queue_receive({ parent.id, "init" }) test.wait_for_events() + expect_button_metadata(parent) test.socket.device_lifecycle:__queue_receive({ parent.id, "init" }) end) @@ -123,13 +127,19 @@ for ep = 1, 4 do or OnOff.server.commands.Off(parent)):to_endpoint(ep) } }, }) - test.register_message_test("Relay report " .. ep .. " " .. command_name .. " reaches only its child", { + local report = { { channel = "zigbee", direction = "receive", message = { parent.id, OnOff.attributes.OnOff:build_test_attr_report(parent, on):from_endpoint(ep) } }, { channel = "capability", direction = "send", message = children[ep]:generate_test_message("main", capabilities.switch.switch(command_name)) }, - }) + } + if on then + table.insert(report, { channel = "capability", direction = "send", + message = parent:generate_test_message("main", capabilities.switch.switch.on()) }) + end + test.register_message_test("Relay report " .. ep .. " " .. command_name + .. " updates its child and the report-based aggregate", report) end test.register_coroutine_test("Scene " .. ep .. " ignores relay attributes and relay commands", function() @@ -139,10 +149,12 @@ for ep = 1, 4 do { capability = "switch", component = "main", command = "on", args = {} } }) end) - test.register_coroutine_test("Scene command 0xFD on endpoint " .. ep .. " emits one pushed event", function() + test.register_coroutine_test("Scene command 0xFD on endpoint " .. ep .. " emits child and parent pushed events", function() expect_button_metadata(scene_children[ep]) test.socket.capability:__expect_send(scene_children[ep]:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) + test.socket.capability:__expect_send(scene_parent:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) test.socket.zigbee:__queue_receive({ scene_parent.id, zb_utils.build_custom_command_id(scene_parent, 0x0006, 0xFD, nil, "", ep) }) end) @@ -155,6 +167,8 @@ for ep = 1, 4 do test.register_coroutine_test("Mode setting for child " .. ep .. " targets only that gang", function() children[ep]:expect_metadata_update({ profile = "ts0601-scene-switch-child-scene" }) test.socket.zigbee:__expect_send({ parent.id, enum_write(0xD020, 1, ep) }) + test.socket.zigbee:__expect_send({ parent.id, cluster_base.read_attribute(parent, + data_types.ClusterId(0xE001), data_types.AttributeId(0xD020)):to_endpoint(ep) }) test.socket.device_lifecycle:__queue_receive(children[ep]:generate_info_changed({ preferences = { switchMode = "scene" } })) @@ -171,6 +185,8 @@ for ep = 1, 4 do scene_children[ep]:expect_metadata_update({ profile = "ts0601-scene-switch-child" }) test.socket.zigbee:__expect_send({ scene_parent.id, cluster_base.write_attribute(scene_parent, data_types.ClusterId(0xE001), data_types.AttributeId(0xD020), data_types.Enum8(0)):to_endpoint(ep) }) + test.socket.zigbee:__expect_send({ scene_parent.id, cluster_base.read_attribute(scene_parent, + data_types.ClusterId(0xE001), data_types.AttributeId(0xD020)):to_endpoint(ep) }) test.socket.device_lifecycle:__queue_receive(scene_children[ep]:generate_info_changed({ preferences = { switchMode = "switch" } })) @@ -191,6 +207,7 @@ test.register_coroutine_test("Invalid mode report cannot select an unsupported p end) test.register_coroutine_test("Indicator and power-on settings use their documented attributes", function() + expect_button_metadata(parent) test.socket.zigbee:__expect_send({ parent.id, enum_write(0x8001, 2, 1, 0x0006) }) test.socket.zigbee:__expect_send({ parent.id, enum_write(0x8002, 0, 1, 0x0006) }) test.socket.zigbee:__expect_send({ parent.id, enum_write(0xD010, 1, 3) }) @@ -205,10 +222,21 @@ test.register_coroutine_test("Switch profile completion refreshes a report lost child:expect_metadata_update({ profile = "ts0601-scene-switch-child" }) test.socket.zigbee:__expect_send({ scene_parent.id, cluster_base.write_attribute(scene_parent, data_types.ClusterId(0xE001), data_types.AttributeId(0xD020), data_types.Enum8(0)):to_endpoint(2) }) + test.socket.zigbee:__expect_send({ scene_parent.id, cluster_base.read_attribute(scene_parent, + data_types.ClusterId(0xE001), data_types.AttributeId(0xD020)):to_endpoint(2) }) test.socket.device_lifecycle:__queue_receive(child:generate_info_changed({ preferences = { switchMode = "switch" } })) test.wait_for_events() + child:expect_metadata_update({ profile = "ts0601-scene-switch-child" }) + expect_button_metadata(child) + test.socket.zigbee:__expect_send({ scene_parent.id, + OnOff.attributes.OnOff:read(scene_parent):to_endpoint(2) }) + test.socket.zigbee:__queue_receive({ scene_parent.id, zb_utils.build_attribute_report(scene_parent, + 0xE001, { { 0xD020, data_types.Enum8.ID, 0 } }):from_endpoint(2) }) + test.wait_for_events() + test.socket.capability:__expect_send(scene_parent:generate_test_message("main", + capabilities.switch.switch.off())) test.socket.zigbee:__queue_receive({ scene_parent.id, OnOff.attributes.OnOff:build_test_attr_report(scene_parent, false):from_endpoint(2) }) test.wait_for_events() @@ -271,7 +299,8 @@ test.register_coroutine_test("Configure binds all four endpoints without generic test.socket.device_lifecycle:__queue_receive({ parent.id, "doConfigure" }) end) -test.register_coroutine_test("Driver switch provisions the refresh-only parent using its own configuration", function() +test.register_coroutine_test("Driver switch provisions the aggregate parent using its own configuration", function() + expect_button_metadata(parent) expect_configuration() test.socket.device_lifecycle:__queue_receive({ parent.id, "driverSwitched" }) end) @@ -300,6 +329,7 @@ end) test.register_coroutine_test("Initialization creates exactly four relay children", function() end, { test_init = function() test.mock_device.add_test_device(parent) + expect_button_metadata(parent) for ep = 1, 4 do parent:expect_device_create({ type = "EDGE_CHILD", parent_device_id = parent.id, parent_assigned_child_key = "gang" .. ep, label = parent.label .. " L" .. ep, @@ -332,8 +362,8 @@ for _, scene in ipairs({ false, true }) do local status_name = success and "success" or "failure" test.register_coroutine_test("DefaultResponse " .. mode_name .. " " .. ep .. " " .. command_name .. " " .. status_name .. " never claims parent state or native routing", function() - -- Unsupported parent events are silently discarded by the SDK. Observe only this test's - -- real device instance before that guard, so a generic parent handler cannot pass unnoticed. + -- A command acknowledgement must not be interpreted as the aggregate relay state. + -- Observe the handler directly as well as the output channel to catch native registration. local actual = rawget(sample, "wrapped_device") local emit = actual.emit_component_event local raw_emit = rawget(actual, "emit_component_event") @@ -350,10 +380,16 @@ for _, scene in ipairs({ false, true }) do test.socket.zigbee:__queue_receive({ sample.id, build_onoff_default_response(sample, ep, command_id, status) }) test.wait_for_events() + assert(parent_events == 0 and native_registrations == 0, + "DefaultResponse must not emit aggregate state or register native routing") -- A real report remains authoritative, even after a failed command response. if not scene then test.socket.capability:__expect_send(children[ep]:generate_test_message("main", capabilities.switch.switch(command_name))) + if on then + test.socket.capability:__expect_send(parent:generate_test_message("main", + capabilities.switch.switch.on())) + end end test.socket.zigbee:__queue_receive({ sample.id, OnOff.attributes.OnOff:build_test_attr_report(sample, on):from_endpoint(ep) }) @@ -361,13 +397,343 @@ for _, scene in ipairs({ false, true }) do rawset(actual, "emit_component_event", raw_emit) rawset(actual, "register_native_capability_attr_handler", raw_register) - assert(parent_events == 0 and native_registrations == 0, - string.format("DefaultResponse leaked to generic parent handling: %d parent events, %d native registrations", - parent_events, native_registrations)) + assert(native_registrations == 0, "Reports must retain explicit child and aggregate routing") end) end end end end +local function receive_relay_report(device, child, gang, on, aggregate) + if child ~= nil then + test.socket.capability:__expect_send(child:generate_test_message("main", + capabilities.switch.switch(on and "on" or "off"))) + end + if aggregate ~= nil then + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.switch.switch(aggregate))) + end + test.socket.zigbee:__queue_receive({ device.id, + OnOff.attributes.OnOff:build_test_attr_report(device, on):from_endpoint(gang) }) + test.wait_for_events() +end + +local function receive_mode_report(device, gang, mode) + test.socket.zigbee:__queue_receive({ device.id, zb_utils.build_attribute_report(device, + 0xE001, { { 0xD020, data_types.Enum8.ID, mode == "scene" and 1 or 0 } }):from_endpoint(gang) }) + test.wait_for_events() +end + +local function expect_parent_switch(device, state) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.switch.switch(state))) +end + +for _, on in ipairs({ true, false }) do + local command_name = on and "on" or "off" + test.register_coroutine_test("Parent " .. command_name .. " controls all four relay endpoints without claiming state", function() + for ep = 1, 4 do + test.socket.zigbee:__expect_send({ parent.id, + (on and OnOff.server.commands.On(parent) or OnOff.server.commands.Off(parent)):to_endpoint(ep) }) + end + test.socket.capability:__queue_receive({ parent.id, + { capability = "switch", component = "main", command = command_name, args = {} } }) + end) + + test.register_coroutine_test("Parent " .. command_name .. " excludes scene gangs during a profile transition", function() + for _, ep in ipairs({ 2, 4 }) do + children[ep]:expect_metadata_update({ profile = "ts0601-scene-switch-child-scene" }) + receive_mode_report(parent, ep, "scene") + end + for _, ep in ipairs({ 1, 3 }) do + test.socket.zigbee:__expect_send({ parent.id, + (on and OnOff.server.commands.On(parent) or OnOff.server.commands.Off(parent)):to_endpoint(ep) }) + end + test.socket.capability:__queue_receive({ parent.id, + { capability = "switch", component = "main", command = command_name, args = {} } }) + end) + + test.register_coroutine_test("Parent " .. command_name .. " is inert when every gang is a scene button", function() + test.socket.capability:__queue_receive({ scene_parent.id, + { capability = "switch", component = "main", command = command_name, args = {} } }) + end) +end + +test.register_coroutine_test("Parent remains unknown until all relay gangs report off", function() + for ep = 1, 4 do + receive_relay_report(parent, children[ep], ep, false, ep == 4 and "off" or nil) + end +end) + +test.register_coroutine_test("Parent turns on with any known on and waits for unknown gangs before off", function() + receive_relay_report(parent, children[3], 3, true, "on") + receive_relay_report(parent, children[3], 3, false) + receive_relay_report(parent, children[1], 1, false) + receive_relay_report(parent, children[4], 4, false) + receive_relay_report(parent, children[2], 2, false, "off") +end) + +test.register_coroutine_test("Parent aggregate deduplicates repeated state while retaining child reports", function() + for ep = 1, 4 do + receive_relay_report(parent, children[ep], ep, false, ep == 4 and "off" or nil) + end + receive_relay_report(parent, children[2], 2, false) + receive_relay_report(parent, children[1], 1, true, "on") + receive_relay_report(parent, children[4], 4, true) + receive_relay_report(parent, children[1], 1, false) + receive_relay_report(parent, children[4], 4, false, "off") +end) + +test.register_coroutine_test("Parent mixed-mode aggregate excludes scene relay reports", function() + for _, ep in ipairs({ 2, 4 }) do + children[ep]:expect_metadata_update({ profile = "ts0601-scene-switch-child-scene" }) + receive_mode_report(parent, ep, "scene") + end + receive_relay_report(parent, children[1], 1, false) + receive_relay_report(parent, nil, 2, true) + receive_relay_report(parent, children[3], 3, false, "off") + receive_relay_report(parent, nil, 4, true) +end) + +test.register_coroutine_test("Changing the only on relay to scene immediately recomputes the parent", function() + for ep = 1, 4 do + receive_relay_report(parent, children[ep], ep, ep == 2, ep == 2 and "on" or nil) + end + children[2]:expect_metadata_update({ profile = "ts0601-scene-switch-child-scene" }) + expect_parent_switch(parent, "off") + receive_mode_report(parent, 2, "scene") + receive_relay_report(parent, nil, 2, true) +end) + +test.register_coroutine_test("Preference mode change cannot declare parent off before hardware confirmation", function() + for ep = 1, 4 do + receive_relay_report(parent, children[ep], ep, ep == 2, ep == 2 and "on" or nil) + end + children[2]:expect_metadata_update({ profile = "ts0601-scene-switch-child-scene" }) + test.socket.zigbee:__expect_send({ parent.id, enum_write(0xD020, 1, 2) }) + test.socket.zigbee:__expect_send({ parent.id, cluster_base.read_attribute(parent, + data_types.ClusterId(0xE001), data_types.AttributeId(0xD020)):to_endpoint(2) }) + test.socket.device_lifecycle:__queue_receive(children[2]:generate_info_changed({ + preferences = { switchMode = "scene" } + })) + test.wait_for_events() + receive_relay_report(parent, nil, 2, false) + receive_relay_report(parent, children[1], 1, false) + children[2]:expect_metadata_update({ profile = "ts0601-scene-switch-child-scene" }) + expect_parent_switch(parent, "off") + receive_mode_report(parent, 2, "scene") +end) + +test.register_coroutine_test("Returning to switch discards an old on state until a fresh report", function() + for ep = 1, 4 do + receive_relay_report(parent, children[ep], ep, ep == 1, ep == 1 and "on" or nil) + end + children[1]:expect_metadata_update({ profile = "ts0601-scene-switch-child-scene" }) + expect_parent_switch(parent, "off") + receive_mode_report(parent, 1, "scene") + test.socket.zigbee:__expect_send({ parent.id, OnOff.attributes.OnOff:read(parent):to_endpoint(1) }) + receive_mode_report(parent, 1, "switch") + receive_relay_report(parent, children[3], 3, false) + receive_relay_report(parent, children[1], 1, true, "on") +end) + +test.register_coroutine_test("Returning to switch cannot reuse an old off state to declare all off", function() + for ep = 1, 4 do + receive_relay_report(parent, children[ep], ep, false, ep == 4 and "off" or nil) + end + children[1]:expect_metadata_update({ profile = "ts0601-scene-switch-child-scene" }) + receive_mode_report(parent, 1, "scene") + test.socket.zigbee:__expect_send({ parent.id, OnOff.attributes.OnOff:read(parent):to_endpoint(1) }) + receive_mode_report(parent, 1, "switch") + receive_relay_report(parent, children[2], 2, true, "on") + receive_relay_report(parent, children[2], 2, false) + receive_relay_report(parent, children[1], 1, false, "off") +end) + +test.register_coroutine_test("All-scene mode reports establish parent off without any synthetic press", function() + expect_button_metadata(scene_children[1]) + expect_parent_switch(scene_parent, "off") + receive_mode_report(scene_parent, 1, "scene") + expect_button_metadata(scene_children[2]) + receive_mode_report(scene_parent, 2, "scene") +end) + +test.register_coroutine_test("Scene-to-switch report requests real state before the child profile finishes", function() + scene_children[3]:expect_metadata_update({ profile = "ts0601-scene-switch-child" }) + expect_button_metadata(scene_children[3]) + test.socket.zigbee:__expect_send({ scene_parent.id, OnOff.attributes.OnOff:read(scene_parent):to_endpoint(3) }) + receive_mode_report(scene_parent, 3, "switch") + receive_relay_report(scene_parent, nil, 3, true, "on") +end) + +test.register_coroutine_test("Repeated physical scene presses generate fresh child and parent events", function() + for _ = 1, 2 do + expect_button_metadata(scene_children[4]) + for _, target in ipairs({ scene_children[4], scene_parent }) do + test.socket.capability:__expect_send(target:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + end + test.socket.zigbee:__queue_receive({ scene_parent.id, + zb_utils.build_custom_command_id(scene_parent, 0x0006, 0xFD, nil, "", 4) }) + test.wait_for_events() + end +end) + +test.register_coroutine_test("Physical scene press reaches parent while the child profile update is pending", function() + children[2]:expect_metadata_update({ profile = "ts0601-scene-switch-child-scene" }) + receive_mode_report(parent, 2, "scene") + test.socket.capability:__expect_send(parent:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + test.socket.zigbee:__queue_receive({ parent.id, + zb_utils.build_custom_command_id(parent, 0x0006, 0xFD, nil, "", 2) }) +end) + +test.register_coroutine_test("Physical scene press reaches parent while the child device is unavailable", function() + local actual = rawget(scene_parent, "wrapped_device") + local get_child = actual.get_child_by_parent_assigned_key + local original = rawget(actual, "get_child_by_parent_assigned_key") + rawset(actual, "get_child_by_parent_assigned_key", function(self, key) + if key == "gang4" then return nil end + return get_child(self, key) + end) + test.socket.capability:__expect_send(scene_parent:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + test.socket.zigbee:__queue_receive({ scene_parent.id, + zb_utils.build_custom_command_id(scene_parent, 0x0006, 0xFD, nil, "", 4) }) + test.wait_for_events() + rawset(actual, "get_child_by_parent_assigned_key", original) +end) + +test.register_coroutine_test("Unknown scene endpoint cannot generate a parent button event", function() + test.socket.zigbee:__queue_receive({ scene_parent.id, + zb_utils.build_custom_command_id(scene_parent, 0x0006, 0xFD, nil, "", 5) }) +end) + +local function request_gang_mode(device, child, gang, mode) + local capability = mode == "scene" and capabilities.button or capabilities.switch + if child:supports_capability(capabilities.button) then expect_button_metadata(child) end + if not child:supports_capability(capability) then + child:expect_metadata_update({ profile = mode == "scene" + and "ts0601-scene-switch-child-scene" or "ts0601-scene-switch-child" }) + end + test.socket.zigbee:__expect_send({ device.id, cluster_base.write_attribute(device, + data_types.ClusterId(0xE001), data_types.AttributeId(0xD020), + data_types.Enum8(mode == "scene" and 1 or 0)):to_endpoint(gang) }) + test.socket.zigbee:__expect_send({ device.id, cluster_base.read_attribute(device, + data_types.ClusterId(0xE001), data_types.AttributeId(0xD020)):to_endpoint(gang) }) + test.socket.device_lifecycle:__queue_receive(child:generate_info_changed({ + preferences = { switchMode = mode } + })) + test.wait_for_events() +end + +for _, on in ipairs({ true, false }) do + local command_name = on and "on" or "off" + test.register_coroutine_test("Parent and child " .. command_name .. " skip a gang with an unconfirmed scene request", function() + request_gang_mode(parent, children[2], 2, "scene") + for _, ep in ipairs({ 1, 3, 4 }) do + test.socket.zigbee:__expect_send({ parent.id, + (on and OnOff.server.commands.On(parent) or OnOff.server.commands.Off(parent)):to_endpoint(ep) }) + end + test.socket.capability:__queue_receive({ parent.id, + { capability = "switch", component = "main", command = command_name, args = {} } }) + test.socket.capability:__queue_receive({ children[2].id, + { capability = "switch", component = "main", command = command_name, args = {} } }) + test.socket.zigbee:__queue_receive({ parent.id, + zb_utils.build_custom_command_id(parent, 0x0006, 0xFD, nil, "", 2) }) + end) +end + +test.register_coroutine_test("Rejected scene request restores the switch profile and waits for fresh relay state", function() + for ep = 1, 4 do + receive_relay_report(parent, children[ep], ep, ep == 2, ep == 2 and "on" or nil) + end + request_gang_mode(parent, children[2], 2, "scene") + -- The cloud may have applied the requested profile before the device reports its actual mode. + local device_info = utils.deep_copy(children[2].raw_st_data) + device_info.preferences.switchMode = "scene" + device_info.profile = utils.deep_copy(scene_children[2].raw_st_data.profile) + expect_button_metadata(children[2]) + test.socket.device_lifecycle:__queue_receive({ children[2].id, "infoChanged", json.encode(device_info) }) + test.wait_for_events() + receive_relay_report(parent, nil, 2, false) + children[2]:expect_metadata_update({ profile = "ts0601-scene-switch-child" }) + expect_button_metadata(children[2]) + test.socket.zigbee:__expect_send({ parent.id, OnOff.attributes.OnOff:read(parent):to_endpoint(2) }) + receive_mode_report(parent, 2, "switch") + receive_relay_report(parent, children[1], 1, false) + receive_relay_report(parent, nil, 2, false, "off") +end) + +test.register_coroutine_test("Unconfirmed return to switch blocks control and presses until actual mode readback", function() + request_gang_mode(scene_parent, scene_children[2], 2, "switch") + expect_button_metadata(scene_children[1]) + receive_mode_report(scene_parent, 1, "scene") + test.socket.capability:__queue_receive({ scene_parent.id, + { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.capability:__queue_receive({ scene_children[2].id, + { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.zigbee:__queue_receive({ scene_parent.id, + zb_utils.build_custom_command_id(scene_parent, 0x0006, 0xFD, nil, "", 2) }) + test.wait_for_events() + receive_relay_report(scene_parent, nil, 2, true) + scene_children[2]:expect_metadata_update({ profile = "ts0601-scene-switch-child" }) + expect_button_metadata(scene_children[2]) + test.socket.zigbee:__expect_send({ scene_parent.id, OnOff.attributes.OnOff:read(scene_parent):to_endpoint(2) }) + receive_mode_report(scene_parent, 2, "switch") + receive_relay_report(scene_parent, nil, 2, true, "on") +end) + +test.register_coroutine_test("Reinitializing during a pending mode change retries actual mode readback", function() + request_gang_mode(parent, children[2], 2, "scene") + assert(rawget(parent, "wrapped_device").persistent_store.pending_mode_gang_2 == "scene", + "Pending mode must survive a driver restart") + expect_button_metadata(parent) + children[2]:expect_metadata_update({ profile = "ts0601-scene-switch-child-scene" }) + test.socket.zigbee:__expect_send({ parent.id, cluster_base.read_attribute(parent, + data_types.ClusterId(0xE001), data_types.AttributeId(0xD020)):to_endpoint(2) }) + test.socket.device_lifecycle:__queue_receive({ parent.id, "init" }) + test.wait_for_events() + test.socket.zigbee:__expect_send({ parent.id, OnOff.attributes.OnOff:read(parent):to_endpoint(2) }) + receive_mode_report(parent, 2, "switch") + receive_relay_report(parent, children[2], 2, true, "on") +end) + +test.register_coroutine_test("Late profile completion after a rejected mode request is corrected to actual mode", function() + request_gang_mode(parent, children[2], 2, "scene") + test.socket.zigbee:__expect_send({ parent.id, OnOff.attributes.OnOff:read(parent):to_endpoint(2) }) + receive_mode_report(parent, 2, "switch") + local device_info = utils.deep_copy(children[2].raw_st_data) + device_info.preferences.switchMode = "scene" + device_info.profile = utils.deep_copy(scene_children[2].raw_st_data.profile) + expect_button_metadata(children[2]) + children[2]:expect_metadata_update({ profile = "ts0601-scene-switch-child" }) + test.socket.device_lifecycle:__queue_receive({ children[2].id, "infoChanged", json.encode(device_info) }) + test.wait_for_events() + receive_relay_report(parent, nil, 2, true, "on") +end) + +local legacy_profile = t_utils.get_profile_definition(PARENT_PROFILE) +legacy_profile.components.main.capabilities = { { id = "refresh", version = 1 } } +local legacy_parent = make_parent("Existing refresh-only parent", legacy_profile) +local legacy_children = make_children(legacy_parent, SWITCH_PROFILE) + +local function initialize_legacy_parent() + test.mock_device.add_test_device(legacy_parent) + legacy_parent:expect_metadata_update({ profile = "ts0601-scene-switch-4-gang" }) + for ep = 1, 4 do test.mock_device.add_test_device(legacy_children[ep]) end +end + +test.register_coroutine_test("Existing refresh-only parent requests the updated profile without recreating children", function() end, + { test_init = initialize_legacy_parent }) + +test.register_coroutine_test("Parent profile migration initializes metadata and reads real state without a press", function() + local device_info = utils.deep_copy(legacy_parent.raw_st_data) + device_info.profile = utils.deep_copy(parent.raw_st_data.profile) + expect_button_metadata(legacy_parent) + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_reads(legacy_parent, false) + test.socket.device_lifecycle:__queue_receive({ legacy_parent.id, "infoChanged", json.encode(device_info) }) +end, { test_init = initialize_legacy_parent }) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua index 7217e318d3..ab4e6addaf 100644 --- a/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua @@ -14,6 +14,7 @@ local read_attribute = require "st.zigbee.zcl.global_commands.read_attribute" local default_response = require "st.zigbee.zcl.global_commands.default_response" local OnOff = clusters.OnOff +local PARENT_PROFILE = "ts0601-scene-switch-4-gang" local SWITCH_PROFILE = "ts0601-scene-switch-child" local SCENE_PROFILE = "ts0601-scene-switch-child-scene" local MODE_CLUSTER = 0xE001 @@ -45,6 +46,10 @@ local function mode_field(gang) return string.format("mode_gang_%d", gang) end +local function pending_mode_field(gang) + return string.format("pending_mode_gang_%d", gang) +end + local function gang_mode(parent, gang) local mode = parent:get_field(mode_field(gang)) if mode ~= nil then return mode end @@ -56,10 +61,60 @@ local function gang_mode(parent, gang) return "switch" end +local function switch_state_field(gang) + return string.format("switch_state_gang_%d", gang) +end + +local function update_parent_switch(parent) + if not parent:supports_capability(capabilities.switch) then return end + local state = "off" + local unknown = false + for gang = 1, 4 do + if parent:get_field(pending_mode_field(gang)) ~= nil then + unknown = true + elseif gang_mode(parent, gang) ~= "scene" then + local gang_state = parent:get_field(switch_state_field(gang)) + if gang_state == "on" then + state = "on" + break + end + if gang_state == nil then unknown = true end + end + end + -- An unknown relay cannot prove all relays are off. With no relay-mode gangs, + -- the aggregate is off and parent commands have no relay targets. + if state == "off" and unknown then return end + if parent:get_field("aggregate_switch_state") ~= state then + parent:emit_event(capabilities.switch.switch(state)) + parent:set_field("aggregate_switch_state", state) + end +end + +local function set_gang_mode(parent, gang, mode) + local previous = gang_mode(parent, gang) + local pending = parent:get_field(pending_mode_field(gang)) + parent:set_field(mode_field(gang), mode, { persist = true }) + parent:set_field(pending_mode_field(gang), nil) + if previous ~= mode or pending ~= nil or mode == "scene" then + -- Relay state from the previous mode must not survive a mode transition. + parent:set_field(switch_state_field(gang), nil) + end + update_parent_switch(parent) + return previous ~= mode or pending ~= nil +end + local function profile_for_mode(mode) return mode == "scene" and SCENE_PROFILE or SWITCH_PROFILE end +local function ensure_parent_profile(parent) + if not parent:supports_capability(capabilities.switch) + or not parent:supports_capability(capabilities.button) then + -- Existing devices keep their cloud profile when a driver package updates. + parent:try_update_metadata({ profile = PARENT_PROFILE }) + end +end + local function initialize_button(child) if child:supports_capability(capabilities.button) then child:emit_event(capabilities.button.supportedButtonValues( @@ -81,17 +136,18 @@ local function ensure_children(driver, parent) for gang = 1, 4 do local child = get_child(parent, gang) local mode = gang_mode(parent, gang) + local profile_mode = parent:get_field(pending_mode_field(gang)) or mode if child == nil then driver:try_create_device({ type = "EDGE_CHILD", parent_device_id = parent.id, parent_assigned_child_key = string.format("gang%d", gang), label = string.format("%s L%d", parent.label or "Zemismart KES606", gang), - profile = profile_for_mode(mode), + profile = profile_for_mode(profile_mode), }) else parent:set_field(mode_field(gang), mode, { persist = true }) - set_child_profile(child, mode) + set_child_profile(child, profile_mode) end end end @@ -139,7 +195,7 @@ local function configure_enum(parent, cluster_id, attribute_id, gang) end local function read_gang(parent, gang) - if gang_mode(parent, gang) ~= "scene" then + if parent:get_field(pending_mode_field(gang)) == nil and gang_mode(parent, gang) ~= "scene" then parent:send(OnOff.attributes.OnOff:read(parent):to_endpoint(gang)) end read_enum(parent, MODE_CLUSTER, MODE_ATTRIBUTE, gang) @@ -161,20 +217,35 @@ end local function relay_command(device, on) local gang = child_gang(device) local parent = parent_from_device(device) - if gang == nil or parent == nil or gang_mode(parent, gang) == "scene" then return end - local command = on and OnOff.server.commands.On(parent) or OnOff.server.commands.Off(parent) - parent:send(command:to_endpoint(gang)) + if parent == nil then return end + local first, last = gang, gang + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + first, last = 1, 4 + elseif gang == nil then + return + end + for target = first, last do + if parent:get_field(pending_mode_field(target)) == nil and gang_mode(parent, target) ~= "scene" then + local command = on and OnOff.server.commands.On(parent) or OnOff.server.commands.Off(parent) + parent:send(command:to_endpoint(target)) + end + end end local function onoff_report(_, device, value, zb_rx) local gang = endpoint_from_rx(zb_rx) local parent = parent_from_device(device) - if gang == nil or parent == nil or gang_mode(parent, gang) == "scene" then return end + if gang == nil or parent == nil or parent:get_field(pending_mode_field(gang)) ~= nil + or gang_mode(parent, gang) == "scene" then return end + local state = (value.value == true or value.value == 1) and "on" or "off" + -- Keep only states received during this driver lifetime, including reports + -- that arrive before an asynchronous child-profile update completes. + parent:set_field(switch_state_field(gang), state) local child = get_child(parent, gang) if child ~= nil and child:supports_capability(capabilities.switch) then - local state = (value.value == true or value.value == 1) and "on" or "off" child:emit_event(capabilities.switch.switch(state)) end + update_parent_switch(parent) end local function mode_report(_, device, value, zb_rx) @@ -182,12 +253,15 @@ local function mode_report(_, device, value, zb_rx) local parent = parent_from_device(device) if gang == nil or parent == nil or (value.value ~= 0 and value.value ~= 1) then return end local mode = value.value == 1 and "scene" or "switch" - parent:set_field(mode_field(gang), mode, { persist = true }) + local mode_changed = set_gang_mode(parent, gang, mode) local child = get_child(parent, gang) if child ~= nil then set_child_profile(child, mode) initialize_button(child) end + if mode_changed and mode == "switch" then + parent:send(OnOff.attributes.OnOff:read(parent):to_endpoint(gang)) + end end local function power_on_report(_, device, value, zb_rx) @@ -211,20 +285,29 @@ end local function scene_command(_, device, zb_rx) local gang = endpoint_from_rx(zb_rx) local parent = parent_from_device(device) - if gang == nil or parent == nil or gang_mode(parent, gang) ~= "scene" then return end + if gang == nil or parent == nil or parent:get_field(pending_mode_field(gang)) ~= nil + or gang_mode(parent, gang) ~= "scene" then return end local child = get_child(parent, gang) if child ~= nil and child:supports_capability(capabilities.button) then initialize_button(child) child:emit_event(capabilities.button.button.pushed({ state_change = true })) end + if parent:supports_capability(capabilities.button) then + parent:emit_event(capabilities.button.button.pushed({ state_change = true })) + end end local function initialize(driver, device) if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + ensure_parent_profile(device) ensure_children(driver, device) - else - initialize_button(device) + for gang = 1, 4 do + if device:get_field(pending_mode_field(gang)) ~= nil then + read_enum(device, MODE_CLUSTER, MODE_ATTRIBUTE, gang) + end + end end + initialize_button(device) end local function do_configure(driver, device) @@ -246,8 +329,10 @@ local function do_configure(driver, device) end local function driver_switched(driver, device) - -- The root driver's capability check does not include this sub-driver's refresh/button pair. + -- The root driver's capability check does not include this sub-driver's button capability. if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + ensure_parent_profile(device) + initialize_button(device) do_configure(driver, device) else initialize_button(device) @@ -278,9 +363,29 @@ local function update_parent_preferences(device, old_preferences) end end +local function profile_has_capability(profile, capability) + local main = profile and profile.components and profile.components.main + local supported = main and main.capabilities or {} + if supported[capability.ID] ~= nil then return true end + -- API 19 and API 21 can deliver capability maps and arrays respectively. + for _, entry in pairs(supported) do + if entry.id == capability.ID then return true end + end + return false +end + local function info_changed(_, device, _, args) local old_preferences = args and args.old_st_store and args.old_st_store.preferences or {} + local old_profile = args and args.old_st_store and args.old_st_store.profile if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + initialize_button(device) + if old_profile ~= nil and (not profile_has_capability(old_profile, capabilities.switch) + or not profile_has_capability(old_profile, capabilities.button)) + and device:supports_capability(capabilities.switch) + and device:supports_capability(capabilities.button) then + update_parent_switch(device) + for gang = 1, 4 do read_gang(device, gang) end + end update_parent_preferences(device, old_preferences) return end @@ -290,27 +395,24 @@ local function info_changed(_, device, _, args) local preferences = device.preferences or {} -- A profile update is asynchronous; initialize its button metadata when it arrives. initialize_button(device) - local old_profile = args and args.old_st_store and args.old_st_store.profile local old_main = old_profile and old_profile.components and old_profile.components.main - local old_switch = old_main and old_main.capabilities and old_main.capabilities.switch - if old_switch == nil and old_main ~= nil then - for _, capability in pairs(old_main.capabilities or {}) do - if capability.id == capabilities.switch.ID then - old_switch = capability - break - end - end - end - if old_main ~= nil and old_switch == nil and device:supports_capability(capabilities.switch) then + if old_main ~= nil and not profile_has_capability(old_profile, capabilities.switch) + and device:supports_capability(capabilities.switch) + and parent:get_field(pending_mode_field(gang)) == nil then -- A relay report may have arrived while the previous button profile was still active. parent:send(OnOff.attributes.OnOff:read(parent):to_endpoint(gang)) end local mode = preferences.switchMode if changed(preferences, old_preferences, "switchMode") and (mode == "switch" or mode == "scene") then - parent:set_field(mode_field(gang), mode, { persist = true }) - set_child_profile(device, mode) + -- A desired mode is not proof that the device accepted it. Freeze the + -- previous actual mode before the asynchronous child profile can change. + parent:set_field(mode_field(gang), gang_mode(parent, gang), { persist = true }) + parent:set_field(pending_mode_field(gang), mode, { persist = true }) + parent:set_field(switch_state_field(gang), nil) + update_parent_switch(parent) write_enum(parent, MODE_CLUSTER, MODE_ATTRIBUTE, gang, mode == "scene" and 1 or 0) + read_enum(parent, MODE_CLUSTER, MODE_ATTRIBUTE, gang) end if changed(preferences, old_preferences, "countdownSeconds") then local seconds = tonumber(preferences.countdownSeconds) @@ -322,6 +424,8 @@ local function info_changed(_, device, _, args) parent:set_field(string.format("countdown_gang_%d", gang), seconds, { persist = true }) end end + -- A requested profile can arrive after its mode write was rejected/read back. + set_child_profile(device, parent:get_field(pending_mode_field(gang)) or gang_mode(parent, gang)) end return { @@ -350,7 +454,7 @@ return { [OnOff.ID] = { [default_response.DefaultResponse.ID] = function() -- Relay state comes from attribute reports, not command acknowledgements. - -- The generic handler targets the display-only parent and registers native handling. + -- The generic handler would target only the parent and register native handling. end, }, }, From 09a46554713f6c093d1fdda2a333ce5a10856103 Mon Sep 17 00:00:00 2001 From: zemismart-dev <275927643+zemismart-dev@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:14:54 +0800 Subject: [PATCH 6/7] Address KES606 matcher and nested driver review --- .../zigbee-switch/src/sub_drivers.lua | 1 - .../src/test/test_tuya_multi.lua | 111 ++++++++++++++++++ .../src/test/test_zemismart_kes606.lua | 57 ++++++++- .../src/tuya-multi/can_handle.lua | 20 +++- .../zigbee-switch/src/tuya-multi/init.lua | 3 +- .../src/tuya-multi/sub_drivers.lua | 8 ++ .../src/zemismart-kes606/can_handle.lua | 15 +-- .../src/zemismart-kes606/init.lua | 2 + 8 files changed, 194 insertions(+), 23 deletions(-) create mode 100644 drivers/SmartThings/zigbee-switch/src/tuya-multi/sub_drivers.lua diff --git a/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua b/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua index 95da3d18e7..60390d8074 100644 --- a/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua @@ -32,7 +32,6 @@ return { lazy_load_if_possible("wallhero"), lazy_load_if_possible("inovelli"), -- Combined driver for both VZM31-SN and VZM32-SN lazy_load_if_possible("laisiao"), - lazy_load_if_possible("zemismart-kes606"), lazy_load_if_possible("tuya-multi"), lazy_load_if_possible("frient"), lazy_load_if_possible("frient-IO"), diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua b/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua index 7754d6281e..c9f5375495 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua @@ -7,6 +7,7 @@ local BasicCluster = clusters.Basic local OnOffCluster = clusters.OnOff local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local capabilities = require "st.capabilities" local profile = t_utils.get_profile_definition("basic-switch.yml") @@ -27,16 +28,126 @@ local mock_device = test.mock_device.build_test_zigbee_device({ }, fingerprinted_endpoint_id = 0x01 }) +local mock_child = test.mock_device.build_test_child_device({ + profile = profile, + parent_device_id = mock_device.id, + parent_assigned_child_key = "02", + label = "Generic Tuya second endpoint", +}) zigbee_test_utils.prepare_zigbee_env_info() local function test_init() mock_device:set_field("_configuration_version", 1, {persist = true}) test.mock_device.add_test_device(mock_device) + mock_child:set_field("_configuration_version", 1, {persist = true}) + test.mock_device.add_test_device(mock_child) end test.set_test_init_function(test_init) +test.register_coroutine_test("Other Tuya switches retain their generic lifecycle handler", function() + local driver = test.driver_wrapper.driver_under_test + local dispatchers = driver.lifecycle_dispatcher:find_child_dispatchers(driver, mock_device, "doConfigure") + assert(#dispatchers == 1 and dispatchers[1].name == "tuya-multi") + assert(#dispatchers[1]:find_child_dispatchers(driver, mock_device, "doConfigure") == 0, + "Generic Tuya devices must not enter the KES606 sub-driver") + assert(not require("zemismart-kes606.can_handle")({}, driver, mock_device)) +end) + +test.register_message_test("Other Tuya switches retain standard switch commands and reports", { + { channel = "capability", direction = "receive", message = { + mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } + } }, + { channel = "zigbee", direction = "send", message = { + mock_device.id, OnOffCluster.server.commands.On(mock_device) + } }, + { channel = "devices", direction = "send", message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } + } }, + { channel = "zigbee", direction = "receive", message = { + mock_device.id, OnOffCluster.attributes.OnOff:build_test_attr_report(mock_device, true) + } }, + { channel = "capability", direction = "send", message = + mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + { channel = "devices", direction = "send", message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } }, +}) + +test.register_coroutine_test("Generic Tuya children configure through the root without the Tuya magic packet", function() + local driver = test.driver_wrapper.driver_under_test + assert(not require("tuya-multi.can_handle")({}, driver, mock_child)) + assert(#driver.lifecycle_dispatcher:find_child_dispatchers(driver, mock_child, "doConfigure") == 0) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOffCluster.attributes.OnOff:read(mock_device):to_endpoint(0x02) }) + mock_child:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ mock_child.id, "doConfigure" }) +end) + +test.register_coroutine_test("Generic Tuya children retain the root driver-switch decision", function() + local driver = test.driver_wrapper.driver_under_test + assert(#driver.lifecycle_dispatcher:find_child_dispatchers(driver, mock_child, "driverSwitched") == 0) + mock_child:expect_metadata_update({ provisioning_state = "NONFUNCTIONAL" }) + test.socket.device_lifecycle:__queue_receive({ mock_child.id, "driverSwitched" }) +end) + +test.register_coroutine_test("Generic Tuya child commands and reports retain their own endpoint", function() + mock_child:expect_native_cmd_handler_registration("switch", "on") + test.socket.zigbee:__expect_send({ mock_device.id, + OnOffCluster.server.commands.On(mock_device):to_endpoint(0x02) }) + test.socket.capability:__queue_receive({ mock_child.id, + { capability = "switch", component = "main", command = "on", args = {} } }) + test.wait_for_events() + mock_device:expect_native_attr_handler_registration("switch", "switch") + test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.switch.switch.on())) + test.socket.zigbee:__queue_receive({ mock_device.id, + OnOffCluster.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x02) }) +end) + +for _, endpoint_ids in ipairs({ { 1, 4 }, { 1 } }) do + local endpoints = {} + for _, ep in ipairs(endpoint_ids) do + endpoints[ep] = { id = ep, manufacturer = "_TZ123fas", server_clusters = { 0x0006 } } + end + local sample = test.mock_device.build_test_zigbee_device({ + profile = profile, fingerprinted_endpoint_id = 1, zigbee_endpoints = endpoints, + }) + local is_multi = #endpoint_ids > 1 + local description = is_multi + and "Sparse Tuya endpoints 1 and 4 both configure through the generic Tuya handler" + or "Single-endpoint Tuya device retains root configuration without a magic packet" + test.register_coroutine_test(description, function() + local driver = test.driver_wrapper.driver_under_test + local actual = driver:get_device_info(sample.id) + assert((require("tuya-multi.can_handle")({}, driver, actual)) == is_multi) + assert(actual.zigbee_endpoints[2] == nil, "The test must preserve the gap in the SDK endpoint map") + test.socket.zigbee:__set_channel_ordering("relaxed") + for _, ep in ipairs(endpoint_ids) do + test.socket.zigbee:__expect_send({ sample.id, OnOffCluster.attributes.OnOff:read(sample):to_endpoint(ep) }) + test.socket.zigbee:__expect_send({ sample.id, + OnOffCluster.attributes.OnOff:configure_reporting(sample, 0, 300):to_endpoint(ep) }) + test.socket.zigbee:__expect_send({ sample.id, + zigbee_test_utils.build_bind_request(sample, zigbee_test_utils.mock_hub_eui, + OnOffCluster.ID, ep):to_endpoint(ep) }) + end + if is_multi then + test.socket.zigbee:__expect_send({ sample.id, + zigbee_test_utils.build_attribute_read(sample, BasicCluster.ID, + { 0x0004, 0x0000, 0x0001, 0x0005, 0x0007, 0xfffe }) }) + end + sample:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ sample.id, "doConfigure" }) + end, { + test_init = function() + sample:set_field("_configuration_version", 1, {persist = true}) + test.mock_device.add_test_device(sample) + end, + }) +end + test.register_coroutine_test( "lifecycle configure event should configure device", function () diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua index 7d79295e13..208b6c4b55 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua @@ -63,7 +63,7 @@ local function expect_button_metadata(child) capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }))) end -test.set_test_init_function(function() +local function initialize_devices() test.mock_device.add_test_device(parent) test.mock_device.add_test_device(scene_parent) expect_button_metadata(parent) @@ -74,7 +74,8 @@ test.set_test_init_function(function() test.mock_device.add_test_device(scene_children[ep]) expect_button_metadata(scene_children[ep]) end -end) +end +test.set_test_init_function(initialize_devices) local function enum_write(attribute, value, ep, cluster) return cluster_base.write_attribute(parent, data_types.ClusterId(cluster or 0xE001), @@ -96,9 +97,59 @@ test.register_coroutine_test("Only the exact four-gang fingerprint uses the comp }) assert(not matches({}, nil, other_model)) assert(not matches({}, nil, nil)) - assert(not require("tuya-multi.can_handle")({}, nil, parent)) + assert(require("tuya-multi.can_handle")({}, nil, parent)) end) +local function assert_nested_dispatch(dispatcher, device, message) + local driver = test.driver_wrapper.driver_under_test + local tuya_dispatchers = dispatcher:find_child_dispatchers(driver, device, message) + assert(#tuya_dispatchers == 1 and tuya_dispatchers[1].name == "tuya-multi", + "KES606 must route through the generic Tuya parent exactly once") + local nested_dispatchers = tuya_dispatchers[1]:find_child_dispatchers(driver, device, message) + assert(#nested_dispatchers == 1 and nested_dispatchers[1].name == "zemismart-kes606", + "KES606 handlers must be nested under Tuya without a parallel root registration") + assert(nested_dispatchers[1].lazy_loaded, "Nested handlers must load when dispatching the device") +end + +test.register_coroutine_test("Parent and all four children dispatch through the nested Tuya handler", function() + local driver = test.driver_wrapper.driver_under_test + local command = { capability = "switch", component = "main", command = "on", args = {} } + for _, devices in ipairs({ { parent, scene_parent }, children, scene_children }) do + for _, device in ipairs(devices) do + assert(require("tuya-multi.can_handle")({}, driver, device)) + assert_nested_dispatch(driver.capability_dispatcher, device, command) + for _, lifecycle in ipairs({ "init", "added", "doConfigure", "driverSwitched", "infoChanged" }) do + assert_nested_dispatch(driver.lifecycle_dispatcher, device, lifecycle) + end + end + end +end) + +for _, child_key in ipairs({ "gang0", "gang5", "gang1-extra", "invalid" }) do + local malformed_child = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(SWITCH_PROFILE), + parent_device_id = parent.id, parent_assigned_child_key = child_key, + label = "Malformed KES606 child " .. child_key, + }) + test.register_coroutine_test("Malformed child key " .. child_key .. " stays isolated from generic relay handlers", function() + local driver = test.driver_wrapper.driver_under_test + assert(require("zemismart-kes606.can_handle")({}, driver, malformed_child)) + for _, command_name in ipairs({ "on", "off" }) do + local command = { capability = "switch", component = "main", command = command_name, args = {} } + assert_nested_dispatch(driver.capability_dispatcher, malformed_child, command) + test.socket.capability:__queue_receive({ malformed_child.id, command }) + end + test.socket.device_lifecycle:__queue_receive(malformed_child:generate_info_changed({ + preferences = { switchMode = "scene", countdownSeconds = 30 } + })) + end, { + test_init = function() + initialize_devices() + test.mock_device.add_test_device(malformed_child) + end, + }) +end + test.register_coroutine_test("Repeated parent initialization does not recreate children", function() expect_button_metadata(parent) test.socket.device_lifecycle:__queue_receive({ parent.id, "init" }) diff --git a/drivers/SmartThings/zigbee-switch/src/tuya-multi/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/tuya-multi/can_handle.lua index 34884e07f4..66c0975408 100644 --- a/drivers/SmartThings/zigbee-switch/src/tuya-multi/can_handle.lua +++ b/drivers/SmartThings/zigbee-switch/src/tuya-multi/can_handle.lua @@ -1,9 +1,11 @@ --- Copyright 2025 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local device_lib = require "st.device" + local function is_multi_endpoint(device) local main_endpoint = device:get_endpoint(0x0006) - for _, ep in ipairs(device.zigbee_endpoints) do + for _, ep in pairs(device.zigbee_endpoints) do if ep.id ~= main_endpoint then return true end @@ -12,12 +14,20 @@ local function is_multi_endpoint(device) end return function(opts, driver, device) - -- The composite relay/scene device has its own configure and refresh handlers. - if device:get_manufacturer() == "_TZ3000_hurauima" and device:get_model() == "TS0726" then + if device == nil then return false end + local parent = device + if device.network_type == device_lib.NETWORK_TYPE_CHILD then + parent = device:get_parent_device() + end + if parent == nil or parent.zigbee_endpoints == nil then return false end + if device.network_type == device_lib.NETWORK_TYPE_CHILD + and not require("zemismart-kes606.can_handle")(opts, driver, device) then return false end + local TUYA_MFR_HEADER = "_TZ" - if string.sub(device:get_manufacturer(),1,3) == TUYA_MFR_HEADER and is_multi_endpoint(device) then -- if it is a tuya device, then send the magic packet + local manufacturer = parent:get_manufacturer() + if manufacturer ~= nil and string.sub(manufacturer,1,3) == TUYA_MFR_HEADER and is_multi_endpoint(parent) then -- if it is a tuya device, then send the magic packet local subdriver = require("tuya-multi") return true, subdriver end diff --git a/drivers/SmartThings/zigbee-switch/src/tuya-multi/init.lua b/drivers/SmartThings/zigbee-switch/src/tuya-multi/init.lua index 51c91ad768..1139efd306 100644 --- a/drivers/SmartThings/zigbee-switch/src/tuya-multi/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/tuya-multi/init.lua @@ -1,4 +1,4 @@ --- Copyright 2025 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" @@ -48,6 +48,7 @@ local tuya_switch_handler = { supported_capabilities = { capabilities.switch }, + sub_drivers = require("tuya-multi.sub_drivers"), can_handle = require("tuya-multi.can_handle"), } diff --git a/drivers/SmartThings/zigbee-switch/src/tuya-multi/sub_drivers.lua b/drivers/SmartThings/zigbee-switch/src/tuya-multi/sub_drivers.lua new file mode 100644 index 0000000000..4531808b5b --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/tuya-multi/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load = require "lazy_load_subdriver" + +return { + lazy_load("zemismart-kes606"), +} diff --git a/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/can_handle.lua index d09709ba87..c7fafd7afe 100644 --- a/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/can_handle.lua +++ b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/can_handle.lua @@ -1,20 +1,9 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local device_lib = require "st.device" - return function(_, _, device) - if device == nil then return false end - local parent = device - if device.network_type == device_lib.NETWORK_TYPE_CHILD then - local gang = tonumber((device.parent_assigned_child_key or ""):match("^gang([1-4])$")) - if gang == nil then return false end - parent = device:get_parent_device() - elseif device.network_type ~= device_lib.NETWORK_TYPE_ZIGBEE then - return false - end - if parent ~= nil and parent:get_manufacturer() == "_TZ3000_hurauima" - and parent:get_model() == "TS0726" then + if device ~= nil and device:get_manufacturer() == "_TZ3000_hurauima" + and device:get_model() == "TS0726" then return true, require("zemismart-kes606") end return false diff --git a/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua index ab4e6addaf..251e07c605 100644 --- a/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua @@ -51,6 +51,8 @@ local function pending_mode_field(gang) end local function gang_mode(parent, gang) + -- Child profile changes are asynchronous. Prefer the tracked mode, updated + -- by device readback; capabilities are only a fallback when it is not stored. local mode = parent:get_field(mode_field(gang)) if mode ~= nil then return mode end local child = get_child(parent, gang) From 5fd6d98f3b0afa91c0981a1b5ae45aee068e6233 Mon Sep 17 00:00:00 2001 From: zemismart-dev <275927643+zemismart-dev@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:54:40 +0800 Subject: [PATCH 7/7] Fix KES606 dispatcher assertions on legacy Edge SDKs --- .../zigbee-switch/src/test/test_tuya_multi.lua | 4 +++- .../zigbee-switch/src/test/test_zemismart_kes606.lua | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua b/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua index c9f5375495..5d43cda43e 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua @@ -2,6 +2,7 @@ -- Licensed under the Apache License, Version 2.0 local test = require "integration_test" +local version = require "version" local clusters = require "st.zigbee.zcl.clusters" local BasicCluster = clusters.Basic local OnOffCluster = clusters.OnOff @@ -48,8 +49,9 @@ test.set_test_init_function(test_init) test.register_coroutine_test("Other Tuya switches retain their generic lifecycle handler", function() local driver = test.driver_wrapper.driver_under_test + local tuya_name = version.api >= 16 and "tuya-multi" or require("tuya-multi").NAME local dispatchers = driver.lifecycle_dispatcher:find_child_dispatchers(driver, mock_device, "doConfigure") - assert(#dispatchers == 1 and dispatchers[1].name == "tuya-multi") + assert(#dispatchers == 1 and dispatchers[1].name == tuya_name) assert(#dispatchers[1]:find_child_dispatchers(driver, mock_device, "doConfigure") == 0, "Generic Tuya devices must not enter the KES606 sub-driver") assert(not require("zemismart-kes606.can_handle")({}, driver, mock_device)) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua index 208b6c4b55..2b70cb9323 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua @@ -2,6 +2,7 @@ -- Licensed under the Apache License, Version 2.0 local test = require "integration_test" +local version = require "version" local t_utils = require "integration_test.utils" local zb_utils = require "integration_test.zigbee_test_utils" local capabilities = require "st.capabilities" @@ -102,11 +103,14 @@ end) local function assert_nested_dispatch(dispatcher, device, message) local driver = test.driver_wrapper.driver_under_test + -- Legacy lazy loading retains the template NAME; API 16+ uses module paths. + local tuya_name = version.api >= 16 and "tuya-multi" or require("tuya-multi").NAME + local kes606_name = version.api >= 16 and "zemismart-kes606" or require("zemismart-kes606").NAME local tuya_dispatchers = dispatcher:find_child_dispatchers(driver, device, message) - assert(#tuya_dispatchers == 1 and tuya_dispatchers[1].name == "tuya-multi", + assert(#tuya_dispatchers == 1 and tuya_dispatchers[1].name == tuya_name, "KES606 must route through the generic Tuya parent exactly once") local nested_dispatchers = tuya_dispatchers[1]:find_child_dispatchers(driver, device, message) - assert(#nested_dispatchers == 1 and nested_dispatchers[1].name == "zemismart-kes606", + assert(#nested_dispatchers == 1 and nested_dispatchers[1].name == kes606_name, "KES606 handlers must be nested under Tuya without a parallel root registration") assert(nested_dispatchers[1].lazy_loaded, "Nested handlers must load when dispatching the device") end