diff --git a/drivers/SmartThings/zigbee-valve/fingerprints.yml b/drivers/SmartThings/zigbee-valve/fingerprints.yml index 31ea03cd3e..dc5331ada9 100644 --- a/drivers/SmartThings/zigbee-valve/fingerprints.yml +++ b/drivers/SmartThings/zigbee-valve/fingerprints.yml @@ -59,3 +59,13 @@ zigbeeManufacturer : manufacturer: SONOFF model: SWV-ZNU deviceProfileName: valve-battery + - id: SONOFF/SWV-ZF2U + deviceLabel: SONOFF Water Valve 2 + manufacturer: SONOFF + model: SWV-ZF2U + deviceProfileName: valve-battery-2 + - id: SONOFF/SWV-ZF2 + deviceLabel: SONOFF Water Valve 2 + manufacturer: SONOFF + model: SWV-ZF2 + deviceProfileName: valve-battery-2 diff --git a/drivers/SmartThings/zigbee-valve/profiles/valve-battery-2.yml b/drivers/SmartThings/zigbee-valve/profiles/valve-battery-2.yml new file mode 100644 index 0000000000..484ad7acd2 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/profiles/valve-battery-2.yml @@ -0,0 +1,21 @@ +name: valve-battery-2 +components: +- id: main + capabilities: + - id: valve + version: 1 + - id: battery + version: 1 + - id: powerSource + version: 1 + config: + values: + - key: "powerSource.value" + enabledValues: + - battery + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterValve diff --git a/drivers/SmartThings/zigbee-valve/profiles/valve.yml b/drivers/SmartThings/zigbee-valve/profiles/valve.yml new file mode 100644 index 0000000000..4503c347fe --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/profiles/valve.yml @@ -0,0 +1,10 @@ +name: valve +components: +- id: main + capabilities: + - id: valve + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterValve diff --git a/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua b/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua index 90e56b541c..18174e338a 100644 --- a/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua +++ b/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua @@ -2,10 +2,12 @@ -- Licensed under the Apache License, Version 2.0 local FINGERPRINTS = { + { mfr = "SONOFF", model = "SWV-ZF2U" }, + { mfr = "SONOFF", model = "SWV-ZF2" }, { mfr = "SONOFF", model = "SWV-ZFU" }, { mfr = "SONOFF", model = "SWV-ZFE" }, { mfr = "SONOFF", model = "SWV-ZNU" }, - { mfr = "SONOFF", model = "SWV-ZNE" }, + { mfr = "SONOFF", model = "SWV-ZNE" } } local function sonoff_can_handle(opts, driver, device, ...) diff --git a/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua b/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua index 8bc14a84ef..bd7ff4a070 100644 --- a/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua +++ b/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua @@ -2,49 +2,131 @@ -- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" +local log = require "log" local zcl_clusters = require "st.zigbee.zcl.clusters" +local Basic = zcl_clusters.Basic local OnOff = zcl_clusters.OnOff local PowerConfiguration = zcl_clusters.PowerConfiguration +local st_device = require "st.device" 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()) +local CONFIGURATION = { + { + cluster = PowerConfiguration.ID, + attribute = PowerConfiguration.attributes.BatteryPercentageRemaining.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = PowerConfiguration.attributes.BatteryPercentageRemaining.base_type, + reportable_change = 1 + }, + { + cluster = OnOff.ID, + attribute = OnOff.attributes.OnOff.ID, + minimum_interval = 0, + maximum_interval = 600, + data_type = OnOff.attributes.OnOff.base_type + } +} + +local function find_child(parent, ep_id) + return parent:get_child_by_parent_assigned_key(string.format("%02X", ep_id)) +end + +local function is_dual_channel(device) + local model = device:get_model() + return model == "SWV-ZF2U" or model == "SWV-ZF2" +end + +--- Map OnOff reports to the corresponding valve. +local function onoff_attr_handler(driver, device, value, zb_rx) + local ep = zb_rx.address_header.src_endpoint.value + local target = device + if is_dual_channel(device) and ep == 0x02 then + local child = find_child(device, 2) + if child then target = child end + end + log.info(string.format("[SWV] OnOff report ep=%s value=%s", tostring(ep), tostring(value.value))) + if value.value ~= false and value.value ~= 0 then + target:emit_event(capabilities.valve.valve.open()) else - device:emit_event(capabilities.valve.valve.closed()) + target: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 +--- Zigbee BatteryPercentageRemaining is measured in half-percent units. 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)) + device:emit_event(capabilities.battery.battery(utils.round(value.value / 2))) +end + +local function device_init(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZIGBEE then + if is_dual_channel(device) then + device:set_find_child(find_child) + end + for _, attribute in ipairs(CONFIGURATION) do + device:add_configured_attribute(attribute) + end + end +end + +local function do_configure(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZIGBEE then + device:refresh() + device:configure() + end +end + +local function device_added(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZIGBEE and is_dual_channel(device) then + if find_child(device, 2) == nil then + driver:try_create_device({ + type = "EDGE_CHILD", + label = string.format("%s 2", device.label), + profile = "valve", + parent_device_id = device.id, + parent_assigned_child_key = string.format("%02X", 2), + vendor_provided_label = string.format("%s 2", device.label), + }) + end + end + device:refresh() +end + +local function driver_switched(driver, device) + device_added(driver, device) +end + +--- A device button press prompts a refresh of both SWV2C channels. +local function identify_handler(driver, device, zb_rx) + if not is_dual_channel(device) then return end + log.info("[SWV] Identify received, syncing valve states") + device.thread:call_with_delay(2, function() + device:send(OnOff.attributes.OnOff:read(device)) + device:send(OnOff.attributes.OnOff:read(device):to_endpoint(0x02)) + end) end local sonoff_valve_handler = { NAME = "SONOFF Water Valve Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + driverSwitched = driver_switched, + }, 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 } + }, + cluster = { + [Basic.ID] = { + [Basic.server.commands.ResetToFactoryDefaults.ID] = identify_handler + } } }, can_handle = require "sonoff.can_handle", diff --git a/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve_2.lua b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve_2.lua new file mode 100644 index 0000000000..72beb424d9 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve_2.lua @@ -0,0 +1,386 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local Basic = clusters.Basic +local OnOff = clusters.OnOff +local PowerConfiguration = clusters.PowerConfiguration +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +-- Parent device (endpoint 1): valve + battery + powerSource + firmwareUpdate + refresh +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("valve-battery-2.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "SWV-ZF2U", + server_clusters = { 0x0000, 0x0001, 0x0006, 0x0404, 0xFC11 } + }, + [2] = { + id = 2, + manufacturer = "SONOFF", + model = "SWV-ZF2U", + server_clusters = { 0x0006 } + } + } + } +) + +-- Child device (endpoint 2): valve + refresh only; no battery or power source +local mock_child = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("valve.yml"), + device_network_id = string.format("%04X:%02X", mock_device:get_short_address(), 2), + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%02X", 2) +}) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_child) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +local function test_init_without_child() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +-- ============================================================================ +-- Parent device (endpoint 1) tests: OnOff attribute reports -> valve events +-- ============================================================================ + +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()) + } + } +) + +-- ============================================================================ +-- Child device (endpoint 2) tests: OnOff attribute reports -> child valve events +-- ============================================================================ + +test.register_message_test( + "OnOff(on) from endpoint 2 should set child valve to open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_child.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_child:generate_test_message("main", capabilities.valve.valve.open()) + } + } +) + +test.register_message_test( + "OnOff(off) from endpoint 2 should set child valve to closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_child.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_child:generate_test_message("main", capabilities.valve.valve.closed()) + } + } +) + +-- ============================================================================ +-- Parent device battery and power source tests +-- ============================================================================ + +-- Battery percentage +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)) + } + } +) + +-- PowerSource(battery) +test.register_message_test( + "PowerSource(battery) reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Basic.attributes.PowerSource:build_test_attr_report(mock_device, + 0x03) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) + } + } +) + +-- ============================================================================ +-- Parent device (endpoint 1) valve command tests +-- ============================================================================ + +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) } + } + } +) + +-- ============================================================================ +-- Child device (endpoint 2) valve command tests +-- ============================================================================ + +test.register_message_test( + "Child valve command(open) should send OnOff.On to endpoint 2", + { + { + channel = "capability", + direction = "receive", + message = { mock_child.id, { capability = "valve", component = "main", command = "open", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x02) } + } + } +) + +test.register_message_test( + "Child valve command(close) should send OnOff.Off to endpoint 2", + { + { + channel = "capability", + direction = "receive", + message = { mock_child.id, { capability = "valve", component = "main", command = "close", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x02) } + } + } +) + +-- ============================================================================ +-- doConfigure lifecycle tests +-- ============================================================================ + +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, + OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource: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) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID, 2):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 600, 0):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Basic.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:configure_reporting(mock_device, 5, 600) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +-- ============================================================================ +-- Refresh tests +-- ============================================================================ + +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, Basic.attributes.PowerSource:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.attributes.OnOff:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +-- ============================================================================ +-- Device added lifecycle tests: child-device creation and refresh +-- ============================================================================ + +test.register_coroutine_test( + "added lifecycle should create child device and refresh parent states", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + -- Verify child-device creation + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = string.format("%s 2", mock_device.label), + profile = "valve", + parent_device_id = mock_device.id, + parent_assigned_child_key = "02" + }) + + -- Verify that refresh reads parent-device attributes + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource: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, + OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + end, + { test_init = test_init_without_child } +) + +test.run_registered_tests()