diff --git a/drivers/SmartThings/zigbee-valve/fingerprints.yml b/drivers/SmartThings/zigbee-valve/fingerprints.yml index 6c9678452d..f663f95236 100644 --- a/drivers/SmartThings/zigbee-valve/fingerprints.yml +++ b/drivers/SmartThings/zigbee-valve/fingerprints.yml @@ -39,5 +39,14 @@ zigbeeManufacturer : manufacturer: Compacta model: ZBVC1(1023A) deviceProfileName: valve-battery-powerSource - + - 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/init.lua b/drivers/SmartThings/zigbee-valve/src/init.lua index 717012e2bb..113f8bce99 100644 --- a/drivers/SmartThings/zigbee-valve/src/init.lua +++ b/drivers/SmartThings/zigbee-valve/src/init.lua @@ -1,6 +1,16 @@ --- Copyright 2022 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - +-- Copyright 2022 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 ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" @@ -20,6 +30,7 @@ local function device_added(self, device) end local zigbee_valve_driver_template = { + health_check = false, supported_capabilities = { valve, battery, 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..2b0a4f6e9d --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua @@ -0,0 +1,21 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "SONOFF", model = "SWV-ZF2U" }, + { mfr = "SONOFF", model = "SWV-ZF2" } +} + +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 + if device.parent_device_id ~= nil then + return true, require "sonoff" + 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..69a604bf0f --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua @@ -0,0 +1,140 @@ +-- Copyright 2026 SmartThings, Inc. +-- 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" + +local BATTERY_POLL_INTERVAL = 7200 + +local function find_child(parent, ep_id) + return parent:get_child_by_parent_assigned_key(string.format("%02X", ep_id)) +end + +--- OnOff Property Reporting → Valve Capability Point Event +local function onoff_attr_handler(driver, device, value, zb_rx) + local ep = zb_rx.address_header.src_endpoint.value + local target = device + if 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 == true or value.value == 1 then + target:emit_event(capabilities.valve.valve.open()) + else + target:emit_event(capabilities.valve.valve.closed()) + end +end + +--- Battery Percentage Attribute Handler +local function battery_percentage_handler(driver, device, value) + device:emit_event(capabilities.battery.battery(utils.round(value.value / 2))) +end + +--- Lifecycle initialization handler +local function device_init(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZIGBEE then + device:set_find_child(find_child) + device.thread:call_on_schedule( + BATTERY_POLL_INTERVAL, + function() + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) + end + ) + end +end + +--- doConfigure +local function do_configure(driver, device) + device:try_update_metadata({ provisioning_state = "PROVISIONED" }) + if device.network_type == st_device.NETWORK_TYPE_ZIGBEE then + device:emit_event(capabilities.valve.valve.closed()) + device:emit_event(capabilities.battery.battery(100)) + else + device:emit_event(capabilities.valve.valve.closed()) + end +end + +--- added +local function device_added(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZIGBEE 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 + else + device:emit_event(capabilities.valve.valve.closed()) + end +end + +--- driverSwitched +local function driver_switched(driver, device) + device_added(driver, device) +end + +--- valve.open +local function valve_open_handler(driver, device, command) + device:send(OnOff.server.commands.On(device)) + device:send(OnOff.attributes.OnOff:read(device)) +end + +--- valve.close +local function valve_close_handler(driver, device, command) + device:send(OnOff.server.commands.Off(device)) + device:send(OnOff.attributes.OnOff:read(device)) +end + +--- Identity cluster handler:button pressed on the device, sync valve states +local function identify_handler(driver, device, zb_rx) + 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, + }, + capability_handlers = { + [capabilities.valve.ID] = { + [capabilities.valve.commands.open.NAME] = valve_open_handler, + [capabilities.valve.commands.close.NAME] = valve_close_handler, + } + }, + zigbee_handlers = { + attr = { + [OnOff.ID] = { + [OnOff.attributes.OnOff.ID] = onoff_attr_handler + }, + [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", +} + +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_2.lua b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve_2.lua new file mode 100644 index 0000000000..1a924f1e85 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve_2.lua @@ -0,0 +1,379 @@ +-- 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" + +-- 父设备(端点1):valve + battery + powerSource + firmwareUpdate + refresh +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("sonoff-irrigation-2.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "SWV-ZF2E", + server_clusters = { 0x0000, 0x0001, 0x0006, 0x0404, 0xFC11 } + }, + [2] = { + id = 2, + manufacturer = "SONOFF", + model = "SWV-ZF2E", + server_clusters = { 0x0006 } + } + } + } +) + +-- 子设备(端点2):只有 valve + refresh,无电池/电源 +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) + +-- ============================================================================ +-- 父设备(端点1)测试:OnOff 属性上报 → valve 事件 +-- ============================================================================ + +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()) + } + } +) + +-- ============================================================================ +-- 子设备(端点2)测试:OnOff 属性上报 → 子设备 valve 事件 +-- ============================================================================ + +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()) + } + } +) + +-- ============================================================================ +-- 父设备电池/电源测试 +-- ============================================================================ + +-- 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()) + } + } +) + +-- ============================================================================ +-- 父设备(端点1)valve 命令测试 +-- ============================================================================ + +test.register_message_test( + "Capability(valve) command(open) should send OnOff.On and read OnOff", + { + { + 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) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.attributes.OnOff:read(mock_device) } + } + } +) + +test.register_message_test( + "Capability(valve) command(close) should send OnOff.Off and read OnOff", + { + { + 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) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.attributes.OnOff:read(mock_device) } + } + } +) + +-- ============================================================================ +-- 子设备(端点2)valve 命令测试 +-- ============================================================================ + +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) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.attributes.OnOff:read(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) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) } + } + } +) + +-- ============================================================================ +-- doConfigure 生命周期测试 +-- ============================================================================ + +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, + 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, 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 测试 +-- ============================================================================ + +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, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +-- ============================================================================ +-- Device added 生命周期测试(验证子设备创建 + 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" }) + + -- 验证子设备创建 + 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" + }) + + -- 验证 refresh 读取父设备属性 + 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, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + end +) + +test.run_registered_tests()