From 2b1511ae86a61d8954786213ec7ad356bf300e56 Mon Sep 17 00:00:00 2001 From: "Jan Jakubiszyn/SmartThings Development (BJ.jakubiszynS) /SRPOL/Engineer/Samsung Electronics" Date: Mon, 7 Sep 2026 20:19:24 +0200 Subject: [PATCH 1/2] Hager rotary dimmer implementation # Conflicts: # drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua --- .../matter-switch/fingerprints.yml | 5 + .../src/sub_drivers/hager/init.lua | 72 ++++++-- .../matter-switch/src/switch_utils/fields.lua | 1 + .../src/test/test_hager_waasys.lua | 174 +++++++++++++++++- 4 files changed, 239 insertions(+), 13 deletions(-) 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() From 0e499ad226c97eac81ed48a90afac976ae5f11a7 Mon Sep 17 00:00:00 2001 From: yanggx24 Date: Mon, 12 Oct 2026 21:34:33 +0800 Subject: [PATCH 2/2] [WWSTCERT-13446, WWSTCERT-13450, WWSTCERT-13454, WWSTCERT-13458] SONOFF/SWV1C (#3157) * feat:sonoff swv valve * Zigbee Valve:modify comment language and file name * Zigbee Valve:Add Sonoff valve subdriver lazy loading * test: fix zigbee valve lazy loading coverage * Fix SONOFF valve command handling * Configure Sonoff valve battery reporting * chore: simplify zigbee-valve license header * chore: remove test_lazy_load_subdriver.lua * Use default SONOFF valve reporting config * fix(zigbee-valve): restore default battery reporting configuration * Fix SONOFF valve command test expectations * Remove unnecessary line breaks in test_sonoff_valve.lua * Update drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua Co-authored-by: Konrad K <33450498+KKlimczukS@users.noreply.github.com> * Update drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua Co-authored-by: Konrad K <33450498+KKlimczukS@users.noreply.github.com> * Remove redundant comments from the SONOFF test file --------- Co-authored-by: Konrad K <33450498+KKlimczukS@users.noreply.github.com> --- .../SmartThings/zigbee-valve/fingerprints.yml | 22 +- .../zigbee-valve/profiles/valve-battery.yml | 14 + drivers/SmartThings/zigbee-valve/src/init.lua | 1 - .../zigbee-valve/src/sonoff/can_handle.lua | 20 ++ .../zigbee-valve/src/sonoff/init.lua | 53 ++++ .../zigbee-valve/src/sub_drivers.lua | 1 + .../src/test/test_sonoff_valve.lua | 251 ++++++++++++++++++ 7 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 drivers/SmartThings/zigbee-valve/profiles/valve-battery.yml create mode 100644 drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-valve/src/sonoff/init.lua create mode 100644 drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua diff --git a/drivers/SmartThings/zigbee-valve/fingerprints.yml b/drivers/SmartThings/zigbee-valve/fingerprints.yml index 6c9678452d..31ea03cd3e 100644 --- a/drivers/SmartThings/zigbee-valve/fingerprints.yml +++ b/drivers/SmartThings/zigbee-valve/fingerprints.yml @@ -39,5 +39,23 @@ zigbeeManufacturer : manufacturer: Compacta model: ZBVC1(1023A) deviceProfileName: valve-battery-powerSource - - + - id: SONOFF/SWV-ZFE + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZFE + deviceProfileName: valve-battery + - id: SONOFF/SWV-ZFU + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZFU + deviceProfileName: valve-battery + - id: SONOFF/SWV-ZNE + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZNE + deviceProfileName: valve-battery + - id: SONOFF/SWV-ZNU + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZNU + deviceProfileName: valve-battery diff --git a/drivers/SmartThings/zigbee-valve/profiles/valve-battery.yml b/drivers/SmartThings/zigbee-valve/profiles/valve-battery.yml new file mode 100644 index 0000000000..9aa59a8482 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/profiles/valve-battery.yml @@ -0,0 +1,14 @@ +name: valve-battery +components: +- id: main + capabilities: + - id: valve + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories : + - name: WaterValve diff --git a/drivers/SmartThings/zigbee-valve/src/init.lua b/drivers/SmartThings/zigbee-valve/src/init.lua index 717012e2bb..b3e250cbd3 100644 --- a/drivers/SmartThings/zigbee-valve/src/init.lua +++ b/drivers/SmartThings/zigbee-valve/src/init.lua @@ -1,7 +1,6 @@ -- Copyright 2022 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 - local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" diff --git a/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua b/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua new file mode 100644 index 0000000000..90e56b541c --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua @@ -0,0 +1,20 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "SONOFF", model = "SWV-ZFU" }, + { mfr = "SONOFF", model = "SWV-ZFE" }, + { mfr = "SONOFF", model = "SWV-ZNU" }, + { mfr = "SONOFF", model = "SWV-ZNE" }, +} + +local function sonoff_can_handle(opts, driver, device, ...) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require "sonoff" + end + end + return false +end + +return sonoff_can_handle diff --git a/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua b/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua new file mode 100644 index 0000000000..8bc14a84ef --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua @@ -0,0 +1,53 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local OnOff = zcl_clusters.OnOff +local PowerConfiguration = zcl_clusters.PowerConfiguration +local utils = require "st.utils" + +--- OnOff Property Reporting Handler → Valve Capability Point Event +--- SONOFF reports valve state through the OnOff cluster, so keep an explicit mapping +--- from OnOff reports to valve events while using the parent driver's default commands. +--- @param driver table Driver instance +--- @param device table Device instance +--- @param value table Zigbee attribute value +local function onoff_attr_handler(driver, device, value) + local is_on = value.value ~= false and value.value ~= 0 + if is_on then + device:emit_event(capabilities.valve.valve.open()) + else + device:emit_event(capabilities.valve.valve.closed()) + end +end + +--- Battery Percentage Attribute Handler +--- Zigbee BatteryPercentageRemaining range 0-200 (0%-100%), needs to be divided by 2 +--- @param driver table Driver instance +--- @param device table Device instance +--- @param value table Zigbee attribute value +local function battery_percentage_handler(driver, device, value) + local raw = value.value + local percent = utils.round(raw / 2) + device:emit_event(capabilities.battery.battery(percent)) +end + +local sonoff_valve_handler = { + NAME = "SONOFF Water Valve Handler", + zigbee_handlers = { + attr = { + -- OnOff property reporting → valve event (to compensate for the defect of the parent driver's default mapping being skipped) + [OnOff.ID] = { + [OnOff.attributes.OnOff.ID] = onoff_attr_handler + }, + -- Battery percentage attribute reporting → battery event + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_percentage_handler + } + } + }, + can_handle = require "sonoff.can_handle", +} + +return sonoff_valve_handler diff --git a/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua b/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua index 258579c7fb..649c5fc898 100644 --- a/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua @@ -5,5 +5,6 @@ local lazy_load_if_possible = require "lazy_load_subdriver" local sub_drivers = { lazy_load_if_possible("sinope"), lazy_load_if_possible("ezex"), + lazy_load_if_possible("sonoff"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua new file mode 100644 index 0000000000..93fd9ad153 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua @@ -0,0 +1,251 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local OnOff = clusters.OnOff +local PowerConfiguration = clusters.PowerConfiguration +local capabilities = require "st.capabilities" +local data_types = require "st.zigbee.data_types" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("valve-battery.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "SWV-ZFE", + server_clusters = {0x0000, 0x0001, 0x0006, 0x0404, 0xFC11} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "OnOff(on) should set valve to open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, true) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) + } + } +) + +test.register_message_test( + "OnOff(off) should set valve to closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, false) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) + } + } +) + +local uint8_onoff_off_report = OnOff.attributes.OnOff:build_test_attr_report(mock_device, false) +uint8_onoff_off_report.body.zcl_body.attr_records[1].data_type.value = data_types.Uint8.ID + +test.register_message_test( + "OnOff(off) report with Uint8 data type should set valve to closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, uint8_onoff_off_report } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) + } + } +) + +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) + } + } +) + +test.register_coroutine_test( + "Init lifecycle should use default battery percentage reporting configuration", + function () + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 600, 0) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_message_test( + "Capability(valve) command(open) should send OnOff.On", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "valve", component = "main", command = "open", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device) } + } + } +) + +test.register_message_test( + "Capability(valve) command(close) should send OnOff.Off", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "valve", component = "main", command = "close", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device) } + } + } +) + +test.register_coroutine_test( + "doConfigure lifecycle should configure device", + function () + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 600, 0) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.attributes.OnOff:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +test.register_message_test( + "Device added event should refresh device states", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_device.id, "added" }, + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.attributes.OnOff:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +test.run_registered_tests()