diff --git a/drivers/SmartThings/zigbee-valve/fingerprints.yml b/drivers/SmartThings/zigbee-valve/fingerprints.yml index 6c9678452d..327a1e948e 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: sonoff-irrigation + - id: SONOFF/SWV-ZFU + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZFU + deviceProfileName: sonoff-irrigation + - id: SONOFF/SWV-ZNE + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZNE + deviceProfileName: sonoff-irrigation + - id: SONOFF/SWV-ZNU + deviceLabel: SONOFF Water Valve + manufacturer: SONOFF + model: SWV-ZNU + deviceProfileName: sonoff-irrigation 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..b0009ff14c 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" @@ -8,6 +18,7 @@ local defaults = require "st.zigbee.defaults" --ZCL local zcl_clusters = require "st.zigbee.zcl.clusters" local Basic = zcl_clusters.Basic +local PowerConfiguration = zcl_clusters.PowerConfiguration --Capability local capabilities = require "st.capabilities" local battery = capabilities.battery @@ -27,6 +38,17 @@ local zigbee_valve_driver_template = { refresh }, cluster_configurations = { + [battery.ID] = { + { + cluster = PowerConfiguration.ID, + attribute = PowerConfiguration.attributes.BatteryPercentageRemaining.ID, + minimum_interval = 300, + maximum_interval = 900, + data_type = PowerConfiguration.attributes.BatteryPercentageRemaining.base_type, + reportable_change = 2, + configurable = true + } + }, [powerSource.ID] = { { cluster = Basic.ID, @@ -41,9 +63,10 @@ local zigbee_valve_driver_template = { lifecycle_handlers = { added = device_added }, - sub_drivers = require("sub_drivers"), - health_check = false, - shared_device_thread_enabled = true, + sub_drivers = { + require("sinope"), + require("sonoff") + } } defaults.register_for_default_handlers(zigbee_valve_driver_template, zigbee_valve_driver_template.supported_capabilities) 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..0635a81ed1 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua @@ -0,0 +1,118 @@ +-- 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" + +-- Battery Polling Interval (seconds): SWV1C is a battery sleep device, polling every 2 hours +local BATTERY_POLL_INTERVAL = 7200 + +-- SWV1C Fingerprint list +local FINGERPRINTS = { + { mfr = "SONOFF", model = "SWV-ZFU" }, + { mfr = "SONOFF", model = "SWV-ZFE" }, + { mfr = "SONOFF", model = "SWV-ZNU" }, + { mfr = "SONOFF", model = "SWV-ZNE" } +} + +--- OnOff Property Reporting Handler → Valve Capability Point Event +--- It must be handled explicitly because the child driver defines capability_handlers. +--- The default OnOff→valve mapping from the parent driver will be skipped (only OnOff→switch remains) +--- @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 + +--- Lifecycle initialization handler +--- Ensures battery data is available by periodically actively reading BatteryPercentageRemaining +--- @param driver table Driver instance +--- @param device table Device instance +local function device_init(driver, device) + device.thread:call_on_schedule( + BATTERY_POLL_INTERVAL, + function() + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) + end + ) +end + +--- valve.open ability handler +--- @param driver table driver instance +--- @param device table device instance +--- @param command table ability command +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 capability handler +--- @param driver table driver instance +--- @param device table device instance +--- @param command table capability command +local function valve_close_handler(driver, device, command) + device:send(OnOff.server.commands.Off(device)) + device:send(OnOff.attributes.OnOff:read(device)) +end + +--- Device Matching Check +--- @param opts table Options +--- @param driver table Driver instance +--- @param device table Device instance +--- @return boolean Whether this sub-driver takes over +local function is_sonoff_valve(opts, driver, device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true + end + end + return false +end + +local sonoff_valve_handler = { + NAME = "SONOFF Water Valve Handler", + lifecycle_handlers = { + init = device_init + }, + 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 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 = is_sonoff_valve +} + +return sonoff_valve_handler 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..6651d3ca1e --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua @@ -0,0 +1,281 @@ +-- 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 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("sonoff-irrigation.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) + +-- OnOff(on) → valve.open +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()) + } + } +) + +-- OnOff(off) → valve.closed +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()) + } + } +) + +-- 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()) + } + } +) + +-- valve.open → OnOff.On +-- valve.open → OnOff.On + read OnOff attribute +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) } + } + } +) + +-- valve.close → OnOff.Off + read OnOff attribute +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) } + } + } +) + +-- doConfigure lifecycle +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, 300, 900, 2) + }) + 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 +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, 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" + } +) + +test.run_registered_tests()