diff --git a/drivers/SmartThings/zigbee-switch/fingerprints.yml b/drivers/SmartThings/zigbee-switch/fingerprints.yml index bb2ce5f027..3804743f05 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: scene-switch-4-gang #SMARTvill - id: "SMARTvill/SLA06" deviceLabel: "SMARTvill Switch 1" diff --git a/drivers/SmartThings/zigbee-switch/profiles/scene-switch-4-gang.yml b/drivers/SmartThings/zigbee-switch/profiles/scene-switch-4-gang.yml new file mode 100644 index 0000000000..acd6317a6f --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/scene-switch-4-gang.yml @@ -0,0 +1,73 @@ +name: scene-switch-4-gang +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: button + version: 1 + - 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/scene-switch-child-scene.yml b/drivers/SmartThings/zigbee-switch/profiles/scene-switch-child-scene.yml new file mode 100644 index 0000000000..c716d0b934 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/scene-switch-child-scene.yml @@ -0,0 +1,19 @@ +name: 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/scene-switch-child.yml b/drivers/SmartThings/zigbee-switch/profiles/scene-switch-child.yml new file mode 100644 index 0000000000..f281d15056 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/scene-switch-child.yml @@ -0,0 +1,30 @@ +name: 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/test/test_tuya_multi.lua b/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua index 7754d6281e..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,11 +2,13 @@ -- 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 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 +29,127 @@ 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 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_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)) +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 new file mode 100644 index 0000000000..af3baaaae0 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zemismart_kes606.lua @@ -0,0 +1,567 @@ +-- Copyright 2026 SmartThings, Inc. +-- 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" +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 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 = "scene-switch-4-gang.yml" +local SWITCH_PROFILE = "scene-switch-child.yml" +local SCENE_PROFILE = "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 = string.format("%02X", 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(device) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }))) +end + +local function initialize_devices() + test.socket.capability:__set_channel_ordering("relaxed") + test.mock_device.add_test_device(parent) + test.mock_device.add_test_device(scene_parent) + expect_button_metadata(parent) + expect_button_metadata(scene_parent) + 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 + +test.set_test_init_function(initialize_devices) + +local function enum_read(device, attribute, ep, cluster) + return cluster_base.read_attribute(device, data_types.ClusterId(cluster or 0xE001), + data_types.AttributeId(attribute)):to_endpoint(ep) +end + +local function enum_write(device, attribute, value, ep, cluster) + return cluster_base.write_attribute(device, data_types.ClusterId(cluster or 0xE001), + data_types.AttributeId(attribute), data_types.Enum8(value)):to_endpoint(ep) +end + +local function expect_parent_switch(device, state) + test.socket.capability:__expect_send(device:generate_test_message("main", capabilities.switch.switch(state))) +end + +local function receive_mode_report(device, ep, 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(ep) }) + test.wait_for_events() +end + +local function confirm_switch_modes() + for ep = 1, 4 do + test.socket.zigbee:__expect_send({ parent.id, OnOff.attributes.OnOff:read(parent):to_endpoint(ep) }) + receive_mode_report(parent, ep, "switch") + end +end + +local function confirm_scene_modes() + for ep = 1, 4 do + if ep == 4 then expect_parent_switch(scene_parent, "off") end + receive_mode_report(scene_parent, ep, "scene") + end +end + +local function receive_relay_report(device, child, ep, on, aggregate) + if child then + test.socket.capability:__expect_send(child:generate_test_message("main", + capabilities.switch.switch(on and "on" or "off"))) + end + if aggregate then expect_parent_switch(device, aggregate) end + test.socket.zigbee:__queue_receive({ device.id, + OnOff.attributes.OnOff:build_test_attr_report(device, on):from_endpoint(ep) }) + test.wait_for_events() +end + +local function send_switch_command(device, name) + test.socket.capability:__queue_receive({ device.id, + { capability = "switch", component = "main", command = name, args = {} } }) +end + +local function expect_relay_command(device, ep, on) + test.socket.zigbee:__expect_send({ device.id, + (on and OnOff.server.commands.On(device) or OnOff.server.commands.Off(device)):to_endpoint(ep) }) +end + +local function expect_mode_request(device, ep, mode) + test.socket.zigbee:__expect_send({ device.id, enum_write(device, 0xD020, mode == "scene" and 1 or 0, ep) }) + test.socket.zigbee:__expect_send({ device.id, enum_read(device, 0xD020, ep) }) +end + +local function assert_nested_dispatch(dispatcher, device, message) + local driver = test.driver_wrapper.driver_under_test + 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_name) + local nested = tuya_dispatchers[1]:find_child_dispatchers(driver, device, message) + assert(#nested == 1 and nested[1].name == kes606_name, + "KES606 must dispatch exactly once through the nested Tuya handler") +end + +test.register_coroutine_test("Only the exact four-gang fingerprint and its children use KES606", function() + local matches = require "zemismart-kes606.can_handle" + assert(matches({}, nil, parent)) + for ep = 1, 4 do assert(matches({}, nil, children[ep])) end + for _, fingerprint in ipairs({ + { manufacturer = "_TZ3000_other", model = "TS0726" }, + { manufacturer = "_TZ3000_hurauima", model = "TS0004" }, + }) do + fingerprint.profile = t_utils.get_profile_definition(PARENT_PROFILE) + assert(not matches({}, nil, test.mock_device.build_test_zigbee_device(fingerprint))) + end + assert(not matches({}, nil, nil)) +end) + +test.register_coroutine_test("Parent and children dispatch through the nested Tuya handler", function() + local driver = test.driver_wrapper.driver_under_test + for _, devices in ipairs({ { parent, scene_parent }, children, scene_children }) do + for _, device in ipairs(devices) do + assert_nested_dispatch(driver.capability_dispatcher, device, + { capability = "switch", component = "main", command = "on", args = {} }) + assert_nested_dispatch(driver.lifecycle_dispatcher, device, "init") + end + end +end) + +test.register_coroutine_test("Unknown modes ignore commands and reports without declaring all off", function() + for _, device in ipairs({ parent, scene_parent, children[1], scene_children[1] }) do + send_switch_command(device, "on") + end + receive_relay_report(parent, nil, 1, true) + test.socket.zigbee:__queue_receive({ scene_parent.id, + zb_utils.build_custom_command_id(scene_parent, 0x0006, 0xFD, nil, "", 1) }) +end) + +for _, key in ipairs({ "00", "05", "01-extra", "gang1", "invalid" }) do + local malformed = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(SWITCH_PROFILE), parent_device_id = parent.id, + parent_assigned_child_key = key, label = "Malformed KES606 child " .. key, + }) + test.register_coroutine_test("Malformed child key " .. key .. " cannot target a relay", function() + confirm_switch_modes() + send_switch_command(malformed, "on") + send_switch_command(malformed, "off") + test.socket.device_lifecycle:__queue_receive(malformed:generate_info_changed({ + preferences = { switchMode = "scene", countdownSeconds = 30 } + })) + end, { test_init = function() + initialize_devices() + test.mock_device.add_test_device(malformed) + end }) +end + +test.register_coroutine_test("Added creates four children with standard endpoint keys", function() + test.socket.device_lifecycle:__queue_receive({ parent.id, "added" }) +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 = string.format("%02X", ep), label = parent.label .. " L" .. ep, + profile = "scene-switch-child" }) + end + end, +}) + +test.register_coroutine_test("Repeated initialization preserves children and does not synthesize presses", function() + for _ = 1, 2 do + expect_button_metadata(parent) + test.socket.device_lifecycle:__queue_receive({ parent.id, "init" }) + test.wait_for_events() + end + 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 name = on and "on" or "off" + test.register_coroutine_test("Relay " .. ep .. " " .. name .. " uses the standard child endpoint", function() + confirm_switch_modes() + expect_relay_command(parent, ep, on) + send_switch_command(children[ep], name) + end) + test.register_coroutine_test("Relay report " .. ep .. " " .. name .. " updates its child and aggregate", function() + confirm_switch_modes() + receive_relay_report(parent, children[ep], ep, on, on and "on" or nil) + end) + end + + test.register_coroutine_test("Scene endpoint " .. ep .. " emits physical presses and ignores relay traffic", function() + confirm_scene_modes() + receive_relay_report(scene_parent, nil, ep, true) + send_switch_command(scene_children[ep], "on") + for _ = 1, 2 do + for _, device in ipairs({ scene_children[ep], scene_parent }) do + test.socket.capability:__expect_send(device: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, "", ep) }) + test.wait_for_events() + end + end) + + test.register_coroutine_test("Relay endpoint " .. ep .. " ignores scene commands", function() + confirm_switch_modes() + test.socket.zigbee:__queue_receive({ parent.id, + zb_utils.build_custom_command_id(parent, 0x0006, 0xFD, nil, "", ep) }) + end) + + test.register_coroutine_test("Mode preference " .. ep .. " waits for D020 before changing profile", function() + confirm_switch_modes() + expect_mode_request(parent, ep, "scene") + test.socket.device_lifecycle:__queue_receive(children[ep]:generate_info_changed({ + preferences = { switchMode = "scene" } + })) + test.wait_for_events() + expect_relay_command(parent, ep, true) + send_switch_command(children[ep], "on") + test.wait_for_events() + children[ep]:expect_metadata_update({ profile = "scene-switch-child-scene" }) + receive_mode_report(parent, ep, "scene") + send_switch_command(children[ep], "on") + end) + + test.register_coroutine_test("Countdown " .. ep .. " targets its 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 D020 values and endpoints cannot change profiles or press buttons", function() + for _, ep in ipairs({ 1, 5 }) do + test.socket.zigbee:__queue_receive({ parent.id, zb_utils.build_attribute_report(parent, + 0xE001, { { 0xD020, data_types.Enum8.ID, ep == 1 and 2 or 1 } }):from_endpoint(ep) }) + end + test.socket.zigbee:__queue_receive({ scene_parent.id, + zb_utils.build_custom_command_id(scene_parent, 0x0006, 0xFD, nil, "", 5) }) +end) + +test.register_coroutine_test("Indicator and power-on preferences retain their documented writes", function() + test.socket.zigbee:__expect_send({ parent.id, enum_write(parent, 0x8001, 2, 1, 0x0006) }) + test.socket.zigbee:__expect_send({ parent.id, enum_write(parent, 0x8002, 0, 1, 0x0006) }) + test.socket.zigbee:__expect_send({ parent.id, enum_write(parent, 0xD010, 1, 3) }) + test.socket.device_lifecycle:__queue_receive(parent:generate_info_changed({ + preferences = { lightMode = "pos", relayStatus = "off", relayStatus3 = "on" } + })) +end) + +for _, on in ipairs({ true, false }) do + local name = on and "on" or "off" + test.register_coroutine_test("Parent " .. name .. " controls all confirmed relay endpoints", function() + confirm_switch_modes() + for ep = 1, 4 do expect_relay_command(parent, ep, on) end + send_switch_command(parent, name) + end) + test.register_coroutine_test("Parent " .. name .. " excludes confirmed scene endpoints", function() + confirm_switch_modes() + for _, ep in ipairs({ 2, 4 }) do + children[ep]:expect_metadata_update({ profile = "scene-switch-child-scene" }) + receive_mode_report(parent, ep, "scene") + end + for _, ep in ipairs({ 1, 3 }) do expect_relay_command(parent, ep, on) end + send_switch_command(parent, name) + end) + test.register_coroutine_test("Parent " .. name .. " is inert with all scene endpoints", function() + confirm_scene_modes() + send_switch_command(scene_parent, name) + end) +end + +test.register_coroutine_test("Parent waits for unknown child states before all-off and deduplicates aggregate events", function() + confirm_switch_modes() + receive_relay_report(parent, children[3], 3, true, "on") + receive_relay_report(parent, children[3], 3, false) + for _, ep in ipairs({ 1, 4, 2 }) do + receive_relay_report(parent, children[ep], ep, false, ep == 2 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("Unknown mode prevents all-off even when confirmed relays are off", function() + for ep = 1, 3 do + test.socket.zigbee:__expect_send({ parent.id, OnOff.attributes.OnOff:read(parent):to_endpoint(ep) }) + receive_mode_report(parent, ep, "switch") + receive_relay_report(parent, children[ep], ep, false) + end + children[4]:expect_metadata_update({ profile = "scene-switch-child-scene" }) + expect_parent_switch(parent, "off") + receive_mode_report(parent, 4, "scene") +end) + +test.register_coroutine_test("Changing the only on relay to scene recomputes aggregate from remaining children", function() + confirm_switch_modes() + 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 = "scene-switch-child-scene" }) + expect_parent_switch(parent, "off") + receive_mode_report(parent, 2, "scene") + receive_relay_report(parent, nil, 2, true) + 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 presses still reach the parent when the child is unavailable", function() + confirm_scene_modes() + 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 == "04" 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("Reinitialization retains confirmed modes for parent routing", function() + confirm_switch_modes() + children[2]:expect_metadata_update({ profile = "scene-switch-child-scene" }) + receive_mode_report(parent, 2, "scene") + expect_button_metadata(parent) + test.socket.device_lifecycle:__queue_receive({ parent.id, "init" }) + test.wait_for_events() + for _, ep in ipairs({ 1, 3, 4 }) do expect_relay_command(parent, ep, true) end + send_switch_command(parent, "on") +end) + +test.register_coroutine_test("A child added after its scene mode report adopts the confirmed profile", function() + receive_mode_report(parent, 2, "scene") + test.mock_device.add_test_device(children[2]) + children[2]:expect_metadata_update({ profile = "scene-switch-child-scene" }) + test.socket.device_lifecycle:__queue_receive({ children[2].id, "added" }) + test.wait_for_events() + send_switch_command(children[2], "on") + 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_init = function() + test.mock_device.add_test_device(parent) + expect_button_metadata(parent) +end }) + +test.register_coroutine_test("A late scene profile is corrected to the newer confirmed switch mode", function() + confirm_switch_modes() + children[2]:expect_metadata_update({ profile = "scene-switch-child-scene" }) + receive_mode_report(parent, 2, "scene") + test.socket.zigbee:__expect_send({ parent.id, OnOff.attributes.OnOff:read(parent):to_endpoint(2) }) + receive_mode_report(parent, 2, "switch") + + local info = utils.deep_copy(children[2].raw_st_data) + -- A profile completion preserves the current preferences; it is not a new mode request. + info.preferences = utils.deep_copy(children[2].preferences) + info.profile = utils.deep_copy(scene_children[2].raw_st_data.profile) + expect_button_metadata(children[2]) + children[2]:expect_metadata_update({ profile = "scene-switch-child" }) + test.socket.device_lifecycle:__queue_receive({ children[2].id, "infoChanged", json.encode(info) }) + test.wait_for_events() + receive_relay_report(parent, nil, 2, true) + + info.profile = utils.deep_copy(children[2].raw_st_data.profile) + test.socket.zigbee:__expect_send({ parent.id, OnOff.attributes.OnOff:read(parent):to_endpoint(2) }) + test.socket.device_lifecycle:__queue_receive({ children[2].id, "infoChanged", json.encode(info) }) + test.wait_for_events() + receive_relay_report(parent, children[2], 2, true, "on") + expect_relay_command(parent, 2, false) + send_switch_command(children[2], "off") +end) + +test.register_coroutine_test("Returning to switch refreshes again when the child profile finishes", function() + confirm_scene_modes() + expect_button_metadata(scene_children[2]) + expect_mode_request(scene_parent, 2, "switch") + test.socket.device_lifecycle:__queue_receive(scene_children[2]:generate_info_changed({ + preferences = { switchMode = "switch" } + })) + test.wait_for_events() + send_switch_command(scene_children[2], "on") + scene_children[2]:expect_metadata_update({ profile = "scene-switch-child" }) + 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) + local info = utils.deep_copy(scene_children[2].raw_st_data) + info.preferences.switchMode = "switch" + info.profile = utils.deep_copy(children[2].raw_st_data.profile) + test.socket.zigbee:__expect_send({ scene_parent.id, OnOff.attributes.OnOff:read(scene_parent):to_endpoint(2) }) + test.socket.device_lifecycle:__queue_receive({ scene_children[2].id, "infoChanged", json.encode(info) }) + test.wait_for_events() + receive_relay_report(scene_parent, scene_children[2], 2, true, "on") +end) + +local function expect_reads(device, endpoint) + for ep = endpoint or 1, endpoint or 4 do + test.socket.zigbee:__expect_send({ device.id, OnOff.attributes.OnOff:read(device):to_endpoint(ep) }) + test.socket.zigbee:__expect_send({ device.id, enum_read(device, 0xD020, ep) }) + end +end + +local function expect_configuration() + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_reads(parent) + for ep = 1, 4 do + for _, cluster in ipairs({ OnOff.ID, 0xE001 }) do + test.socket.zigbee:__expect_send({ parent.id, + zb_utils.build_bind_request(parent, zb_utils.mock_hub_eui, cluster, ep):to_endpoint(ep) }) + end + test.socket.zigbee:__expect_send({ parent.id, + OnOff.attributes.OnOff:configure_reporting(parent, 0, 300):to_endpoint(ep) }) + test.socket.zigbee:__expect_send({ parent.id, cluster_base.configure_reporting(parent, + data_types.ClusterId(0xE001), data_types.AttributeId(0xD020), data_types.Enum8.ID, 1, 3600):to_endpoint(ep) }) + end + test.socket.zigbee:__expect_send({ parent.id, zb_utils.build_attribute_read(parent, 0x0000, + { 0x0004, 0x0000, 0x0001, 0x0005, 0x0007, 0xFFFE }) }) + parent:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.register_coroutine_test("Tuya configuration binds and configures OnOff and D020 once per endpoint", function() + expect_configuration() + test.socket.device_lifecycle:__queue_receive({ parent.id, "doConfigure" }) +end) + +test.register_coroutine_test("Driver switch configures and provisions the aggregate parent", function() + expect_configuration() + test.socket.device_lifecycle:__queue_receive({ parent.id, "driverSwitched" }) +end) + +test.register_coroutine_test("Child configure has no Zigbee traffic and driver switch provisions both profiles", function() + for _, device in ipairs({ children[2], scene_children[3] }) do + device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ device.id, "doConfigure" }) + test.wait_for_events() + device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ device.id, "driverSwitched" }) + test.wait_for_events() + end +end) + +test.register_coroutine_test("Inherited parent refresh reads OnOff and D020 without synthetic presses", function() + confirm_scene_modes() + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_reads(scene_parent) + test.socket.capability:__queue_receive({ scene_parent.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } }) +end) + +test.register_coroutine_test("Inherited child refresh reads only its endpoint", function() + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_reads(parent, 3) + test.socket.capability:__queue_receive({ children[3].id, + { capability = "refresh", component = "main", command = "refresh", args = {} } }) +end) + +local function build_default_response(device, ep, command_id, status) + local body = default_response.DefaultResponse(command_id, status) + return messages.ZigbeeMessageRx({ + address_header = messages.AddressHeader(device:get_short_address(), ep, + 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 _, on in ipairs({ true, false }) do + local name = on and "on" or "off" + local command_id = on and OnOff.server.commands.On.ID or OnOff.server.commands.Off.ID + test.register_coroutine_test("Successful " .. name .. " DefaultResponse follows standard child routing and aggregation", function() + confirm_switch_modes() + for ep = 1, 4 do + test.socket.capability:__expect_send(children[ep]:generate_test_message("main", capabilities.switch.switch(name))) + if (on and ep == 1) or (not on and ep == 4) then expect_parent_switch(parent, name) end + test.socket.zigbee:__queue_receive({ parent.id, build_default_response(parent, ep, command_id, Status.SUCCESS) }) + test.wait_for_events() + end + end) + test.register_coroutine_test("Failed " .. name .. " DefaultResponse does not claim relay state", function() + confirm_switch_modes() + test.socket.zigbee:__queue_receive({ parent.id, build_default_response(parent, 2, command_id, Status.FAILURE) }) + test.wait_for_events() + receive_relay_report(parent, children[2], 2, on, on and "on" or nil) + end) +end + +test.register_coroutine_test("Scene and unknown-mode DefaultResponses cannot emit switch events", function() + test.socket.zigbee:__queue_receive({ parent.id, + build_default_response(parent, 1, OnOff.server.commands.On.ID, Status.SUCCESS) }) + confirm_scene_modes() + for ep = 1, 4 do + test.socket.zigbee:__queue_receive({ scene_parent.id, + build_default_response(scene_parent, ep, OnOff.server.commands.On.ID, Status.SUCCESS) }) + end +end) + +test.register_coroutine_test("Unrelated successful DefaultResponse does not claim relay state", function() + confirm_switch_modes() + test.socket.zigbee:__queue_receive({ parent.id, + build_default_response(parent, 1, OnOff.server.commands.Toggle.ID, Status.SUCCESS) }) +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..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,8 +14,20 @@ local function is_multi_endpoint(device) end return function(opts, driver, device) + 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..fbc5e6ea98 100644 --- a/drivers/SmartThings/zigbee-switch/src/tuya-multi/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/tuya-multi/init.lua @@ -1,7 +1,8 @@ --- Copyright 2025 SmartThings, Inc. +-- 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 data_types = require "st.zigbee.data_types" local zcl_messages = require "st.zigbee.zcl" @@ -33,6 +34,7 @@ local function read_attribute_function(device, cluster_id, attr_id) end local do_configure = function(self, device) + if device.network_type == device_lib.NETWORK_TYPE_CHILD then return end device:refresh() device:configure() local magic_spell = {0x0004, 0x0000, 0x0001, 0x0005, 0x0007, 0xfffe} @@ -48,6 +50,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 new file mode 100644 index 0000000000..c7fafd7afe --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/can_handle.lua @@ -0,0 +1,10 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(_, _, device) + if device ~= nil and device:get_manufacturer() == "_TZ3000_hurauima" + and device: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..c7ff78ffd4 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zemismart-kes606/init.lua @@ -0,0 +1,284 @@ +-- 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 switch_defaults = require "st.zigbee.defaults.switch_defaults" +local default_response = require "st.zigbee.zcl.global_commands.default_response" +local find_child = require "lifecycle_handlers.find_child" + +local OnOff = clusters.OnOff +local SWITCH_PROFILE = "scene-switch-child" +local SCENE_PROFILE = "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 valid_endpoint(endpoint) + return endpoint ~= nil and endpoint >= 1 and endpoint <= 4 +end + +local function mode_field(endpoint) + return string.format("mode_gang_%d", endpoint) +end + +local function is_relay(parent, child) + local endpoint = child and child:get_endpoint() + return parent ~= nil and valid_endpoint(endpoint) + and parent:get_field(mode_field(endpoint)) == "switch" +end + +local function update_parent_switch(parent) + local state, unknown = "off", false + for endpoint = 1, 4 do + local mode = parent:get_field(mode_field(endpoint)) + if mode == "switch" then + local child = find_child(parent, endpoint) + local child_state = child and child:get_latest_state("main", "switch", "switch") + if child_state == "on" then + state = "on" + break + end + if child_state == nil then unknown = true end + elseif mode == nil then + unknown = true + end + end + -- Use the children's last known states; an unknown relay cannot establish all-off. + if state == "off" and unknown then return end + if parent:get_latest_state("main", "switch", "switch") ~= state then + parent:emit_event(capabilities.switch.switch(state)) + end +end + +local function initialize_button(device) + if device:supports_capability(capabilities.button) then + device:emit_event(capabilities.button.supportedButtonValues( + { "pushed" }, { visibility = { displayed = false } })) + device:emit_event(capabilities.button.numberOfButtons( + { value = 1 }, { visibility = { displayed = false } })) + end +end + +local function sync_child_profile(parent, child) + local mode = parent:get_field(mode_field(child:get_endpoint())) + if mode == nil then return end + local capability = mode == "scene" and capabilities.button or capabilities.switch + if not child:supports_capability(capability) then + child:try_update_metadata({ profile = mode == "scene" and SCENE_PROFILE or SWITCH_PROFILE }) + end +end + +local function ensure_children(driver, parent) + for endpoint = 1, 4 do + if find_child(parent, endpoint) == nil then + driver:try_create_device({ + type = "EDGE_CHILD", + parent_device_id = parent.id, + parent_assigned_child_key = string.format("%02X", endpoint), + label = string.format("%s L%d", parent.label, endpoint), + profile = parent:get_field(mode_field(endpoint)) == "scene" and SCENE_PROFILE or SWITCH_PROFILE, + }) + end + end +end + +local function device_init(_, device) + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + device:set_find_child(find_child) + device:add_configured_attribute({ + cluster = MODE_CLUSTER, + attribute = MODE_ATTRIBUTE, + minimum_interval = 1, + maximum_interval = 3600, + data_type = data_types.Enum8, + }) + end + initialize_button(device) +end + +local function device_added(driver, device) + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + ensure_children(driver, device) + else + local parent = device:get_parent_device() + if parent ~= nil and valid_endpoint(device:get_endpoint()) then + sync_child_profile(parent, device) + if is_relay(parent, device) then device:send(OnOff.attributes.OnOff:read(device)) end + end + end +end + +local function switch_command(handler) + return function(driver, device, command) + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + for endpoint = 1, 4 do + local child = find_child(device, endpoint) + if is_relay(device, child) and child:supports_capability(capabilities.switch) then + handler(driver, child, command, false) + end + end + elseif is_relay(device:get_parent_device(), device) and device:supports_capability(capabilities.switch) then + handler(driver, device, command, false) + end + end +end + +local function onoff_report(driver, device, value, zb_rx) + local child = find_child(device, zb_rx.address_header.src_endpoint.value) + if is_relay(device, child) and child:supports_capability(capabilities.switch) then + -- Native handling would bypass the parent aggregation below. + switch_defaults.on_off_attr_handler(driver, device, value, zb_rx, false) + update_parent_switch(device) + end +end + +local function onoff_default_response(driver, device, zb_rx) + local child = find_child(device, zb_rx.address_header.src_endpoint.value) + if is_relay(device, child) and child:supports_capability(capabilities.switch) then + switch_defaults.default_response_handler(driver, device, zb_rx, false) + update_parent_switch(device) + end +end + +local function mode_report(_, device, value, zb_rx) + local endpoint = zb_rx.address_header.src_endpoint.value + if not valid_endpoint(endpoint) or (value.value ~= 0 and value.value ~= 1) then return end + local mode = value.value == 1 and "scene" or "switch" + local previous = device:get_field(mode_field(endpoint)) + -- D020 readback is the single mode source; preferences only request a change. + device:set_field(mode_field(endpoint), mode, { persist = true }) + local child = find_child(device, endpoint) + if child ~= nil then + sync_child_profile(device, child) + if mode == "switch" and previous ~= mode then + child:send(OnOff.attributes.OnOff:read(child)) + end + end + update_parent_switch(device) +end + +local function scene_command(_, device, zb_rx) + local endpoint = zb_rx.address_header.src_endpoint.value + if not valid_endpoint(endpoint) or device:get_field(mode_field(endpoint)) ~= "scene" then return end + local child = find_child(device, endpoint) + if child ~= nil and child:supports_capability(capabilities.button) then + child:emit_event(capabilities.button.button.pushed({ state_change = true })) + end + device:emit_event(capabilities.button.button.pushed({ state_change = true })) +end + +local function driver_switched(driver, device) + -- The SDK checks root supported_capabilities, which does not include button. + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + ensure_children(driver, device) + require("tuya-multi").lifecycle_handlers.doConfigure(driver, 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 + +local function write_enum(device, cluster, attribute, value, endpoint) + local message = cluster_base.write_attribute(device, data_types.ClusterId(cluster), + data_types.AttributeId(attribute), data_types.Enum8(value)) + if endpoint ~= nil then message:to_endpoint(endpoint) end + device:send(message) +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, value, 1) 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, value, 1) end + end + for endpoint = 1, 4 do + local name = string.format("relayStatus%d", endpoint) + local value = POWER_ON_VALUES[preferences[name]] + if changed(preferences, old_preferences, name) and value ~= nil then + write_enum(device, MODE_CLUSTER, POWER_ON_ATTRIBUTE, value, endpoint) + end + end +end + +local function info_changed(_, device, _, args) + local old_store = args and args.old_st_store or {} + local old_preferences = old_store.preferences or {} + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + update_parent_preferences(device, old_preferences) + return + end + local parent = device:get_parent_device() + local endpoint = device:get_endpoint() + if parent == nil or not valid_endpoint(endpoint) then return end + initialize_button(device) + local preferences = device.preferences or {} + local mode = preferences.switchMode + if changed(preferences, old_preferences, "switchMode") and (mode == "switch" or mode == "scene") then + write_enum(device, MODE_CLUSTER, MODE_ATTRIBUTE, mode == "scene" and 1 or 0) + device:send(cluster_base.read_attribute(device, + data_types.ClusterId(MODE_CLUSTER), data_types.AttributeId(MODE_ATTRIBUTE))) + 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))) + device:send(OnOff.server.commands.OnWithTimedOff( + device, data_types.Uint8(0), data_types.Uint16(seconds), data_types.Uint16(seconds))) + end + end + -- Reconcile a delayed profile update against the latest D020 mode. + sync_child_profile(parent, device) + if old_store.profile ~= nil and old_store.profile.id ~= device.profile.id + and is_relay(parent, device) and device:supports_capability(capabilities.switch) then + -- A read received while the old button profile was active could not emit switch state. + device:send(OnOff.attributes.OnOff:read(device)) + end +end + +return { + NAME = "Zemismart KES606 Four Gang", + supported_capabilities = { capabilities.refresh, capabilities.switch, capabilities.button }, + lifecycle_handlers = { + added = device_added, + init = device_init, + driverSwitched = driver_switched, + infoChanged = info_changed, + }, + capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = switch_command(switch_defaults.on), + [capabilities.switch.commands.off.NAME] = switch_command(switch_defaults.off), + }, + }, + zigbee_handlers = { + global = { [OnOff.ID] = { [default_response.DefaultResponse.ID] = onoff_default_response } }, + attr = { + [OnOff.ID] = { [OnOff.attributes.OnOff.ID] = onoff_report }, + [MODE_CLUSTER] = { [MODE_ATTRIBUTE] = mode_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"), +}