diff --git a/drivers/SmartThings/zigbee-switch/fingerprints.yml b/drivers/SmartThings/zigbee-switch/fingerprints.yml index b44a13d3c1..e05e4aa64a 100644 --- a/drivers/SmartThings/zigbee-switch/fingerprints.yml +++ b/drivers/SmartThings/zigbee-switch/fingerprints.yml @@ -111,6 +111,11 @@ zigbeeManufacturer: manufacturer: LUMI model: lumi.switch.n0acn2 deviceProfileName: aqara-switch-module + - id: "Aqara/lumi.switch.acn047" + deviceLabel: Aqara Dual Relay Module T2 (With Neutral) + manufacturer: Aqara + model: lumi.switch.acn047 + deviceProfileName: aqara-dual-relay-module-unified - id: "LUMI/lumi.light.acn004" deviceLabel: Aqara Smart Dimmer Controller T1 Pro manufacturer: LUMI diff --git a/drivers/SmartThings/zigbee-switch/profiles/aqara-dual-relay-module-unified.yml b/drivers/SmartThings/zigbee-switch/profiles/aqara-dual-relay-module-unified.yml new file mode 100644 index 0000000000..99ad4e2dd9 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/aqara-dual-relay-module-unified.yml @@ -0,0 +1,42 @@ +name: aqara-dual-relay-module-unified +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: button + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch + - id: interlock + capabilities: + - id: mode + version: 1 + categories: + - name: Switch + - id: devicemode + capabilities: + - id: mode + version: 1 + categories: + - name: Switch +preferences: + - preferenceId: stse.powerOffMemory + explicit: true + - preferenceId: stse.pulseIntervalSetting + explicit: true + - preferenceId: stse.switchType + explicit: true + - preferenceId: stse.changeToWirelessSwitch + explicit: true +metadata: + mnmn: SolutionsEngineering + vid: SmartThings-smartthings-Aqara_Dual_Relay_Module_T2 diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/aqara/can_handle.lua index e183f78266..c0eb27343e 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/can_handle.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/can_handle.lua @@ -1,6 +1,8 @@ -- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +-- Matches any device whose manufacturer/model is listed in aqara/fingerprints.lua, routing it to +-- the aqara sub-driver (and, in turn, its version / multi-switch sub-drivers). return function(opts, driver, device) local FINGERPRINTS = require("aqara.fingerprints") for _, fingerprint in ipairs(FINGERPRINTS) do diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/aqara/fingerprints.lua index 63f94e2be3..3fc2fbc3e3 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/fingerprints.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/fingerprints.lua @@ -2,21 +2,22 @@ -- Licensed under the Apache License, Version 2.0 return { - { mfr = "LUMI", model = "lumi.plug.maeu01" }, - { mfr = "LUMI", model = "lumi.plug.macn01" }, - { mfr = "LUMI", model = "lumi.switch.n0agl1" }, - { mfr = "LUMI", model = "lumi.switch.l0agl1" }, - { mfr = "LUMI", model = "lumi.switch.n0acn2" }, - { mfr = "LUMI", model = "lumi.switch.n1acn1" }, - { mfr = "LUMI", model = "lumi.switch.n2acn1" }, - { mfr = "LUMI", model = "lumi.switch.n3acn1" }, - { mfr = "LUMI", model = "lumi.switch.b1laus01" }, - { mfr = "LUMI", model = "lumi.switch.b2laus01" }, - { mfr = "LUMI", model = "lumi.switch.n1aeu1" }, - { mfr = "LUMI", model = "lumi.switch.n2aeu1" }, - { mfr = "LUMI", model = "lumi.switch.l1aeu1" }, - { mfr = "LUMI", model = "lumi.switch.l2aeu1" }, - { mfr = "LUMI", model = "lumi.switch.b1nacn01" }, - { mfr = "LUMI", model = "lumi.switch.b2nacn01" }, - { mfr = "LUMI", model = "lumi.switch.b3n01" } - } + { mfr = "LUMI", model = "lumi.plug.maeu01" }, + { mfr = "LUMI", model = "lumi.plug.macn01" }, + { mfr = "LUMI", model = "lumi.switch.n0agl1" }, + { mfr = "LUMI", model = "lumi.switch.l0agl1" }, + { mfr = "LUMI", model = "lumi.switch.n0acn2" }, + { mfr = "LUMI", model = "lumi.switch.n1acn1" }, + { mfr = "LUMI", model = "lumi.switch.n2acn1" }, + { mfr = "LUMI", model = "lumi.switch.n3acn1" }, + { mfr = "LUMI", model = "lumi.switch.b1laus01" }, + { mfr = "LUMI", model = "lumi.switch.b2laus01" }, + { mfr = "LUMI", model = "lumi.switch.n1aeu1" }, + { mfr = "LUMI", model = "lumi.switch.n2aeu1" }, + { mfr = "LUMI", model = "lumi.switch.l1aeu1" }, + { mfr = "LUMI", model = "lumi.switch.l2aeu1" }, + { mfr = "LUMI", model = "lumi.switch.b1nacn01" }, + { mfr = "LUMI", model = "lumi.switch.b2nacn01" }, + { mfr = "LUMI", model = "lumi.switch.b3n01" }, + { mfr = "Aqara", model = "lumi.switch.acn047" } +} diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/init.lua b/drivers/SmartThings/zigbee-switch/src/aqara/init.lua index 0c64b594fe..3a93738af1 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/init.lua @@ -23,9 +23,30 @@ local CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID = 0x0200 local RESTORE_TURN_OFF_INDICATOR_LIGHT_ATTRIBUTE_ID = 0x0203 local MAX_POWER_ATTRIBUTE_ID = 0x020B local ELECTRIC_SWITCH_TYPE_ATTRIBUTE_ID = 0x000A - +-- Aqara private cluster (0xFCC0) attributes used by the Dual Relay Module T2 (lumi.switch.acn047) +local DEVICE_MODE_ATTRIBUTE_ID = 0x0289 -- relay working mode (wet/dry contact, pulse), Uint8 0..3 +local INTERLOCK_ATTRIBUTE_ID = 0x02D0 -- interlock between the two relays, Boolean +local POWER_OFF_MEMORY_ATTRIBUTE_ID = 0x0517 -- power-off memory behavior, Uint8 (see powerOffMemory value_map) +local PULSE_INTERVAL_ATTRIBUTE_ID = 0x00EB -- pulse width in ms when running in pulse mode, Uint16 local LAST_REPORT_TIME = "LAST_REPORT_TIME" local PRIVATE_MODE = "PRIVATE_MODE" +-- "interlock" / "devicemode" are extra profile components on aqara-dual-relay-module-unified.yml. +-- The order of the SUPPORTED_* lists matches the raw device values (0-based), see the handlers below. +local COMPONENT_INTERLOCK = "interlock" +local SUPPORTED_INTERLOCK = { "normal", "interlock" } +local COMPONENT_DEVICE_MODE = "devicemode" +-- Relay working modes mapped to their raw device values. dry_contact_open_pulse_mode (raw 2) is +-- intentionally not exposed; the remaining modes keep their original device values (on_off stays 3). +local DEVICE_MODE_TO_VALUE = { + wet_contact_mode = 0, + dry_contact_closed_pulse_mode = 1, + dry_contact_on_off_mode = 3, +} +local DEVICE_MODE_FROM_VALUE = { + [0] = "wet_contact_mode", + [1] = "dry_contact_closed_pulse_mode", + [3] = "dry_contact_on_off_mode", +} local preference_map = { ["stse.restorePowerState"] = { @@ -39,7 +60,7 @@ local preference_map = { attribute_id = CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID, mfg_code = MFG_CODE, data_type = data_types.Uint8, - value_map = { [true] = 0x00,[false] = 0x01 }, + value_map = { [true] = 0x00, [false] = 0x01 }, }, ["stse.maxPower"] = { cluster_id = PRIVATE_CLUSTER_ID, @@ -112,26 +133,71 @@ local preference_map = { data_type = data_types.Uint8, value_map = { rocker = 0x01, rebound = 0x02 }, }, + -- External switch wiring type (same attribute as stse.electricSwitchType, with an extra "disabled" + -- option): rocker = maintained, rebound/button = momentary, disabled = external switch ignored. + ["stse.switchType"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = ELECTRIC_SWITCH_TYPE_ATTRIBUTE_ID, + mfg_code = MFG_CODE, + data_type = data_types.Uint8, + value_map = { rocker = 0x01, rebound = 0x02, nc = 0x00 }, + }, ["stse.turnOffIndicatorLight"] = { cluster_id = PRIVATE_CLUSTER_ID, attribute_id = RESTORE_TURN_OFF_INDICATOR_LIGHT_ATTRIBUTE_ID, mfg_code = MFG_CODE, data_type = data_types.Boolean, }, + ["stse.powerOffMemory"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = POWER_OFF_MEMORY_ATTRIBUTE_ID, + mfg_code = MFG_CODE, + data_type = data_types.Uint8, + value_map = { restore = 0x01, on = 0x00, off = 0x02, reverse = 0x03 }, + }, + ["stse.pulseIntervalSetting"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = PULSE_INTERVAL_ATTRIBUTE_ID, + mfg_code = MFG_CODE, + data_type = data_types.Uint16, + value_type = { "number" }, -- presence flag: coerce the preference value with tonumber() before writing + } } - +-- Handles reports of the private-mode attribute. Caches the current private-mode state and, when the +-- device is not yet in private mode, forces it into private mode and configures energy reporting. +-- acn047 is excluded from being forced into private mode (it stays on standard clusters). local function private_mode_handler(driver, device, value, zb_rx) device:set_field(PRIVATE_MODE, value.value, { persist = true }) if value.value ~= 1 then - device:send(cluster_base.write_manufacturer_specific_attribute(device, - PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01)) -- private + if device:get_model() ~= "lumi.switch.acn047" then + device:send(cluster_base.write_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01)) -- private + end device:send(SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(device, 900, 3600, 1)) -- minimal interval : 15min device:set_field(constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, 10, { persist = true }) device:set_field(constants.SIMPLE_METERING_DIVISOR_KEY, 1000, { persist = true }) end end +-- Reflect the device's interlock state onto the "interlock" component. +-- value.value is a Boolean; +1 converts the 0/1 state into a Lua (1-based) list index. +local function interlock_switch_handler(driver, device, value, zb_rx) + local component = device.profile.components[COMPONENT_INTERLOCK] + if component == nil then return end + local cur_state = 0 + if value.value then cur_state = 1 end + device:emit_component_event(component, capabilities.mode.mode(SUPPORTED_INTERLOCK[cur_state + 1])) +end +-- Reflect the relay working mode onto the "devicemode" component. +-- value.value is the raw device value; only emit for modes we expose (open_pulse is ignored). +local function device_mode_handler(driver, device, value, zb_rx) + local component = device.profile.components[COMPONENT_DEVICE_MODE] + if component == nil then return end + local mode = DEVICE_MODE_FROM_VALUE[value.value] + if mode ~= nil then + device:emit_component_event(component, capabilities.mode.mode(mode)) + end +end local function wireless_switch_handler(driver, device, value, zb_rx) if value.value == 1 then @@ -151,7 +217,7 @@ local function energy_meter_power_consumption_report(driver, device, value, zb_r if raw_value < offset then --- somehow our value has gone below the offset, so we'll reset the offset, since the device seems to have offset = 0 - device:set_field(constants.ENERGY_METER_OFFSET, offset, {persist = true}) + device:set_field(constants.ENERGY_METER_OFFSET, offset, { persist = true }) end device:emit_event(capabilities.energyMeter.energy({ value = raw_value - offset, unit = "Wh" })) @@ -166,8 +232,7 @@ local function energy_meter_power_consumption_report(driver, device, value, zb_r -- power consumption report local delta_energy = 0.0 - local current_power_consumption = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, - capabilities.powerConsumptionReport.powerConsumption.NAME) + local current_power_consumption = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, capabilities.powerConsumptionReport.powerConsumption.NAME) if current_power_consumption ~= nil then delta_energy = math.max(raw_value - current_power_consumption.energy, 0.0) end @@ -184,14 +249,40 @@ local function power_meter_handler(driver, device, value, zb_rx) device:emit_event(capabilities.powerMeter.power({ value = raw_value, unit = "W" })) end +-- setMode command handler shared by the "interlock" and "devicemode" components. +-- The target component is used to decide which private-cluster attribute to write. +local function mode_handler(driver, device, command) + if command.component == COMPONENT_INTERLOCK then + -- interlock attribute is a Boolean: true = relays interlocked, false = independent + local interlock_mode = false + if command.args.mode == SUPPORTED_INTERLOCK[2] then interlock_mode = true end + device:send(cluster_base.write_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, INTERLOCK_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, interlock_mode)) + elseif command.component == COMPONENT_DEVICE_MODE then + -- map the selected mode string to its raw device value + local device_mode = DEVICE_MODE_TO_VALUE[command.args.mode] + if device_mode ~= nil then + device:send(cluster_base.write_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, DEVICE_MODE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, device_mode)) + end + end +end +-- Read back switch state, power/energy (standard clusters) and, when present, the interlock and +-- device-mode private attributes. local function do_refresh(self, device) device:send(OnOff.attributes.OnOff:read(device)) if (device:supports_capability_by_id(capabilities.powerMeter.ID)) then device:send(ElectricalMeasurement.attributes.ActivePower:read(device)) device:send(SimpleMetering.attributes.CurrentSummationDelivered:read(device)) end + if device.profile.components[COMPONENT_INTERLOCK] then + device:send(cluster_base.read_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, INTERLOCK_ATTRIBUTE_ID, MFG_CODE)) + end + if device.profile.components[COMPONENT_DEVICE_MODE] then + device:send(cluster_base.read_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, DEVICE_MODE_ATTRIBUTE_ID, MFG_CODE)) + end end +-- On preference change, write any preference whose value changed to its mapped private-cluster +-- attribute (see preference_map). value_map translates enum strings; value_type coerces numbers. local function device_info_changed(driver, device, event, args) local preferences = device.preferences local old_preferences = args.old_st_store.preferences @@ -203,28 +294,36 @@ local function device_info_changed(driver, device, event, args) if attr.value_map ~= nil then value = attr.value_map[value] end - device:send(cluster_base.write_manufacturer_specific_attribute(device, attr.cluster_id, attr.attribute_id, - attr.mfg_code, attr.data_type, value)) + -- numeric preferences (e.g. pulseInterval) are coerced to a Lua number before being written + if attr.value_type ~= nil then + value = tonumber(value) + end + device:send(cluster_base.write_manufacturer_specific_attribute(device, attr.cluster_id, attr.attribute_id, attr.mfg_code, attr.data_type, value)) end end end end +-- Standard configuration: bind/report standard clusters, read the private-mode attribute, clear any +-- groups (required by these devices) and refresh current state. local function do_configure(self, device) device:configure() - device:send(cluster_base.read_manufacturer_specific_attribute(device, - PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE)) + device:send(cluster_base.read_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE)) device:send(Groups.server.commands.RemoveAllGroups(device)) -- required do_refresh(self, device) end +-- On add, advertise supported button values and restore the last known power/energy (so the values +-- are not blanked to 0 on re-add). local function device_added(driver, device) if (device:supports_capability_by_id(capabilities.button.ID)) then device:emit_event(capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } })) end if (device:supports_capability_by_id(capabilities.powerMeter.ID)) then - device:emit_event(capabilities.powerMeter.power({ value = 0.0, unit = "W" })) - device:emit_event(capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) + local lastPower = device:get_latest_state("main", capabilities.powerMeter.ID, capabilities.powerMeter.power.NAME) or 0.0 + local lastEnergy = device:get_latest_state("main", capabilities.energyMeter.ID, capabilities.energyMeter.energy.NAME) or 0.0 + device:emit_event(capabilities.powerMeter.power({ value = lastPower, unit = "W" })) + device:emit_event(capabilities.energyMeter.energy({ value = lastEnergy, unit = "Wh" })) end end @@ -236,6 +335,9 @@ local aqara_switch_handler = { infoChanged = device_info_changed }, capability_handlers = { + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = mode_handler + }, [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh } @@ -252,7 +354,9 @@ local aqara_switch_handler = { [WIRELESS_SWITCH_ATTRIBUTE_ID] = wireless_switch_handler }, [PRIVATE_CLUSTER_ID] = { - [PRIVATE_ATTRIBUTE_ID] = private_mode_handler + [PRIVATE_ATTRIBUTE_ID] = private_mode_handler, + [INTERLOCK_ATTRIBUTE_ID] = interlock_switch_handler, + [DEVICE_MODE_ATTRIBUTE_ID] = device_mode_handler } } }, diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/can_handle.lua index 1524296cff..16d61cfbbf 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/can_handle.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/can_handle.lua @@ -1,6 +1,8 @@ -- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +-- Matches multi-endpoint Aqara switches/modules listed in aqara/multi-switch/fingerprints.lua +-- (devices that create child devices for their extra switch endpoints). return function(opts, driver, device) local FINGERPRINTS = require("aqara.multi-switch.fingerprints") for _, fingerprint in ipairs(FINGERPRINTS) do diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/fingerprints.lua index 71ed1b26b3..5db38f53ab 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/fingerprints.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/fingerprints.lua @@ -2,13 +2,14 @@ -- Licensed under the Apache License, Version 2.0 return { - { mfr = "LUMI", model = "lumi.switch.n1acn1", children = 1, child_profile = "" }, - { mfr = "LUMI", model = "lumi.switch.n2acn1", children = 2, child_profile = "aqara-switch-child" }, - { mfr = "LUMI", model = "lumi.switch.n3acn1", children = 3, child_profile = "aqara-switch-child" }, - { mfr = "LUMI", model = "lumi.switch.b1laus01", children = 1, child_profile = "" }, - { mfr = "LUMI", model = "lumi.switch.b2laus01", children = 2, child_profile = "aqara-switch-child" }, - { mfr = "LUMI", model = "lumi.switch.l2aeu1", children = 2, child_profile = "aqara-switch-child" }, - { mfr = "LUMI", model = "lumi.switch.n2aeu1", children = 2, child_profile = "aqara-switch-child" }, - { mfr = "LUMI", model = "lumi.switch.b2nacn01", children = 2, child_profile = "aqara-switch-child" }, - { mfr = "LUMI", model = "lumi.switch.b3n01", children = 3, child_profile = "aqara-switch-child" } + { mfr = "LUMI", model = "lumi.switch.n1acn1", children = 1, child_profile = "" }, + { mfr = "LUMI", model = "lumi.switch.n2acn1", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.n3acn1", children = 3, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.b1laus01", children = 1, child_profile = "" }, + { mfr = "LUMI", model = "lumi.switch.b2laus01", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.l2aeu1", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.n2aeu1", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.b4nacn01", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.b3n01", children = 3, child_profile = "aqara-switch-child" }, + { mfr = "Aqara", model = "lumi.switch.acn047", children = 2, child_profile = "aqara-switch-child" } } diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/init.lua b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/init.lua index 4b2146f03c..3180c02f68 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/init.lua @@ -11,8 +11,13 @@ local switch_utils = require "switch_utils" local PRIVATE_CLUSTER_ID = 0xFCC0 local PRIVATE_ATTRIBUTE_ID = 0x0009 local MFG_CODE = 0x115F +local COMPONENT_INTERLOCK = "interlock" +local SUPPORTED_INTERLOCK = { "normal", "interlock" } +local COMPONENT_DEVICE_MODE = "devicemode" +local SUPPORTED_DEVICE_MODE = { "wet_contact_mode", "dry_contact_closed_pulse_mode", "dry_contact_on_off_mode" } local FINGERPRINTS = require("aqara.multi-switch.fingerprints") +-- Number of switch endpoints (parent + children) for this model, from the fingerprint table. local function get_children_amount(device) for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_model() == fingerprint.model then @@ -21,6 +26,7 @@ local function get_children_amount(device) end end +-- Profile name to assign to the created child devices for this model. local function get_child_profile_name(device) for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_model() == fingerprint.model then @@ -37,6 +43,7 @@ local function find_child(parent, ep_id) return parent:get_child_by_parent_assigned_key(string.format("%02X", ep_id)) end +-- Create one EDGE_CHILD device per extra switch endpoint and initialize parent-only state. local function device_added(driver, device) -- Only create children for the actual Zigbee device and not the children if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then @@ -44,7 +51,8 @@ local function device_added(driver, device) if children_amount >= 2 then for i = 2, children_amount, 1 do if find_child(device, i) == nil then - local name = string.format("%s%d", string.sub(device.label, 0, -2), i) + -- child shares the parent's label (endpoint 1 is the parent, 2..n are children) + local name = string.format("%s", device.label) local child_profile = get_child_profile_name(device) local metadata = { type = "EDGE_CHILD", @@ -60,22 +68,36 @@ local function device_added(driver, device) end -- for wireless button - device:emit_event(capabilities.button.numberOfButtons({ value = children_amount }, - { visibility = { displayed = false } })) - device:emit_event(capabilities.powerMeter.power({ value = 0.0, unit = "W" })) - device:emit_event(capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) + device:emit_event(capabilities.button.numberOfButtons({ value = children_amount }, { visibility = { displayed = false } })) - device:send(cluster_base.write_manufacturer_specific_attribute(device, - PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01)) -- private + -- report the static supported modes for the interlock / devicemode components (when present) + if device.profile.components[COMPONENT_INTERLOCK] then + device:emit_component_event(device.profile.components[COMPONENT_INTERLOCK], capabilities.mode.supportedModes(SUPPORTED_INTERLOCK, { visibility = { displayed = false } })) + end + if device.profile.components[COMPONENT_DEVICE_MODE] then + device:emit_component_event(device.profile.components[COMPONENT_DEVICE_MODE], capabilities.mode.supportedModes(SUPPORTED_DEVICE_MODE, { visibility = { displayed = false } })) + end + + -- acn047 (Dual Relay Module T2) must not be forced into Aqara private mode; all other + -- multi-switch models are switched into private mode here. + if device:get_model() ~= "lumi.switch.acn047" then + device:send(cluster_base.write_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01)) -- private + end elseif device.network_type == "DEVICE_EDGE_CHILD" then - device:emit_event(capabilities.button.numberOfButtons({ value = 1 }, - { visibility = { displayed = false } })) + device:emit_event(capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } })) + end + device:emit_event(capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } })) + switch_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, capabilities.button.button.NAME, capabilities.button.button.pushed({ state_change = false })) + -- restore the last known power/energy (instead of resetting to 0) so values survive re-adds + if (device:supports_capability_by_id(capabilities.powerMeter.ID)) then + local lastPower = device:get_latest_state("main", capabilities.powerMeter.ID, capabilities.powerMeter.power.NAME) or 0.0 + local lastEnergy = device:get_latest_state("main", capabilities.energyMeter.ID, capabilities.energyMeter.energy.NAME) or 0.0 + device:emit_event(capabilities.powerMeter.power({ value = lastPower, unit = "W" })) + device:emit_event(capabilities.energyMeter.energy({ value = lastEnergy, unit = "Wh" })) end - device:emit_event(capabilities.button.supportedButtonValues({ "pushed" }, - { visibility = { displayed = false } })) - switch_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, capabilities.button.button.NAME, capabilities.button.button.pushed({state_change = false})) end +-- Register the endpoint->child routing function so reports from child endpoints reach the children. local function device_init(self, device) -- for multiple switch if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_dual_relay_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_dual_relay_module.lua new file mode 100644 index 0000000000..18c5ff1b05 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_dual_relay_module.lua @@ -0,0 +1,269 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local OnOff = clusters.OnOff +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering + +local PRIVATE_CLUSTER_ID = 0xFCC0 +local PRIVATE_ATTRIBUTE_ID = 0x0009 +local MFG_CODE = 0x115F +local INTERLOCK_ATTRIBUTE_ID = 0x02D0 +local DEVICE_MODE_ATTRIBUTE_ID = 0x0289 +local POWER_OFF_MEMORY_ATTRIBUTE_ID = 0x0517 +local PULSE_INTERVAL_ATTRIBUTE_ID = 0x00EB +local ELECTRIC_SWITCH_TYPE_ATTRIBUTE_ID = 0x000A +local CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID = 0x0200 + +local PRIVATE_MODE = "PRIVATE_MODE" + +local SUPPORTED_INTERLOCK = { "normal", "interlock" } +local SUPPORTED_DEVICE_MODE = { "wet_contact_mode", "dry_contact_closed_pulse_mode", "dry_contact_on_off_mode" } + +-- acn047 (Dual Relay Module T2) runs on standard clusters (never Aqara private mode) +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("aqara-dual-relay-module-unified.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Aqara", + model = "lumi.switch.acn047", + server_clusters = { OnOff.ID, ElectricalMeasurement.ID, SimpleMetering.ID } + } + } + } +) + +-- acn047 reports children = 2, so a single child device (relay 2) exists on the second endpoint +local mock_child = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("aqara-switch-child.yml"), + device_network_id = string.format("%04X:%02X", mock_device:get_short_address(), 2), + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%02X", 2) +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_child) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "added: child is reused, button info reported, power/energy restored (0.0 on first add) and no private write", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.numberOfButtons({ value = 2 }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("interlock", + capabilities.mode.supportedModes(SUPPORTED_INTERLOCK, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("devicemode", + capabilities.mode.supportedModes(SUPPORTED_DEVICE_MODE, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 0.0, unit = "W" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "refresh reads OnOff, power/energy (standard clusters) and the interlock/devicemode attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, INTERLOCK_ATTRIBUTE_ID, MFG_CODE) }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, DEVICE_MODE_ATTRIBUTE_ID, MFG_CODE) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "switch on command : parent device", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "switch off command : child device (endpoint 2)", + function() + test.socket.capability:__queue_receive({ mock_child.id, + { capability = "switch", component = "main", command = "off", args = {} } }) + mock_child:expect_native_cmd_handler_registration("switch", "off") + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x02) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "power meter report (standard ElectricalMeasurement cluster)", + function() + mock_device:set_field(PRIVATE_MODE, 0, { persist = true }) + test.socket.zigbee:__queue_receive({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, 100) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 10.0, unit = "W" })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "interlock attribute report updates the interlock component", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, { + { INTERLOCK_ATTRIBUTE_ID, data_types.Boolean.ID, true } + }, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("interlock", + capabilities.mode.mode("interlock"))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "device mode attribute report updates the devicemode component", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, { + { DEVICE_MODE_ATTRIBUTE_ID, data_types.Uint8.ID, 1 } + }, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("devicemode", + capabilities.mode.mode("dry_contact_closed_pulse_mode"))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setMode on the interlock component writes the interlock attribute", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "mode", component = "interlock", command = "setMode", args = { "interlock" } } }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, INTERLOCK_ATTRIBUTE_ID, + MFG_CODE, data_types.Boolean, true) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setMode on the devicemode component (on/off) writes device value 3", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "mode", component = "devicemode", command = "setMode", args = { "dry_contact_on_off_mode" } } }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, DEVICE_MODE_ATTRIBUTE_ID, + MFG_CODE, data_types.Uint8, 3) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "preference powerOffMemory is written on infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["powerOffMemory"] = "poweron" } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + POWER_OFF_MEMORY_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "preference pulseInterval is written on infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["pulseInterval"] = 500 } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PULSE_INTERVAL_ATTRIBUTE_ID, MFG_CODE, data_types.Uint16, 500) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "preference switchType (disabled) is written on infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["switchType"] = "disabled" } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + ELECTRIC_SWITCH_TYPE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "preference changeToWirelessSwitch is written on infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.changeToWirelessSwitch"] = true } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0) }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests()