-
Notifications
You must be signed in to change notification settings - Fork 555
[Aqara] Aqara Dual Relay Module T2 #3146
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,40 @@ | ||||||
| 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: 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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: trailing whitespace
Suggested change
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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). | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the reason for not exposing this? The mode capability can take arbitrary strings, so it could be represented. If a device reports this mode, the driver silently drops it, and then the device state is no longer correct; this seems misleading to the user. Although I am not sure I fully understand these modes, so perhaps I am wrong. |
||||||
| 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 "nc" | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this needed? Can we add the nc variant t othe existing electricSwitchType preference? Since they map to the same attribute, this could get confusing in the future. |
||||||
| -- option): rocker = maintained, rebound/button = momentary, nc = external switch ignored (normally closed). | ||||||
| ["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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since this is used as a flag to coerce the preference value a single element table is a weird way to do it. IMO a boolean flag makes more sense.
Suggested change
|
||||||
| } | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
| -- 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 | ||||||
| } | ||||||
| } | ||||||
| }, | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" }, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the |
||
| { 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" } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should include the firmwareUpdate capability like the other device profiles do. This allows the device FW version to be displayed to the user in the app.