From 0f03646d4427b3850c8bb00db62fd0cfe6677c27 Mon Sep 17 00:00:00 2001 From: Carter Swedal Date: Fri, 17 Jan 2025 09:22:10 -0600 Subject: [PATCH 1/5] Reapply "[Bar 300] Issues - device don't operate about 'stop' command (#1797)" (#1871) This reverts commit 4dcce6db53b4f178596cc3d082961fede009d2a7. --- drivers/SmartThings/jbl/profiles/jbl.yml | 2 -- drivers/SmartThings/jbl/src/init.lua | 11 ++++++++++- drivers/SmartThings/jbl/src/jbl/api.lua | 3 ++- .../SmartThings/jbl/src/jbl/capability_handler.lua | 5 ----- drivers/SmartThings/jbl/src/jbl/device_manager.lua | 1 - 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/drivers/SmartThings/jbl/profiles/jbl.yml b/drivers/SmartThings/jbl/profiles/jbl.yml index c60823fed7..143c90634f 100644 --- a/drivers/SmartThings/jbl/profiles/jbl.yml +++ b/drivers/SmartThings/jbl/profiles/jbl.yml @@ -10,12 +10,10 @@ components: enabledValues: - 'playing' - 'paused' - - 'stopped' - key: "{{enumCommands}}" enabledValues: - 'play' - 'pause' - - 'stop' - id: mediaTrackControl version: 1 - id: audioMute diff --git a/drivers/SmartThings/jbl/src/init.lua b/drivers/SmartThings/jbl/src/init.lua index d707c27376..f422ecf84c 100644 --- a/drivers/SmartThings/jbl/src/init.lua +++ b/drivers/SmartThings/jbl/src/init.lua @@ -179,6 +179,16 @@ local function device_init(driver, device) refresh(driver, device, nil) device:set_field(fields._INIT, true, { persist = false }) + + device:emit_event(capabilities.mediaPlayback.supportedPlaybackCommands({ + capabilities.mediaPlayback.commands.play.NAME, + capabilities.mediaPlayback.commands.pause.NAME, + })) + + device:emit_event(capabilities.mediaTrackControl.supportedTrackControlCommands({ + capabilities.mediaTrackControl.commands.nextTrack.NAME, + capabilities.mediaTrackControl.commands.previousTrack.NAME, + })) end local lan_driver = Driver("jbl", @@ -204,7 +214,6 @@ local lan_driver = Driver("jbl", [capabilities.mediaPlayback.ID] = { [capabilities.mediaPlayback.commands.play.NAME] = jbl_capability_handler.playback_play_handler, [capabilities.mediaPlayback.commands.pause.NAME] = jbl_capability_handler.playback_pause_handler, - [capabilities.mediaPlayback.commands.stop.NAME] = jbl_capability_handler.playback_stop_handler, }, [capabilities.audioNotification.ID] = { [capabilities.audioNotification.commands.playTrack.NAME] = jbl_capability_handler.audioNotification_handler, diff --git a/drivers/SmartThings/jbl/src/jbl/api.lua b/drivers/SmartThings/jbl/src/jbl/api.lua index 3cebabce55..95e25d14c3 100644 --- a/drivers/SmartThings/jbl/src/jbl/api.lua +++ b/drivers/SmartThings/jbl/src/jbl/api.lua @@ -2,6 +2,7 @@ local log = require "log" local json = require "st.json" local RestClient = require "lunchbox.rest" local utils = require "utils" +local st_utils = require "st.utils" local cosock = require "cosock" local jbl_api = {} @@ -68,7 +69,7 @@ function jbl_api.new_device_manager(bridge_ip, bridge_info, socket_builder) return setmetatable( { - headers = ADDITIONAL_HEADERS, + headers = st_utils.deep_copy(ADDITIONAL_HEADERS), client = RestClient.new(base_url, socket_builder), base_url = base_url, }, jbl_api diff --git a/drivers/SmartThings/jbl/src/jbl/capability_handler.lua b/drivers/SmartThings/jbl/src/jbl/capability_handler.lua index 1ad5259c01..75f05266ba 100644 --- a/drivers/SmartThings/jbl/src/jbl/capability_handler.lua +++ b/drivers/SmartThings/jbl/src/jbl/capability_handler.lua @@ -9,7 +9,6 @@ local function smartthings_playback_capability_handler(driver, device, capabilit local st_status_to_jbl_playback_status_table = { paused = "pause", playing = "play", - stopped = "stop", } local conn_info = device:get_field(fields.CONN_INFO) @@ -34,10 +33,6 @@ function capability_handler.playback_pause_handler(driver, device, args) smartthings_playback_capability_handler(driver, device, "paused") end -function capability_handler.playback_stop_handler(driver, device, args) - smartthings_playback_capability_handler(driver, device, "stopped") -end - function capability_handler.next_track_handler(driver, device, args) local conn_info = device:get_field(fields.CONN_INFO) log.info(string.format("media_track_control.next_track_handler : dni = %s", device.device_network_id)) diff --git a/drivers/SmartThings/jbl/src/jbl/device_manager.lua b/drivers/SmartThings/jbl/src/jbl/device_manager.lua index 436b16266e..c7ca9c6092 100644 --- a/drivers/SmartThings/jbl/src/jbl/device_manager.lua +++ b/drivers/SmartThings/jbl/src/jbl/device_manager.lua @@ -28,7 +28,6 @@ end local jbl_playback_state_to_smartthings_playback_status_table = { paused = "paused", playing = "playing", - stopped = "stopped", } function device_manager.handle_status(driver, device, status) From 78ed03996c2c161333fe2b61987136a757b8c7d9 Mon Sep 17 00:00:00 2001 From: nickolas-deboom <158304111+nickolas-deboom@users.noreply.github.com> Date: Tue, 21 Jan 2025 10:45:14 -0600 Subject: [PATCH 2/5] Matter Switch: Improve color temp boundary logic (#1827) This change improves edge condition logic when a device sends a color temperature value at or beyond its limits in mireds and also when a capability command is received that is at or beyond the limits in Kelvin. --- .../SmartThings/matter-switch/src/init.lua | 84 ++++++++----- .../src/test/test_matter_switch.lua | 112 ++++++++++++++++++ 2 files changed, 167 insertions(+), 29 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index e8196705fc..ad7db22871 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -48,7 +48,8 @@ local COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map" -- rather than COMPONENT_TO_ENDPOINT_MAP. local COMPONENT_TO_ENDPOINT_MAP_BUTTON = "__component_to_endpoint_map_button" local IS_PARENT_CHILD_DEVICE = "__is_parent_child_device" -local COLOR_TEMP_BOUND_RECEIVED = "__colorTemp_bound_received" +local COLOR_TEMP_BOUND_RECEIVED_KELVIN = "__colorTemp_bound_received_kelvin" +local COLOR_TEMP_BOUND_RECEIVED_MIRED = "__colorTemp_bound_received_mired" local COLOR_TEMP_MIN = "__color_temp_min" local COLOR_TEMP_MAX = "__color_temp_max" local LEVEL_BOUND_RECEIVED = "__level_bound_received" @@ -780,7 +781,16 @@ end local function handle_set_color_temperature(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local temp_in_mired = utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/cmd.args.temperature) + local temp_in_kelvin = cmd.args.temperature + local min_temp_kelvin = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_KELVIN..COLOR_TEMP_MIN, endpoint_id) + local max_temp_kelvin = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_KELVIN..COLOR_TEMP_MAX, endpoint_id) + + local temp_in_mired = utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/temp_in_kelvin) + if min_temp_kelvin ~= nil and temp_in_kelvin <= min_temp_kelvin then + temp_in_mired = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_MIRED..COLOR_TEMP_MAX, endpoint_id) + elseif max_temp_kelvin ~= nil and temp_in_kelvin >= max_temp_kelvin then + temp_in_mired = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_MIRED..COLOR_TEMP_MIN, endpoint_id) + end local req = clusters.ColorControl.server.commands.MoveToColorTemperature(device, endpoint_id, temp_in_mired, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) device:set_field(MOST_RECENT_TEMP, cmd.args.temperature) device:send(req) @@ -852,48 +862,64 @@ local function sat_attr_handler(driver, device, ib, response) end local function temp_attr_handler(driver, device, ib, response) - if ib.data.value ~= nil then - if (ib.data.value < COLOR_TEMPERATURE_MIRED_MIN or ib.data.value > COLOR_TEMPERATURE_MIRED_MAX) then - device.log.warn_with({hub_logs = true}, string.format("Device reported color temperature %d mired outside of sane range of %.2f-%.2f", ib.data.value, COLOR_TEMPERATURE_MIRED_MIN, COLOR_TEMPERATURE_MIRED_MAX)) - return - end - local temp = utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/ib.data.value) - local temp_device = device - if device:get_field(IS_PARENT_CHILD_DEVICE) == true then - temp_device = find_child(device, ib.endpoint_id) or device - end - local most_recent_temp = temp_device:get_field(MOST_RECENT_TEMP) - -- this is to avoid rounding errors from the round-trip conversion of Kelvin to mireds - if most_recent_temp ~= nil and - most_recent_temp <= utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/(ib.data.value - 1)) and - most_recent_temp >= utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/(ib.data.value + 1)) then - temp = most_recent_temp - end - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorTemperature.colorTemperature(temp)) + local temp_in_mired = ib.data.value + if temp_in_mired == nil then + return end + if (temp_in_mired < COLOR_TEMPERATURE_MIRED_MIN or temp_in_mired > COLOR_TEMPERATURE_MIRED_MAX) then + device.log.warn_with({hub_logs = true}, string.format("Device reported color temperature %d mired outside of sane range of %.2f-%.2f", temp_in_mired, COLOR_TEMPERATURE_MIRED_MIN, COLOR_TEMPERATURE_MIRED_MAX)) + return + end + local min_temp_mired = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_MIRED..COLOR_TEMP_MIN, ib.endpoint_id) + local max_temp_mired = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_MIRED..COLOR_TEMP_MAX, ib.endpoint_id) + + local temp = utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/temp_in_mired) + if min_temp_mired ~= nil and temp_in_mired <= min_temp_mired then + temp = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_KELVIN..COLOR_TEMP_MAX, ib.endpoint_id) + elseif max_temp_mired ~= nil and temp_in_mired >= max_temp_mired then + temp = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_KELVIN..COLOR_TEMP_MIN, ib.endpoint_id) + end + + local temp_device = device + if device:get_field(IS_PARENT_CHILD_DEVICE) == true then + temp_device = find_child(device, ib.endpoint_id) or device + end + local most_recent_temp = temp_device:get_field(MOST_RECENT_TEMP) + -- this is to avoid rounding errors from the round-trip conversion of Kelvin to mireds + if most_recent_temp ~= nil and + most_recent_temp <= utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/(temp_in_mired - 1)) and + most_recent_temp >= utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/(temp_in_mired + 1)) then + temp = most_recent_temp + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorTemperature.colorTemperature(temp)) end local mired_bounds_handler_factory = function(minOrMax) return function(driver, device, ib, response) - if ib.data.value == nil then + local temp_in_mired = ib.data.value + if temp_in_mired == nil then return end - if (ib.data.value < COLOR_TEMPERATURE_MIRED_MIN or ib.data.value > COLOR_TEMPERATURE_MIRED_MAX) then - device.log.warn_with({hub_logs = true}, string.format("Device reported a color temperature %d mired outside of sane range of %.2f-%.2f", ib.data.value, COLOR_TEMPERATURE_MIRED_MIN, COLOR_TEMPERATURE_MIRED_MAX)) + if (temp_in_mired < COLOR_TEMPERATURE_MIRED_MIN or temp_in_mired > COLOR_TEMPERATURE_MIRED_MAX) then + device.log.warn_with({hub_logs = true}, string.format("Device reported a color temperature %d mired outside of sane range of %.2f-%.2f", temp_in_mired, COLOR_TEMPERATURE_MIRED_MIN, COLOR_TEMPERATURE_MIRED_MAX)) return end - local temp_in_kelvin = mired_to_kelvin(ib.data.value, minOrMax) - set_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED..minOrMax, ib.endpoint_id, temp_in_kelvin) - local min = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED..COLOR_TEMP_MIN, ib.endpoint_id) - local max = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED..COLOR_TEMP_MAX, ib.endpoint_id) + local temp_in_kelvin = mired_to_kelvin(temp_in_mired, minOrMax) + set_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_KELVIN..minOrMax, ib.endpoint_id, temp_in_kelvin) + -- the minimum color temp in kelvin corresponds to the maximum temp in mireds + if minOrMax == COLOR_TEMP_MIN then + set_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_MIRED..COLOR_TEMP_MAX, ib.endpoint_id, temp_in_mired) + else + set_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_MIRED..COLOR_TEMP_MIN, ib.endpoint_id, temp_in_mired) + end + local min = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_KELVIN..COLOR_TEMP_MIN, ib.endpoint_id) + local max = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_KELVIN..COLOR_TEMP_MAX, ib.endpoint_id) if min ~= nil and max ~= nil then if min < max then device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = min, maximum = max} })) else device.log.warn_with({hub_logs = true}, string.format("Device reported a min color temperature %d K that is not lower than the reported max color temperature %d K", min, max)) end - set_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED..COLOR_TEMP_MAX, ib.endpoint_id, nil) - set_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED..COLOR_TEMP_MIN, ib.endpoint_id, nil) end end end diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua index 7de399b836..676e0928fc 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua @@ -699,6 +699,118 @@ test.register_message_test( } ) +test.register_message_test( + "Device reports mireds outside of supported range, set capability to min/max value in kelvin", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 165) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 365) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2800, maximum = 6000})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 160) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(6000)) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 370) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(2800)) + } + } +) + +test.register_message_test( + "Capability sets color temp outside of supported range, value sent to device is limited to min/max value in mireds", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 165) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 365) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2800, maximum = 6000})) + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {6100} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, 1, 165, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {2700} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, 1, 365, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) + } + } + } +) + test.register_message_test( "Min color temperature outside of range, capability not sent", { From a8aa597d27a22be2ea32a1e8a729b5a9b219d74c Mon Sep 17 00:00:00 2001 From: DongHoon-Ryu <54927879+DongHoon-Ryu@users.noreply.github.com> Date: Wed, 22 Jan 2025 10:27:25 +0900 Subject: [PATCH 3/5] Add Aqara Light Switch H2 (#1822) This is a new Matter Device with four physical switches. - Two of each of the four switchs have On/Off Light(0x0100) and Generic Switch(0x000F) device types. - The other two switches have a Generic Switch(0x000F) device type. - The root node has Electrical Sensor(0x0510) utility device type. --- .../light-power-energy-powerConsumption.yml | 18 + .../SmartThings/matter-switch/src/init.lua | 43 ++- .../src/test/test_aqara_light_switch_h2.lua | 333 ++++++++++++++++++ 3 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 drivers/SmartThings/matter-switch/profiles/light-power-energy-powerConsumption.yml create mode 100644 drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua diff --git a/drivers/SmartThings/matter-switch/profiles/light-power-energy-powerConsumption.yml b/drivers/SmartThings/matter-switch/profiles/light-power-energy-powerConsumption.yml new file mode 100644 index 0000000000..1e64f63594 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-power-energy-powerConsumption.yml @@ -0,0 +1,18 @@ +name: light-power-energy-powerConsumption +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index ad7db22871..2ce8ab2bbe 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -47,6 +47,7 @@ local COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map" -- containing both button endpoints and switch endpoints will use this field -- rather than COMPONENT_TO_ENDPOINT_MAP. local COMPONENT_TO_ENDPOINT_MAP_BUTTON = "__component_to_endpoint_map_button" +local ENERGY_MANAGEMENT_ENDPOINT = "__energy_management_endpoint" local IS_PARENT_CHILD_DEVICE = "__is_parent_child_device" local COLOR_TEMP_BOUND_RECEIVED_KELVIN = "__colorTemp_bound_received_kelvin" local COLOR_TEMP_BOUND_RECEIVED_MIRED = "__colorTemp_bound_received_mired" @@ -66,6 +67,7 @@ local ON_OFF_SWITCH_ID = 0x0103 local ON_OFF_DIMMER_SWITCH_ID = 0x0104 local ON_OFF_COLOR_DIMMER_SWITCH_ID = 0x0105 local GENERIC_SWITCH_ID = 0x000F +local ELECTRICAL_SENSOR_ID = 0x0510 local device_type_profile_map = { [ON_OFF_LIGHT_DEVICE_TYPE_ID] = "light-binary", [DIMMABLE_LIGHT_DEVICE_TYPE_ID] = "light-level", @@ -148,12 +150,19 @@ local device_type_attribute_map = { clusters.Switch.events.LongPress, clusters.Switch.events.ShortRelease, clusters.Switch.events.MultiPressComplete + }, + [ELECTRICAL_SENSOR_ID] = { + clusters.ElectricalPowerMeasurement.attributes.ActivePower, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported } } local child_device_profile_overrides = { { vendor_id = 0x1321, product_id = 0x000C, child_profile = "switch-binary" }, { vendor_id = 0x1321, product_id = 0x000D, child_profile = "switch-binary" }, + { vendor_id = 0x115F, product_id = 0x1008, child_profile = "light-power-energy-powerConsumption" }, -- 2 switch + { vendor_id = 0x115F, product_id = 0x1009, child_profile = "light-power-energy-powerConsumption" }, -- 4 switch } local detect_matter_thing @@ -257,6 +266,7 @@ local HELD_THRESHOLD = 1 local STATIC_BUTTON_PROFILE_SUPPORTED = {1, 2, 3, 4, 5, 6, 7, 8} local DEFERRED_CONFIGURE = "__DEFERRED_CONFIGURE" +local BUTTON_DEVICE_PROFILED = "__button_device_profiled" -- Some switches will send a MultiPressComplete event as part of a long press sequence. Normally the driver will create a -- button capability event on receipt of MultiPressComplete, but in this case that would result in an extra event because @@ -271,6 +281,7 @@ local SUPPORTS_MULTI_PRESS = "__multi_button" -- for MSM devices (MomentarySwitc local INITIAL_PRESS_ONLY = "__initial_press_only" -- for devices that support MS (MomentarySwitch), but not MSR (MomentarySwitchRelease) local HUE_MANUFACTURER_ID = 0x100B +local AQARA_MANUFACTURER_ID = 0x115F --helper function to create list of multi press values local function create_multi_press_values_list(size, supportsHeld) @@ -414,6 +425,13 @@ local function assign_child_profile(device, child_ep) for _, fingerprint in ipairs(child_device_profile_overrides) do if device.manufacturer_info.vendor_id == fingerprint.vendor_id and device.manufacturer_info.product_id == fingerprint.product_id then + if device.manufacturer_info.vendor_id == AQARA_MANUFACTURER_ID then + if child_ep ~= 1 then + -- To add Electrical Sensor only to the first EDGE_CHILD(light-power-energy-powerConsumption) + -- The profile of the second EDGE_CHILD is determined in the "for" loop below (e.g., light-binary) + break + end + end return fingerprint.child_profile end end @@ -437,6 +455,9 @@ local function assign_child_profile(device, child_ep) end local function do_configure(driver, device) + if device:get_field(BUTTON_DEVICE_PROFILED) then + return + end local energy_eps = embedded_cluster_utils.get_endpoints(device, clusters.ElectricalEnergyMeasurement.ID) local power_eps = embedded_cluster_utils.get_endpoints(device, clusters.ElectricalPowerMeasurement.ID) local valve_eps = embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID) @@ -539,6 +560,10 @@ local function initialize_switch(driver, device) end for _, ep in ipairs(switch_eps) do + if _ == 1 then + -- when energy management is defined in the root endpoint(0), replace it with the first switch endpoint and process it. + device:set_field(ENERGY_MANAGEMENT_ENDPOINT, ep) + end if device:supports_server_cluster(clusters.OnOff.ID, ep) then num_switch_server_eps = num_switch_server_eps + 1 if ep ~= main_endpoint then -- don't create a child device that maps to the main endpoint @@ -580,6 +605,7 @@ local function initialize_switch(driver, device) end device:try_update_metadata({profile = profile_name}) device:set_field(DEFERRED_CONFIGURE, true) + device:set_field(BUTTON_DEVICE_PROFILED, true) elseif #button_eps > 0 then local battery_support = false if device.manufacturer_info.vendor_id ~= HUE_MANUFACTURER_ID and @@ -600,6 +626,7 @@ local function initialize_switch(driver, device) if profile_name then device:try_update_metadata({profile = profile_name}) device:set_field(DEFERRED_CONFIGURE, true) + device:set_field(BUTTON_DEVICE_PROFILED, true) else configure_buttons(device) end @@ -681,7 +708,7 @@ local function device_init(driver, device) end local main_endpoint = find_default_endpoint(device) for _, ep in ipairs(device.endpoints) do - if ep.endpoint_id ~= main_endpoint and ep.endpoint_id ~= 0 then + if ep.endpoint_id ~= main_endpoint then local id = 0 for _, dt in ipairs(ep.device_types) do id = math.max(id, dt.device_type_id) @@ -1009,7 +1036,12 @@ local function cumul_energy_imported_handler(driver, device, ib, response) if ib.data.elements.energy then local watt_hour_value = ib.data.elements.energy.value / CONVERSION_CONST_MILLIWATT_TO_WATT device:set_field(TOTAL_IMPORTED_ENERGY, watt_hour_value) - device:emit_event(capabilities.energyMeter.energy({ value = watt_hour_value, unit = "Wh" })) + if ib.endpoint_id ~= 0 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.energyMeter.energy({ value = watt_hour_value, unit = "Wh" })) + else + -- when energy management is defined in the root endpoint(0), replace it with the first switch endpoint and process it. + device:emit_event_for_endpoint(device:get_field(ENERGY_MANAGEMENT_ENDPOINT), capabilities.energyMeter.energy({ value = watt_hour_value, unit = "Wh" })) + end end end @@ -1072,7 +1104,12 @@ end local function active_power_handler(driver, device, ib, response) if ib.data.value then local watt_value = ib.data.value / CONVERSION_CONST_MILLIWATT_TO_WATT - device:emit_event(capabilities.powerMeter.power({ value = watt_value, unit = "W"})) + if ib.endpoint_id ~= 0 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.powerMeter.power({ value = watt_value, unit = "W"})) + else + -- when energy management is defined in the root endpoint(0), replace it with the first switch endpoint and process it. + device:emit_event_for_endpoint(device:get_field(ENERGY_MANAGEMENT_ENDPOINT), capabilities.powerMeter.power({ value = watt_value, unit = "W"})) + end end end diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua new file mode 100644 index 0000000000..476ec36957 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua @@ -0,0 +1,333 @@ +-- Copyright 2024 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local utils = require "st.utils" +local dkjson = require "dkjson" + +local clusters = require "st.matter.clusters" +local button_attr = capabilities.button.button + +local DEFERRED_CONFIGURE = "__DEFERRED_CONFIGURE" + +local aqara_parent_ep = 4 +local aqara_child1_ep = 1 +local aqara_child2_ep = 2 + +local aqara_mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("4-button.yml"), + manufacturer_info = {vendor_id = 0x115F, product_id = 0x1009, product_name = "Aqara Light Switch H2"}, + label = "Aqara Light Switch", + device_id = "00000000-1111-2222-3333-000000000001", + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.ElectricalPowerMeasurement.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 2 }, + {cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 5 } + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1}, -- RootNode + {device_type_id = 0x0510, device_type_revision = 1} -- Electrical Sensor + } + }, + { + endpoint_id = aqara_child1_ep, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0100, device_type_revision = 1}, -- On/Off Light + } + }, + { + endpoint_id = aqara_child2_ep, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0100, device_type_revision = 1}, -- On/Off Light + } + }, + { + endpoint_id = aqara_parent_ep, + clusters = { + {cluster_id = clusters.Switch.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH} + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 5, + clusters = { + {cluster_id = clusters.Switch.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH} + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 6, + clusters = { + {cluster_id = clusters.Switch.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH} + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.Switch.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH} + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + } + } +}) + +local aqara_child_profiles = { + [aqara_child1_ep] = t_utils.get_profile_definition("light-power-energy-powerConsumption.yml"), + [aqara_child2_ep] = t_utils.get_profile_definition("light-binary.yml"), +} + +local aqara_mock_children = {} +for i, endpoint in ipairs(aqara_mock_device.endpoints) do + if endpoint.endpoint_id == aqara_child1_ep or endpoint.endpoint_id == aqara_child2_ep then + local child_data = { + profile = aqara_child_profiles[endpoint.endpoint_id], + device_network_id = string.format("%s:%d", aqara_mock_device.id, endpoint.endpoint_id), + parent_device_id = aqara_mock_device.id, + parent_assigned_child_key = string.format("%d", endpoint.endpoint_id) + } + aqara_mock_children[endpoint.endpoint_id] = test.mock_device.build_test_child_device(child_data) + end +end + +local cumulative_report_val_19 = { + energy = 19000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, +} + +local cumulative_report_val_29 = { + energy = 29000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, +} + +local cumulative_report_val_39 = { + energy = 39000, + start_timestamp = 0, + end_timestamp = 0, + start_systime = 0, + end_systime = 0, +} + +local function test_init() + local opts = { persist = true } + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + clusters.ElectricalPowerMeasurement.attributes.ActivePower, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(aqara_mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(aqara_mock_device)) + end + end + test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) + test.mock_device.add_test_device(aqara_mock_device) + + for _, child in pairs(aqara_mock_children) do + test.mock_device.add_test_device(child) + end + + aqara_mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Aqara Light Switch 1", + profile = "light-power-energy-powerConsumption", + parent_device_id = aqara_mock_device.id, + parent_assigned_child_key = string.format("%d", aqara_child1_ep) + }) + + aqara_mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Aqara Light Switch 2", + profile = "light-binary", + parent_device_id = aqara_mock_device.id, + parent_assigned_child_key = string.format("%d", aqara_child2_ep) + }) + test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "added" }) + test.mock_devices_api._expected_device_updates[aqara_mock_device.device_id] = "00000000-1111-2222-3333-000000000001" + test.mock_devices_api._expected_device_updates[1] = {device_id = "00000000-1111-2222-3333-000000000001"} + test.mock_devices_api._expected_device_updates[1].metadata = {deviceId="00000000-1111-2222-3333-000000000001", profileReference="4-button"} + + aqara_mock_device:set_field(DEFERRED_CONFIGURE, true, opts) + local device_info_copy = utils.deep_copy(aqara_mock_device.raw_st_data) + device_info_copy.profile.id = "4-button" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "infoChanged", device_info_json }) + test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) + + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("main", button_attr.pushed({state_change = false}))) + + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", button_attr.pushed({state_change = false}))) + + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", button_attr.pushed({state_change = false}))) + + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button4", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button4", button_attr.pushed({state_change = false}))) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Button/Switch device : button/switch capability should send the appropriate commands", + function() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 4, {new_position = 1}) + } + ) + + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) + ) + + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 5, {new_position = 1}) + } + ) + + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("button2", capabilities.button.button.pushed({state_change = true})) + ) + + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(aqara_mock_device, aqara_child1_ep, true) + } + ) + + test.socket.capability:__expect_send( + aqara_mock_children[aqara_child1_ep]:generate_test_message("main", capabilities.switch.switch.on()) + ) + + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(aqara_mock_device, aqara_child2_ep, true) + } + ) + + test.socket.capability:__expect_send( + aqara_mock_children[aqara_child2_ep]:generate_test_message("main", capabilities.switch.switch.on()) + ) + end +) + +test.register_coroutine_test( + "Check Energy/Power Management and powerConsumptionReport", + function() + test.socket.matter:__queue_receive( + { + -- don't use "aqara_mock_children[aqara_child1_ep].id," + -- because energy management is at the root endpoint. + aqara_mock_device.id, + clusters.ElectricalPowerMeasurement.attributes.ActivePower:build_test_report_data(aqara_mock_device, 1, 17000) + } + ) + + test.socket.capability:__expect_send( + -- when energy management is in the root endpoint, the event is sent to the first switch endpoint in CHILD_EDGE. + aqara_mock_children[aqara_child1_ep]:generate_test_message("main", capabilities.powerMeter.power({value = 17.0, unit="W"})) + ) + + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported:build_test_report_data(aqara_mock_device, 1, cumulative_report_val_19) + } + ) + + test.socket.capability:__expect_send( + aqara_mock_children[aqara_child1_ep]:generate_test_message("main", capabilities.energyMeter.energy({ value = 19.0, unit = "Wh" })) + ) + + -- in order to do powerConsumptionReport, CumulativeEnergyImported must be called twice. + -- This is because related variable settings are required in set_poll_report_timer_and_schedule(). + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported:build_test_report_data(aqara_mock_device, 1, cumulative_report_val_29) + } + ) + + test.socket.capability:__expect_send( + aqara_mock_children[aqara_child1_ep]:generate_test_message("main", capabilities.energyMeter.energy({ value = 29.0, unit = "Wh" })) + ) + + test.mock_time.advance_time(2000) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported:build_test_report_data( + aqara_mock_device, 1, cumulative_report_val_39 + ) + } + ) +--[[ + -- To do : powerConsumptionReport + test.socket.capability:__expect_send( + aqara_mock_children[aqara_child1_ep]:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:33:19Z", + deltaEnergy = 0.0, + energy = 29.0 + })) + ) +--]] + test.socket.capability:__expect_send( + aqara_mock_children[aqara_child1_ep]:generate_test_message("main", capabilities.energyMeter.energy({ value = 39.0, unit = "Wh" })) + ) + end +) + +test.run_registered_tests() + From a8db39b3fec72a82770dd0f68595f9182e5935f6 Mon Sep 17 00:00:00 2001 From: nickolas-deboom <158304111+nickolas-deboom@users.noreply.github.com> Date: Wed, 22 Jan 2025 15:43:34 -0600 Subject: [PATCH 4/5] Fix matter lock test broken by lua libs update (#1889) Add parameter to a test case in test_matter_lock_codes.lua to resolve a test case issue introduced by a recent lua libs change. --- .../SmartThings/matter-lock/src/test/test_matter_lock_codes.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_codes.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_codes.lua index e1775074ff..c672410296 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_codes.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_codes.lua @@ -354,6 +354,7 @@ test.register_message_test( nil, -- creator_fabric_index nil, -- last_modified_fabric_index 20, -- next_credential_index + nil, -- credential_data im.InteractionResponse.Status.FAILURE ), }, From 7171fd382137116d21e3130c23678a8248137d92 Mon Sep 17 00:00:00 2001 From: DongHoon-Ryu <54927879+DongHoon-Ryu@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:07:15 +0900 Subject: [PATCH 5/5] Add Aqara Climate Sensor W100 (#1843) * Add Aqara Climate Sensor W100 This device has a Temperature Sensor(0x0302), a Humidity Sensor(0x0307), and 3 Generic Switchs(0x000F). * It is modified to support w100 in Matter-switch, not Matter-sensor. * Revert "It is modified to support w100 in Matter-switch, not Matter-sensor." This reverts commit 511f361c2ebae6eebc062e3da4e8804517e5d3c5. * It is modified to support w100 in matter-switch, not matter-sensor. * It is modified to support w100 in matter-switch, not matter-sensor. * Use device:get_endpoints to delete the device's fingerprint and modify it to operate more dynamically --- .../3-button-battery-temperature-humidity.yml | 99 ++++ .../SmartThings/matter-switch/src/init.lua | 89 +++- .../test/test_aqara_climate_sensor_w100.lua | 472 ++++++++++++++++++ 3 files changed, 654 insertions(+), 6 deletions(-) create mode 100644 drivers/SmartThings/matter-switch/profiles/3-button-battery-temperature-humidity.yml create mode 100644 drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua diff --git a/drivers/SmartThings/matter-switch/profiles/3-button-battery-temperature-humidity.yml b/drivers/SmartThings/matter-switch/profiles/3-button-battery-temperature-humidity.yml new file mode 100644 index 0000000000..2fa77164ef --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/3-button-battery-temperature-humidity.yml @@ -0,0 +1,99 @@ +name: 3-button-battery-temperature-humidity +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: TempHumiditySensor +- id: button1 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +- id: button2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +- id: button3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true +deviceConfig: + dashboard: + states: + - component: main + capability: temperatureMeasurement + version: 1 + group: main + composite: true + - component: main + capability: relativeHumidityMeasurement + version: 1 + group: main + values: + - label: " {{humidity.value}} {{humidity.unit}}" + composite: true + actions: [] + basicPlus: [] + detailView: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: refresh + version: 1 + - component: button1 + capability: button + version: 1 + - component: button2 + capability: button + version: 1 + - component: button3 + capability: button + version: 1 + automation: + conditions: + - component: main + capability: temperatureMeasurement + version: 1 + - component: main + capability: relativeHumidityMeasurement + version: 1 + - component: main + capability: battery + version: 1 + - component: button1 + capability: button + version: 1 + - component: button2 + capability: button + version: 1 + - component: button3 + capability: button + version: 1 + actions: [] diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index 2ce8ab2bbe..1d4b05fe3c 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -280,6 +280,10 @@ local EMULATE_HELD = "__emulate_held" -- for non-MSR (MomentarySwitchRelease) de local SUPPORTS_MULTI_PRESS = "__multi_button" -- for MSM devices (MomentarySwitchMultiPress), create an event on receipt of MultiPressComplete local INITIAL_PRESS_ONLY = "__initial_press_only" -- for devices that support MS (MomentarySwitch), but not MSR (MomentarySwitchRelease) +local TEMP_BOUND_RECEIVED = "__temp_bound_received" +local TEMP_MIN = "__temp_min" +local TEMP_MAX = "__temp_max" + local HUE_MANUFACTURER_ID = 0x100B local AQARA_MANUFACTURER_ID = 0x115F @@ -528,7 +532,7 @@ local function initialize_switch(driver, device) table.sort(switch_eps) table.sort(button_eps) - local profile_name = nil + local profile_name = "" local component_map = {} local component_map_used = false @@ -539,7 +543,15 @@ local function initialize_switch(driver, device) -- that have been implemented as server. This can be removed when we have -- support for bindings. local num_switch_server_eps = 0 - local main_endpoint = find_default_endpoint(device) + local main_endpoint + local temperature_eps = device:get_endpoints(clusters.TemperatureMeasurement.ID) + local humidity_eps = device:get_endpoints(clusters.RelativeHumidityMeasurement.ID) + if #temperature_eps > 0 and #humidity_eps > 0 then + -- In case of Aqara Climate Sensor W100, in order to sequentially set the button name to button 1, 2, 3 + main_endpoint = device.MATTER_DEFAULT_ENDPOINT + else + main_endpoint = find_default_endpoint(device) + end -- If a switch endpoint is present, it will be the main endpoint and therefore the -- main component. If button endpoints are present, they will be added as @@ -613,17 +625,21 @@ local function initialize_switch(driver, device) battery_support = true end if #button_eps > 1 and tbl_contains(STATIC_BUTTON_PROFILE_SUPPORTED, #button_eps) then + if #temperature_eps > 0 and #humidity_eps > 0 then + device.log.debug("So far, it means Aqara Climate Sensor W100.") + profile_name = "-temperature-humidity" + end if battery_support then - profile_name = string.format("%d-button-battery", #button_eps) + profile_name = string.format("%d-button-battery", #button_eps) .. profile_name else - profile_name = string.format("%d-button", #button_eps) + profile_name = string.format("%d-button", #button_eps) .. profile_name end elseif not battery_support then -- a battery-less button/remote profile_name = "button" end - if profile_name then + if profile_name ~= "" then device:try_update_metadata({profile = profile_name}) device:set_field(DEFERRED_CONFIGURE, true) device:set_field(BUTTON_DEVICE_PROFILED, true) @@ -1199,6 +1215,49 @@ local function device_added(driver, device) device_init(driver, device) end +local function temperature_attr_handler(driver, device, ib, response) + local measured_value = ib.data.value + if measured_value ~= nil then + local temp = measured_value / 100.0 + local unit = "C" + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperature({value = temp, unit = unit})) + end +end + +local temp_attr_handler_factory = function(minOrMax) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local temp = ib.data.value / 100.0 + local unit = "C" + set_field_for_endpoint(device, TEMP_BOUND_RECEIVED..minOrMax, ib.endpoint_id, temp) + local min = get_field_for_endpoint(device, TEMP_BOUND_RECEIVED..TEMP_MIN, ib.endpoint_id) + local max = get_field_for_endpoint(device, TEMP_BOUND_RECEIVED..TEMP_MAX, ib.endpoint_id) + if min ~= nil and max ~= nil then + if min < max then + -- Only emit the capability for RPC version >= 5 (unit conversion for + -- temperature range capability is only supported for RPC >= 5) + if version.rpc >= 5 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = unit })) + end + set_field_for_endpoint(device, TEMP_BOUND_RECEIVED..TEMP_MIN, ib.endpoint_id, nil) + set_field_for_endpoint(device, TEMP_BOUND_RECEIVED..TEMP_MAX, ib.endpoint_id, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min temperature %d that is not lower than the reported max temperature %d", min, max)) + end + end + end +end + +local function humidity_attr_handler(driver, device, ib, response) + local measured_value = ib.data.value + if measured_value ~= nil then + local humidity = utils.round(measured_value / 100.0) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.relativeHumidityMeasurement.humidity(humidity)) + end +end + local matter_driver_template = { lifecycle_handlers = { init = device_init, @@ -1249,6 +1308,14 @@ local matter_driver_template = { }, [clusters.Switch.ID] = { [clusters.Switch.attributes.MultiPressMax.ID] = max_press_handler + }, + [clusters.RelativeHumidityMeasurement.ID] = { + [clusters.RelativeHumidityMeasurement.attributes.MeasuredValue.ID] = humidity_attr_handler + }, + [clusters.TemperatureMeasurement.ID] = { + [clusters.TemperatureMeasurement.attributes.MeasuredValue.ID] = temperature_attr_handler, + [clusters.TemperatureMeasurement.attributes.MinMeasuredValue.ID] = temp_attr_handler_factory(TEMP_MIN), + [clusters.TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = temp_attr_handler_factory(TEMP_MAX), } }, event = { @@ -1302,6 +1369,14 @@ local matter_driver_template = { }, [capabilities.powerMeter.ID] = { clusters.ElectricalPowerMeasurement.attributes.ActivePower + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue } }, subscribed_events = { @@ -1352,7 +1427,9 @@ local matter_driver_template = { capabilities.powerConsumptionReport, capabilities.valve, capabilities.button, - capabilities.battery + capabilities.battery, + capabilities.temperatureMeasurement, + capabilities.relativeHumidityMeasurement }, sub_drivers = { require("eve-energy"), diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua new file mode 100644 index 0000000000..9729c2f529 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua @@ -0,0 +1,472 @@ +-- Copyright 2024 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local utils = require "st.utils" +local dkjson = require "dkjson" + +local clusters = require "st.matter.generated.zap_clusters" +local button_attr = capabilities.button.button + +local DEFERRED_CONFIGURE = "__DEFERRED_CONFIGURE" + +-- Mock a 3-button device with temperature and humidity sensor +local aqara_mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("3-button-battery-temperature-humidity.yml"), + manufacturer_info = {vendor_id = 0x115F, product_id = 0x2004, product_name = "Aqara Climate Sensor W100"}, + label = "Climate Sensor W100", + device_id = "00000000-1111-2222-3333-000000000001", + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1}, -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0302, device_type_revision = 1}, + } + }, + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "BOTH"}, + }, + device_types = { + {device_type_id = 0x0307, device_type_revision = 1}, + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.Switch.ID, cluster_type = "SERVER", cluster_revision = 1, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.Feature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.Feature.MOMENTARY_SWITCH_LONG_PRESS, + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.Switch.ID, cluster_type = "SERVER", cluster_revision = 1, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.Feature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.Feature.MOMENTARY_SWITCH_LONG_PRESS, + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 5, + clusters = { + {cluster_id = clusters.Switch.ID, cluster_type = "SERVER", cluster_revision = 1, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.Feature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.Feature.MOMENTARY_SWITCH_LONG_PRESS, + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 6, + clusters = { + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", + feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY + } + }, + device_types = { + {device_type_id = 0x0011, device_type_revision = 1} + } + } + } +}) + +local function test_init() + local opts = { persist = true } + local cluster_subscribe_list = { + clusters.PowerSource.server.attributes.BatPercentRemaining, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + + local cluster_read_list = { + clusters.Switch.attributes.MultiPressMax + } + local read_request + + local subscribe_request = cluster_subscribe_list[1]:subscribe(aqara_mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(aqara_mock_device)) + end + end + + test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) + test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) + test.mock_device.add_test_device(aqara_mock_device) + test.set_rpc_version(5) + + test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "added" }) + test.mock_devices_api._expected_device_updates[aqara_mock_device.device_id] = "00000000-1111-2222-3333-000000000001" + test.mock_devices_api._expected_device_updates[1] = {device_id = "00000000-1111-2222-3333-000000000001"} + test.mock_devices_api._expected_device_updates[1].metadata = {deviceId="00000000-1111-2222-3333-000000000001", profileReference="3-button-battery-temperature-humidity"} + + aqara_mock_device:set_field(DEFERRED_CONFIGURE, true, opts) + local device_info_copy = utils.deep_copy(aqara_mock_device.raw_st_data) + device_info_copy.profile.id = "3-button-battery-temperature-humidity" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "infoChanged", device_info_json }) + test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) + + read_request = cluster_read_list[1]:read(aqara_mock_device, 3) + read_request:merge(cluster_read_list[1]:subscribe(aqara_mock_device)) + test.socket.matter:__expect_send({aqara_mock_device.id, read_request}) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button1", button_attr.pushed({state_change = false}))) + + read_request = cluster_read_list[1]:read(aqara_mock_device, 4) + read_request:merge(cluster_read_list[1]:subscribe(aqara_mock_device)) + test.socket.matter:__expect_send({aqara_mock_device.id, read_request}) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", button_attr.pushed({state_change = false}))) + + read_request = cluster_read_list[1]:read(aqara_mock_device, 5) + read_request:merge(cluster_read_list[1]:subscribe(aqara_mock_device)) + test.socket.matter:__expect_send({aqara_mock_device.id, read_request}) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", button_attr.pushed({state_change = false}))) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Temperature reports should generate correct messages", + function () + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.TemperatureMeasurement.server.attributes.MeasuredValue:build_test_report_data(aqara_mock_device, 1, 40*100) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) + ) + end +) + +test.register_coroutine_test( + "Min and max temperature attributes set capability constraint", + function () + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue:build_test_report_data(aqara_mock_device, 1, 500) + } + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_report_data(aqara_mock_device, 1, 4000) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 5.00, maximum = 40.00 }, unit = "C" })) + ) + end +) + +test.register_coroutine_test( + "Relative humidity reports should generate correct messages", + function () + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.RelativeHumidityMeasurement.server.attributes.MeasuredValue:build_test_report_data(aqara_mock_device, 2, 4049) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 40 })) + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.RelativeHumidityMeasurement.server.attributes.MeasuredValue:build_test_report_data(aqara_mock_device, 2, 4050) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 41 })) + ) + end +) + +test.register_coroutine_test( + "Battery percent reports should generate correct messages", + function () + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data(aqara_mock_device, 1, 150) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5))) + ) + end +) + +test.register_coroutine_test( + "Handle single press sequence for a long hold on long-release-capable button", -- only a long press event should generate a held event + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 3, {new_position = 1}) + } + ) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report(aqara_mock_device, 3, {previous_position = 0}) + } + ) + end +) + +test.register_coroutine_test( + "Handle single press sequence for a long hold on multi button", -- pushes should only be generated from multiPressComplete events + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 3, {new_position = 1}) + } + ) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report(aqara_mock_device, 3, {previous_position = 0}) + } + ) + end +) + +test.register_coroutine_test( + "Handle single press sequence for a multi press on multi button", + function() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 3, {new_position = 1}) + } + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report(aqara_mock_device, 3, {previous_position = 0}) + } + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 4, {new_position = 1}) + } + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report(aqara_mock_device, 4, {new_position = 1, current_number_of_presses_counted = 2}) + } + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report(aqara_mock_device, 4, {new_position = 0, total_number_of_presses_counted = 2, previous_position = 1}) + } + ) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", button_attr.double({state_change = true}))) + end +) + +test.register_coroutine_test( + "Handle long press sequence for a long hold on long-release-capable button", -- only a long press event should generate a held event + function () + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 3, {new_position = 1}) + } + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report(aqara_mock_device, 3, {new_position = 1}) + } + ) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button1", button_attr.held({state_change = true}))) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report(aqara_mock_device, 3, {previous_position = 0}) + } + ) + end +) + +test.register_coroutine_test( + "Handle long press sequence for a long hold on multi button", + function () + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 5, {new_position = 1}) + } + ) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report(aqara_mock_device, 5, {new_position = 1}) + } + ) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", button_attr.held({state_change = true}))) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report(aqara_mock_device, 5, {previous_position = 0}) + } + ) + end +) + +test.register_coroutine_test( + "Handle double press", + function() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report(aqara_mock_device, 3, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0}) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("button1", button_attr.double({state_change = true})) + ) + end +) + +test.register_coroutine_test( + "Receiving a max press attribute of 2 should emit correct event", + function() + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data(aqara_mock_device, 3, 2) + } + ) + test.socket.capability:__expect_send( + aqara_mock_device:generate_test_message("button1", capabilities.button.supportedButtonValues({"pushed", "double", "held"}, {visibility = {displayed = false}})) + ) + end +) + +test.register_coroutine_test( + "Handle single press sequence for emulated hold on short-release-only button", + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(aqara_mock_device, 4, {new_position = 1}) + } + ) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.matter:__queue_receive( + { + aqara_mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report(aqara_mock_device, 4, {previous_position = 0}) + } + ) + end +) + +test.register_message_test( + "Receiving a max press attribute of 3 should emit correct event", { + { + channel = "matter", + direction = "receive", + message = { + aqara_mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + aqara_mock_device, 5, 3 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = aqara_mock_device:generate_test_message("button3", + capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) + }, + } +) + +test.register_message_test( + "Receiving a max press attribute of greater than 6 should only emit up to pushed_6x", { + { + channel = "matter", + direction = "receive", + message = { + aqara_mock_device.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + aqara_mock_device, 3, 7 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = aqara_mock_device:generate_test_message("button1", + capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) + }, + } +) + +test.run_registered_tests() +