diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index a889663b92..935024d61c 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -1029,6 +1029,11 @@ matterManufacturer: vendorId: 0x1285 productId: 0x000A deviceProfileName: matter-bridge + - id: "4741/12" + deviceLabel: Hager Rotary Dimmer + vendorId: 0x1285 + productId: 0x000C + deviceProfileName: light-level # HAOJAI - id: "5530/4113" deviceLabel: HAOJAI Smart Switch 3-key diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua index 8df254bde8..959b6fda10 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua @@ -23,6 +23,7 @@ local PRODUCT_ID = { SWITCH_2G = 0x0006, PIR_1_1M = 0x0007, PIR_2_2M = 0x000A, + ROTARY_DIMMER = 0x000C } if version.api < 16 then @@ -33,6 +34,21 @@ local function subscribe (device, endpoint_id, cluster_id, attr_id, event_id) device:send(cluster_base.subscribe(device, endpoint_id, cluster_id, attr_id, event_id)) end +-- The Rotary Dimmer (0x000C) is a non-bridge type Hager Matter device, which +-- additionally supports manual mode transitions that enable/disable +-- functionality via a physical knob. Unlike bridge-type Hager devices, it has +-- no Aggregator device type on its root endpoint. This non-bridge device +-- requires explicit handling to: (1) process endpoint EP1 (normally filtered +-- out for bridge devices), (2) manage its own device type changes without a +-- parent-child hierarchy, endpoint mapping and event routing, (3) support +-- dynamic profile switching triggered by user interaction, as with the knob +-- mode switching from Dimmer to only On/Off. +local function is_non_bridge_type(device) + if switch_utils.get_product_override_field(device, "is_non_bridge_type") then + return true + end +end + local function get_parent (driver, device) return driver:get_device_info(device:get_field(PARENT_ID) or nil) end @@ -51,12 +67,14 @@ local function get_matter_device(device) return nil end -local function extract_reported_endpoints(ib) +local function extract_reported_endpoints(ib, device) local eps = {} if ib.data and ib.data.elements then for _, el in ipairs(ib.data.elements) do local ep_id = el.value - if type(ep_id) == "number" and ep_id ~= 1 and ep_id ~= 2 then + -- For bridge architecture HBnet devices: filter out endpoints 1-2 (reserved for hub infrastructure) + -- For simple non-bridge type HBnet devices: accept all endpoints including 1-2 (active application endpoints) + if type(ep_id) == "number" and ep_id ~= 1 and ep_id ~= 2 or is_non_bridge_type(device) then table.insert(eps, ep_id) end end @@ -188,6 +206,11 @@ local function info_changed(driver, device, event, args) return end + if is_non_bridge_type(device) then + device:subscribe() + return + end + if device.profile.id ~= args.old_st_store.profile.id or device.network_type == device_lib.NETWORK_TYPE_CHILD then device.thread:call_with_delay(3, function() local parent = device:get_parent_device() @@ -225,7 +248,7 @@ local function info_changed(driver, device, event, args) subscribe(device, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID) subscribe(device, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) end - if device.id == matter_device.id then + if not is_non_bridge_type(device) and device.id == matter_device.id then parent:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, map, { persist = true }) matter_device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, map, { persist = true }) end @@ -234,6 +257,13 @@ local function info_changed(driver, device, event, args) end local function device_init (driver, device) + if is_non_bridge_type(device) then + device:set_find_child(switch_utils.find_child) + device:set_field(PARENT_ID, device.id) + device:set_field(MATTER_DEVICE_ID, device.id) + subscribe(device, nil, clusters.Descriptor.ID, clusters.Descriptor.attributes.PartsList.ID, nil) + return + end if device.network_type ~= device_lib.NETWORK_TYPE_MATTER then device.thread:call_with_delay(4, function() info_changed(driver, device, nil, { @@ -265,7 +295,6 @@ local function device_init (driver, device) end) end) end - device:set_component_to_endpoint_fn(switch_utils.component_to_endpoint) device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) end @@ -274,21 +303,26 @@ local function handle_descriptor_report(driver, device, ib, response) local product_id = device.manufacturer_info and device.manufacturer_info.product_id local parent = get_parent(driver, device) - if not parent or ib.endpoint_id ~= 2 then - return + if is_non_bridge_type(device) then + if ib.endpoint_id ~= 0 then + return + end + else + if ib.endpoint_id ~= 2 or not parent then + return + end end - local matter_device = get_matter_device(device) + local matter_device = get_matter_device(device) or device + local new_eps = extract_reported_endpoints(ib, device) or {} - local new_eps = extract_reported_endpoints(ib) or {} table.sort(new_eps) - local removed_eps, added_eps = detect_endpoint_changes(device, new_eps) + local removed_eps, added_eps = detect_endpoint_changes(device, new_eps) device:set_field(ACTIVE_EPS, new_eps, { persist = true }) for _, ep_id in ipairs(added_eps or {}) do subscribe(parent, ep_id, clusters.Descriptor.ID, clusters.Descriptor.attributes.DeviceTypeList.ID, nil) - if product_id == PRODUCT_ID.SWITCH_1G and ep_id == 3 then matter_device:try_update_metadata({ profile = "light-binary" }) elseif product_id == PRODUCT_ID.SWITCH_2G then @@ -347,6 +381,7 @@ local function device_type_handler (driver, device, ib) end local parent = get_parent(driver, device) + if not parent then return end @@ -356,10 +391,19 @@ local function device_type_handler (driver, device, ib) local new_btn_eps = {} local ep_id = ib.endpoint_id local value = ib.data.elements + local reports_dimmable = false + + for _, element in ipairs(value or {}) do + local device_type_field = element.elements.device_type + if device_type_field and device_type_field.value == fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE then + reports_dimmable = true + end + end for _, v in ipairs(stored_btn_eps) do table.insert(new_btn_eps, v) end + for _, element in ipairs(value) do local device_type_field = element.elements.device_type local device_type_id = device_type_field and device_type_field.value @@ -396,11 +440,17 @@ local function device_type_handler (driver, device, ib) driver:try_delete_device(ep_id) end return + elseif ep_id == 1 and device.manufacturer_info.product_id == PRODUCT_ID.ROTARY_DIMMER then + if not reports_dimmable then + device:try_update_metadata({ profile = "light-binary" }) + end end create_child(driver, parent, { ep_id }, 1, assign_profile_for_endpoint(device_type_id)) elseif device_type_id == fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE then if ep_id == 4 and device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_1G then return + elseif ep_id == 1 and device.manufacturer_info.product_id == PRODUCT_ID.ROTARY_DIMMER then + device:try_update_metadata({ profile = "light-level" }) end create_child(driver, parent, { ep_id }, 1, assign_profile_for_endpoint(device_type_id)) elseif device_type_id == fields.DEVICE_TYPE_ID.WINDOW_COVERING then @@ -433,7 +483,7 @@ local function do_configure (driver, device) device:try_update_metadata({ profile = "4-button" }) elseif #bt_eps == 2 then device:try_update_metadata({ profile = "2-button" }) - elseif #lvl_eps > 0 and product_id == PRODUCT_ID.SWITCH_1G then + elseif #lvl_eps > 0 and product_id == PRODUCT_ID.SWITCH_1G or is_non_bridge_type(device) then device:try_update_metadata({ profile = "light-level" }) end device:set_field(BUTTON_EPS, bt_eps, { persist = true }) diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index d16214d2fd..ef52e7d65b 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -125,6 +125,7 @@ SwitchFields.vendor_overrides = { [0x0006] = { needs_hager_subdriver = true }, -- Hager HBnet 2g switch [0x0007] = { needs_hager_subdriver = true }, -- Hager HBnet PIR 1.1M [0x000A] = { needs_hager_subdriver = true }, -- Hager HBnet PIR 2.2M + [0x000C] = { needs_hager_subdriver = true, is_non_bridge_type = true }, -- Hager Rotary Dimmer }, [0x130A] = { -- EVE_MANUFACTURER_ID [0x0050] = { needs_eve_energy_subdriver = true }, -- Eve Energy EU diff --git a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua index 666550dfcf..632cfd9fce 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua @@ -18,6 +18,7 @@ local MATTER_DEVICE_ID = "MATTER_DEVICE_ID" local PARENT_ID = "PARENT_ID" local BUTTON_EPS = "__button_eps" local MAIN_WC_EP = "__main_wc_ep" +local ACTIVE_EPS = "__active_EPS" local function create_parent_device(product_id) return test.mock_device.build_test_matter_device({ @@ -509,6 +510,80 @@ local function create_hager_1g_relay(profile_name, parent) }) end +local function create_hager_rotary_dimmer(profile_name) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Rotary Dimmer", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x000C, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + }, + { + cluster_id = clusters.LevelControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = 254 + } + } + }, + device_types = { { device_type_id = 0x0101, device_type_revision = 1 } } + }, + } + }) +end + +local function create_hager_rotary_switch(profile_name) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Rotary Switch", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x000C, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + } + }, + device_types = { { device_type_id = 0x0100, device_type_revision = 1 } } + }, + } + }) +end + local function add_parent_device(parent) test.mock_device.add_test_device(parent) test.socket.device_lifecycle:__queue_receive({ parent.id, "added" }) @@ -591,7 +666,7 @@ local function configure_parent(device) end -- Create window covering child device on EP12 via PartsList and DeviceTypeList reports -local function announce_window_covering_child(host, matter_device) +local function announce_window_covering_child(host) test.socket.matter:__queue_receive({ host.id, clusters.Descriptor.attributes.PartsList:build_test_report_data(host, 2, data_types.Array({ @@ -634,6 +709,19 @@ local function announce_window_covering_child(host, matter_device) return child_wc end +local function add_standalone_device(device, expected_profile_change) + test.mock_device.add_test_device(device) + test.socket.device_lifecycle:__queue_receive({ device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ device.id, "doConfigure" }) + device:expect_metadata_update({ profile = expected_profile_change }) + device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ device.id, "init" }) + test.socket.matter:__expect_send({ + device.id, + cluster_base.subscribe(device, nil, descriptor.ID, descriptor.attributes.PartsList.ID, nil) + }) +end + local parent = create_parent_device(0x0006) -- 2G button product local parent_1g = create_parent_device(0x0005) -- 1G button product local parent_pir = create_parent_device(0x0007) -- PIR product @@ -1683,7 +1771,7 @@ test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile }) test.mock_device.add_test_device(child_wc) - initiate_info_changed(child_wc, "window-covering",parent) + initiate_info_changed(child_wc, "window-covering", parent) test.socket.matter:__expect_send({ parent.id, @@ -2115,5 +2203,87 @@ end, { min_api_version = 15 }) +test.register_coroutine_test("Test: Rotary Dimmer - Standalone Initialization Subscribes To PartsList And Sets light-level", function() + test.socket.matter:__set_channel_ordering("relaxed") + local rotary = create_hager_rotary_dimmer("light-level") + add_standalone_device(rotary, "light-level") + test.wait_for_events() + + local button_eps = rotary:get_field(BUTTON_EPS) + assert(#button_eps == 0, "BUTTON_EPS is empty for a rotary dimmer") +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: Rotary Dimmer - EP1 Dimmable Device Type Sets light-level And Creates Child", function() + test.socket.matter:__set_channel_ordering("relaxed") + local rotary = create_hager_rotary_dimmer("light-level") + add_standalone_device(rotary, "light-level") + test.wait_for_events() + + test.socket.matter:__queue_receive({ + rotary.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(rotary, 0, data_types.Array({ + data_types.Uint16(1), + })) + }) + test.socket.matter:__expect_send({ + rotary.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(rotary, 1) + }) + test.wait_for_events() + + local active_eps = rotary:get_field(ACTIVE_EPS) + assert(active_eps[1] == 1, "ACTIVE_EPS contains endpoint 1") + + test.socket.matter:__queue_receive({ + rotary.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(rotary, 1, data_types.Array { + { + device_type = data_types.Uint32(257), + revision = data_types.Uint16(1) + } + }) + }) + rotary:expect_metadata_update({ profile = "light-level" }) + test.wait_for_events() +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: Rotary Dimmer - EP1 OnOff Device Type Sets light-binary And Creates Child", function() + test.socket.matter:__set_channel_ordering("relaxed") + local rotary = create_hager_rotary_switch("light-level") + add_standalone_device(rotary, "light-level") + test.wait_for_events() + + test.socket.matter:__queue_receive({ + rotary.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(rotary, 0, data_types.Array({ + data_types.Uint16(1), + })) + }) + test.socket.matter:__expect_send({ + rotary.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(rotary, 1) + }) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + rotary.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(rotary, 1, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + rotary:expect_metadata_update({ profile = "light-binary" }) +end, + { + min_api_version = 15 + }) test.run_registered_tests()