-
Notifications
You must be signed in to change notification settings - Fork 554
[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
25e5c46
c9f1500
59774df
1b273e7
a841d71
6d0eb03
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 |
| 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.
Contributor
Author
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. Mode support varies depending on the device using this driver, which is why I separated them
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. Will some devices report dry_contact_open_pulse_mode? |
||
| 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.
Contributor
Author
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. If we add the 'nc' variant to the existing electricSwitchType preference, I am wondering if there will be any impact or side effects on devices that previously did not support the 'nc' variant.
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. Your concern is valid; adding the variant to the preference will expose it for all devices that have the preference even if they do not support it. IMO this is probably okay, but would depend on how existing devices that do not support it handle the nc option being set in the attribute write. If the attribute write is rejected and it is essentially a no-op (what I would expect), then we can just add text in the preference definition that the option may not be supported. IMO, we should combine them, but I am curious if @hcarter-775 or @tpmanley have any thoughts |
||
| -- 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, | ||
| coerce_number = true, | ||
| } | ||
| } | ||
|
|
||
|
|
||
| -- 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.coerce_number ~= nil and attr.coerce_number 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 | ||
| } | ||
| } | ||
| }, | ||
|
|
||
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.
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.
Since this device does not support firmware updates in the SmartThings environment, I have removed the firmwareUpdate capability from the device profile as suggested by Samsung.
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.
If the device supports reporting its firmware version, I think we should still have it (which pretty much all zigbee devices do support).