Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions drivers/SmartThings/zigbee-switch/fingerprints.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions drivers/SmartThings/zigbee-switch/src/aqara/can_handle.lua
Original file line number Diff line number Diff line change
@@ -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
Expand Down
37 changes: 19 additions & 18 deletions drivers/SmartThings/zigbee-switch/src/aqara/fingerprints.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
134 changes: 119 additions & 15 deletions drivers/SmartThings/zigbee-switch/src/aqara/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"] = {
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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" }))

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
}
Expand All @@ -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
}
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
Loading
Loading