From 0af2a09f1a693cdc3651224929e3d5631a8048f0 Mon Sep 17 00:00:00 2001 From: Woofser Date: Thu, 14 May 2026 19:59:16 +0000 Subject: [PATCH 1/5] add Sombra Shades driver --- .../src/sombra/can_handle.lua | 14 ++ .../src/sombra/fingerprints.lua | 9 ++ .../src/sombra/init.lua | 122 ++++++++++++++++++ .../src/sub_drivers.lua | 1 + .../test_zigbee_window_treatment_sombra.lua | 109 ++++++++++++++++ 5 files changed, 255 insertions(+) create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/sombra/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sombra/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/sombra/can_handle.lua new file mode 100644 index 0000000000..e407ea7198 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("sombra.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("sombra") + end + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua new file mode 100644 index 0000000000..68a18e8009 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "Sombra Shades", model = "WM25/L-Z" }, + { mfr = "Sombra Shades", model = "SS25/L-Z" } +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua new file mode 100644 index 0000000000..ef4b2463f0 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua @@ -0,0 +1,122 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local utils = require "st.utils" +local window_shade_utils = require "window_shade_utils" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local WindowCovering = zcl_clusters.WindowCovering + +local SOMBRA_SHADES_OPENING = "_sombraShadesOpening" +local SOMBRA_SHADES_CLOSING = "_sombraShadesClosing" + +local function current_position_attr_handler(driver, device, value, zb_rx) + local level = value.value + local component = device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value) + local current_level = device:get_latest_state(component, capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + + device:set_field(SOMBRA_SHADES_CLOSING, false) + device:set_field(SOMBRA_SHADES_OPENING, false) + device:emit_event(capabilities.windowShadeLevel.shadeLevel(level)) + + local windowShade = capabilities.windowShade.windowShade + if level == 0 or level == 100 then + device:emit_event(level == 0 and windowShade.closed() or windowShade.open()) + else + local event = current_level < level and windowShade.opening() or windowShade.closing() + device:emit_event(event) + device.thread:call_with_delay(2, function() + local latest_level = device:get_latest_state(component, capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME, 0) + if latest_level > 0 and latest_level < 100 then + device:emit_event(windowShade.partially_open()) + end + end) + end +end + +local function window_shade_pause_handler(driver, device, command) + device:send_to_component(command.component, WindowCovering.server.commands.Stop(device)) +end + +local function window_shade_set_level_handler(driver, device, command) + local level = utils.clamp_value(command.args.shadeLevel, 0, 100) + local current_shades_level = device:get_latest_state(command.component, capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME, 0) + local sombra_opening = device:get_field(SOMBRA_SHADES_OPENING) + local sombra_closing = device:get_field(SOMBRA_SHADES_CLOSING) + + if current_shades_level ~= level and (sombra_opening or sombra_closing) then + device:emit_event(capabilities.windowShadeLevel.shadeLevel(current_shades_level)) + return + end + + if current_shades_level > level then + device:set_field(SOMBRA_SHADES_CLOSING, true) + device:emit_event(capabilities.windowShade.windowShade.closing()) + elseif current_shades_level < level then + device:set_field(SOMBRA_SHADES_OPENING, true) + device:emit_event(capabilities.windowShade.windowShade.opening()) + end + + device:emit_event(capabilities.windowShadeLevel.shadeLevel(level)) + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, level)) +end + +local function window_shade_open_handler(driver, device, command) + command.args.shadeLevel = 100 + window_shade_set_level_handler(driver, device, command) +end + +local function window_shade_close_handler(driver, device, command) + command.args.shadeLevel = 0 + window_shade_set_level_handler(driver, device, command) +end + +local function window_shade_preset_handler(driver, device, command) + local level = window_shade_utils.get_preset_level(device, command.component) + command.args.shadeLevel = level + window_shade_set_level_handler(driver, device, command) +end + +local function device_init(self, device) + device:set_field(SOMBRA_SHADES_CLOSING, false) + device:set_field(SOMBRA_SHADES_OPENING, false) + + if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and + device:get_latest_state("main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME) == nil then + + device:emit_event(capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, { visibility = { displayed = false }})) + local preset_position = window_shade_utils.get_preset_level(device, "main") + device:emit_event(capabilities.windowShadePreset.position(preset_position, { visibility = { displayed = false }})) + device:set_field(window_shade_utils.PRESET_LEVEL_KEY, preset_position, { persist = true }) + end +end + +local sombra_handler = { + NAME = "Sombra Shades Zigbee Window Shade", + capability_handlers = { + [capabilities.windowShadeLevel.ID] = { + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_set_level_handler + }, + [capabilities.windowShade.ID] = { + [capabilities.windowShade.commands.open.NAME] = window_shade_open_handler, + [capabilities.windowShade.commands.close.NAME] = window_shade_close_handler, + [capabilities.windowShade.commands.pause.NAME] = window_shade_pause_handler, + }, + [capabilities.windowShadePreset.ID] = { + [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_preset_handler + } + }, + zigbee_handlers = { + attr = { + [WindowCovering.ID] = { + [WindowCovering.attributes.CurrentPositionLiftPercentage.ID] = current_position_attr_handler + } + } + }, + lifecycle_handlers = { + init = device_init + }, + can_handle = require("sombra.can_handle") +} + +return sombra_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua index 959c8d8c22..3353c87fd4 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua @@ -7,6 +7,7 @@ local sub_drivers = { lazy_load_if_possible("aqara"), lazy_load_if_possible("feibit"), lazy_load_if_possible("somfy"), + lazy_load_if_possible("sombra"), lazy_load_if_possible("invert-lift-percentage"), lazy_load_if_possible("rooms-beautiful"), lazy_load_if_possible("axis"), diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua new file mode 100644 index 0000000000..bfc4e4f0d2 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua @@ -0,0 +1,109 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +local WindowCovering = clusters.WindowCovering + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-battery.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Sombra Shades", + model = "WM25/L-Z", + server_clusters = {0x000, 0x0003, 0x0004, 0x0005, 0x0102} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Handle Window Shade level command for Sombra Shades", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { + capability = "windowShadeLevel", component = "main", + command = "setShadeLevel", args = { 33 } + } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(33)) + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 33) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle CurrentPositionLiftPercentage report for Sombra Shades", + function() + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 20) + } + ) + test.socket.capability:__expect_send( + { + mock_device.id, + { + capability_id = "windowShadeLevel", component_id = "main", + attribute_id = "shadeLevel", state = { value = 20 } + } + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) From 04212d42a1edd83c221d51946cba8b79fa3fdcbe Mon Sep 17 00:00:00 2001 From: Woofser Date: Thu, 14 May 2026 15:03:07 -0500 Subject: [PATCH 2/5] Add new fingerprint for Sombra Shades model --- .../zigbee-window-treatment/src/sombra/fingerprints.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua index 68a18e8009..81d43d7459 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua @@ -3,7 +3,7 @@ local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { { mfr = "Sombra Shades", model = "WM25/L-Z" }, - { mfr = "Sombra Shades", model = "SS25/L-Z" } + { mfr = "Sombra Shades", model = "SOMBRA/Z-M" } } return ZIGBEE_WINDOW_SHADE_FINGERPRINTS From f3a488d04a1d57ed2fac1a56e9b48274aa69d732 Mon Sep 17 00:00:00 2001 From: Woofser Date: Thu, 14 May 2026 15:08:54 -0500 Subject: [PATCH 3/5] Update model identifier for Sombra Shades --- .../src/test/test_zigbee_window_treatment_sombra.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua index bfc4e4f0d2..3318236126 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua @@ -17,7 +17,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( [1] = { id = 1, manufacturer = "Sombra Shades", - model = "WM25/L-Z", + model = "SOMBRA/Z-M", server_clusters = {0x000, 0x0003, 0x0004, 0x0005, 0x0102} } } From 058dff01007221e6ce44459efbf2071e73eddc1d Mon Sep 17 00:00:00 2001 From: Woofser Date: Wed, 20 May 2026 23:13:41 -0500 Subject: [PATCH 4/5] Correct Sombra Shades window shade sub-driver --- .../zigbee-window-treatment/fingerprints.yml | 11 +- .../src/sombra/fingerprints.lua | 1 - .../src/sombra/init.lua | 204 +++++---- .../test_zigbee_window_treatment_sombra.lua | 403 ++++++++++++++++-- 4 files changed, 483 insertions(+), 136 deletions(-) diff --git a/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml b/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml index b2e918746b..e96efca724 100644 --- a/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml +++ b/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml @@ -138,15 +138,10 @@ zigbeeManufacturer: manufacturer: HOPOsmart model: A2230011 deviceProfileName: window-shade-only - - id: "Sombra Shades/WM25/L-Z" - deviceLabel: Sombra Shades Window Treatment + - id: "Sombra Shades/SOMBRA/Z-M" + deviceLabel: Sombra Automated Shades manufacturer: Sombra Shades - model: WM25/L-Z - deviceProfileName: window-treatment-battery - - id: "Sombra Shades/SS25/L-Z" - deviceLabel: Sombra Automated Shades and Blinds - manufacturer: Sombra Shades - model: SS25/L-Z + model: SOMBRA/Z-M deviceProfileName: window-treatment-battery zigbeeGeneric: diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua index 81d43d7459..bac50c21bb 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua @@ -2,7 +2,6 @@ -- Licensed under the Apache License, Version 2.0 local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { - { mfr = "Sombra Shades", model = "WM25/L-Z" }, { mfr = "Sombra Shades", model = "SOMBRA/Z-M" } } diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua index ef4b2463f0..233d6cd169 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua @@ -1,122 +1,166 @@ -- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 + +-- require st provided libraries local capabilities = require "st.capabilities" -local utils = require "st.utils" +local clusters = require "st.zigbee.zcl.clusters" local window_shade_utils = require "window_shade_utils" -local zcl_clusters = require "st.zigbee.zcl.clusters" -local WindowCovering = zcl_clusters.WindowCovering +local device_management = require "st.zigbee.device_management" +local utils = require "st.utils" -local SOMBRA_SHADES_OPENING = "_sombraShadesOpening" -local SOMBRA_SHADES_CLOSING = "_sombraShadesClosing" +local WindowCovering = clusters.WindowCovering +local PowerConfiguration = clusters.PowerConfiguration -local function current_position_attr_handler(driver, device, value, zb_rx) - local level = value.value - local component = device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value) - local current_level = device:get_latest_state(component, capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME) or 0 +-- manufacturer specific cluster details for motor running direction +local CUS_CLU = 0xFCCC +local RUN_DIR_ATTR = 0x0012 - device:set_field(SOMBRA_SHADES_CLOSING, false) - device:set_field(SOMBRA_SHADES_OPENING, false) - device:emit_event(capabilities.windowShadeLevel.shadeLevel(level)) +local MOTOR_STATE = "motorState" +local MOTOR_STATE_IDLE = "idle" +local MOTOR_STATE_OPENING = "opening" +local MOTOR_STATE_CLOSING = "closing" - local windowShade = capabilities.windowShade.windowShade - if level == 0 or level == 100 then - device:emit_event(level == 0 and windowShade.closed() or windowShade.open()) - else - local event = current_level < level and windowShade.opening() or windowShade.closing() - device:emit_event(event) - device.thread:call_with_delay(2, function() - local latest_level = device:get_latest_state(component, capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME, 0) - if latest_level > 0 and latest_level < 100 then - device:emit_event(windowShade.partially_open()) - end - end) - end +----------------------------------------------------------------- +-- local functions +----------------------------------------------------------------- + +-- this is do_refresh +local do_refresh = function(self, device) + device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:read(device)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) end -local function window_shade_pause_handler(driver, device, command) - device:send_to_component(command.component, WindowCovering.server.commands.Stop(device)) +-- this is window_shade_level_cmd +local function window_shade_level_cmd(driver, device, command) + local go_to_level = command.args.shadeLevel + -- send levels without inverting as: 0% lift (open) to 100% lift (closed) + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, go_to_level)) end -local function window_shade_set_level_handler(driver, device, command) - local level = utils.clamp_value(command.args.shadeLevel, 0, 100) - local current_shades_level = device:get_latest_state(command.component, capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME, 0) - local sombra_opening = device:get_field(SOMBRA_SHADES_OPENING) - local sombra_closing = device:get_field(SOMBRA_SHADES_CLOSING) +-- this is window_shade_preset_cmd +local function window_shade_preset_cmd(driver, device, command) + local level = window_shade_utils.get_preset_level(device, command.component) + -- send levels without inverting as: 0% lift (open) to 100% lift (closed) + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, level)) +end + +-- this is device_added +local function device_added(self, device) + device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) + -- initialize motor state + device:set_field(MOTOR_STATE, MOTOR_STATE_IDLE) + device.thread:call_with_delay(3, function(d) + do_refresh(self, device) + end) +end - if current_shades_level ~= level and (sombra_opening or sombra_closing) then - device:emit_event(capabilities.windowShadeLevel.shadeLevel(current_shades_level)) - return +-- this is current_position_attr_handler +local function current_position_attr_handler(driver, device, value, zb_rx) + local level = value.value + local event = nil + local motor_state_value = device:get_field(MOTOR_STATE) or MOTOR_STATE_IDLE + + -- when the device is in action + if motor_state_value == MOTOR_STATE_OPENING then + event = capabilities.windowShade.windowShade.opening() end - if current_shades_level > level then - device:set_field(SOMBRA_SHADES_CLOSING, true) - device:emit_event(capabilities.windowShade.windowShade.closing()) - elseif current_shades_level < level then - device:set_field(SOMBRA_SHADES_OPENING, true) - device:emit_event(capabilities.windowShade.windowShade.opening()) + if motor_state_value == MOTOR_STATE_CLOSING then + event = capabilities.windowShade.windowShade.closing() end - device:emit_event(capabilities.windowShadeLevel.shadeLevel(level)) - device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, level)) -end + -- when the device is idle + if motor_state_value == MOTOR_STATE_IDLE then + if level == 0 then + event = capabilities.windowShade.windowShade.open() + elseif level == 100 then + event = capabilities.windowShade.windowShade.closed() + else + event = capabilities.windowShade.windowShade.partially_open() + end + end -local function window_shade_open_handler(driver, device, command) - command.args.shadeLevel = 100 - window_shade_set_level_handler(driver, device, command) -end + -- update status + if event ~= nil then + device:emit_event(event) + end -local function window_shade_close_handler(driver, device, command) - command.args.shadeLevel = 0 - window_shade_set_level_handler(driver, device, command) + -- update level + device:emit_event(capabilities.windowShadeLevel.shadeLevel(level)) end -local function window_shade_preset_handler(driver, device, command) - local level = window_shade_utils.get_preset_level(device, command.component) - command.args.shadeLevel = level - window_shade_set_level_handler(driver, device, command) +-- this is motor running_direction_attr_handler +local function running_direction_attr_handler(driver, device, value, zb_rx) + local status = value.value + if status == 1 then + device:set_field(MOTOR_STATE, MOTOR_STATE_OPENING) + elseif status == 2 then + device:set_field(MOTOR_STATE, MOTOR_STATE_CLOSING) + else + device:set_field(MOTOR_STATE, MOTOR_STATE_IDLE) + end end -local function device_init(self, device) - device:set_field(SOMBRA_SHADES_CLOSING, false) - device:set_field(SOMBRA_SHADES_OPENING, false) - - if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and - device:get_latest_state("main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME) == nil then +-- this is do_configure +local function do_configure(self, device) + -- configure elements + device:send(device_management.build_bind_request(device, WindowCovering.ID, self.environment_info.hub_zigbee_eui)) + device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:configure_reporting(device, 1, 3600, 1)) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 1, 3600, 1)) + + -- read elements + device.thread:call_with_delay(3, function(d) + do_refresh(self, device) + end) +end - device:emit_event(capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, { visibility = { displayed = false }})) - local preset_position = window_shade_utils.get_preset_level(device, "main") - device:emit_event(capabilities.windowShadePreset.position(preset_position, { visibility = { displayed = false }})) - device:set_field(window_shade_utils.PRESET_LEVEL_KEY, preset_position, { persist = true }) +-- this is battery_perc_attr_handler +local function battery_perc_attr_handler(driver, device, value, zb_rx) + local converted_value = value.value / 2 + converted_value = utils.round(converted_value) + local motor_state_value = device:get_field(MOTOR_STATE) or "" + -- update battery percentage only when the motor is idle + if motor_state_value == MOTOR_STATE_IDLE then + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, + capabilities.battery.battery(utils.clamp_value(converted_value, 0, 100))) end end -local sombra_handler = { - NAME = "Sombra Shades Zigbee Window Shade", +-- create the handler object +local sombra_roller_shade_handler = { + NAME = "sombra_roller_shade_handler", + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure + }, capability_handlers = { [capabilities.windowShadeLevel.ID] = { - [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_set_level_handler - }, - [capabilities.windowShade.ID] = { - [capabilities.windowShade.commands.open.NAME] = window_shade_open_handler, - [capabilities.windowShade.commands.close.NAME] = window_shade_close_handler, - [capabilities.windowShade.commands.pause.NAME] = window_shade_pause_handler, + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_level_cmd }, [capabilities.windowShadePreset.ID] = { - [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_preset_handler + [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_preset_cmd + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, } }, zigbee_handlers = { attr = { [WindowCovering.ID] = { - [WindowCovering.attributes.CurrentPositionLiftPercentage.ID] = current_position_attr_handler + [WindowCovering.attributes.CurrentPositionLiftPercentage.ID] = current_position_attr_handler, + }, + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_perc_attr_handler, + }, + [CUS_CLU] = { + [RUN_DIR_ATTR] = running_direction_attr_handler, } } }, - lifecycle_handlers = { - init = device_init - }, - can_handle = require("sombra.can_handle") + can_handle = require("sombra.can_handle"), } -return sombra_handler +-- return the handler +return sombra_roller_shade_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua index 3318236126..46dca7b87a 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua @@ -1,31 +1,38 @@ --- Copyright 2022 SmartThings, Inc. +-- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 + +-- Mock out globals local test = require "integration_test" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" +local data_types = require "st.zigbee.data_types" local WindowCovering = clusters.WindowCovering +local PowerConfiguration = clusters.PowerConfiguration + +-- manufacturer specific cluster details for motor running direction +local PRIVATE_CLUSTER_ID = 0xFCCC +local PRIVATE_ATTRIBUTE_ID = 0x0012 local mock_device = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("window-treatment-battery.yml"), - fingerprinted_endpoint_id = 0x01, - zigbee_endpoints = { - [1] = { - id = 1, - manufacturer = "Sombra Shades", - model = "SOMBRA/Z-M", - server_clusters = {0x000, 0x0003, 0x0004, 0x0005, 0x0102} + { + profile = t_utils.get_profile_definition("window-treatment-battery.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Sombra Shades", + model = "SOMBRA/Z-M", + server_clusters = {0x0000, 0x0001, 0x0102} + } } } - } ) zigbee_test_utils.prepare_zigbee_env_info() - local function test_init() test.mock_device.add_test_device(mock_device) test.socket.capability:__expect_send( @@ -38,72 +45,374 @@ end test.set_test_init_function(test_init) -test.register_message_test( - "Handle Window Shade level command for Sombra Shades", - { +test.register_coroutine_test( + "Window Shade state open", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 0) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + ) + end, { - channel = "capability", - direction = "receive", - message = { - mock_device.id, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Window Shade state closed", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive( { - capability = "windowShadeLevel", component = "main", - command = "setShadeLevel", args = { 33 } + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 100) } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Motor direction idle with Window Shade state partially open", + function() + test.socket.capability:__set_channel_ordering("relaxed") + local attr_report_data = { + { PRIVATE_ATTRIBUTE_ID, data_types.Uint8.ID, 0 }-- device sends 0 for idle } - }, + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data) + }) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 25) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(25)) + ) + end, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) - }, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowShade open cmd test case", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "open", args = {} } + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.UpOrOpen(mock_device) + }) + test.wait_for_events() + end, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(33)) - }, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowShade close cmd test case", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "close", args = {} } + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.DownOrClose(mock_device) + }) + test.wait_for_events() + end, { - channel = "zigbee", - direction = "send", - message = { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowShade pause cmd test case", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "pause", args = {} } + } + ) + test.socket.zigbee:__expect_send({ mock_device.id, - WindowCovering.server.commands.GoToLiftPercentage(mock_device, 33) + WindowCovering.server.commands.Stop(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Battery Percentage Remaining test cases", + function() + mock_device:set_field("motorState", "idle") + local battery_test_map = { + [200] = 100, + [100] = 50, + [0] = 0 } + for bat_perc_rem, batt_perc_out in pairs(battery_test_map) do + test.socket.zigbee:__queue_receive({ mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, bat_perc_rem) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc_out)) ) + test.wait_for_events() + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Refresh should generate expected messages", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "doConfigure should generate expected messages", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, WindowCovering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:configure_reporting(mock_device, 1, 3600, 1) + }) + 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, 1, 3600, 1) + }) + + -- read values after delay + test.mock_time.advance_time(3) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "added should generate expected messages", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" },{ visibility = { displayed = false }})) + ) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + -- read values after delay + test.mock_time.advance_time(3) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Motor direction opening with window_shade_level_cmd", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + local attr_report_data = { + { PRIVATE_ATTRIBUTE_ID, data_types.Uint8.ID, 1 }-- device sends 1 for opening } - }, + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data) + }) + + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 45 } } + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 45) + } + ) + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 45) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(45)) + ) + end, { - min_api_version = 17 + min_api_version = 17 } ) test.register_coroutine_test( - "Handle CurrentPositionLiftPercentage report for Sombra Shades", + "Motor direction closing with window_shade_level_cmd", function() + test.socket.zigbee:__set_channel_ordering("relaxed") + local attr_report_data = { + { PRIVATE_ATTRIBUTE_ID, data_types.Uint8.ID, 2 }-- device sends 2 for closing + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data) + }) + + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 85 } } + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 85) + } + ) + test.socket.zigbee:__queue_receive( { mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 20) + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 85) } ) test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(85)) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Motor direction closing with window_shade_preset_cmd", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + local attr_report_data = { + { PRIVATE_ATTRIBUTE_ID, data_types.Uint8.ID, 2 }-- device sends 2 for closing + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data) + }) + + test.socket.capability:__queue_receive( { mock_device.id, - { - capability_id = "windowShadeLevel", component_id = "main", - attribute_id = "shadeLevel", state = { value = 20 } - } + {capability = "windowShadePreset", component = "main", command = "presetPosition", args = {}}, + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) + } + ) + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) } ) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) ) - test.mock_time.advance_time(2) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) ) - test.wait_for_events() end, { - min_api_version = 17 + min_api_version = 17 } ) + +test.run_registered_tests() From 18556d4750ae1a98811407fd508920479a5d1e82 Mon Sep 17 00:00:00 2001 From: Woofser Date: Thu, 21 May 2026 07:59:33 -0500 Subject: [PATCH 5/5] Use position-based motion status and add power source support --- .../zigbee-window-treatment/fingerprints.yml | 2 +- .../src/sombra/init.lua | 121 +++---- .../test_zigbee_window_treatment_sombra.lua | 308 +++++++++--------- 3 files changed, 227 insertions(+), 204 deletions(-) diff --git a/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml b/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml index e96efca724..cbed7f98d8 100644 --- a/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml +++ b/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml @@ -142,7 +142,7 @@ zigbeeManufacturer: deviceLabel: Sombra Automated Shades manufacturer: Sombra Shades model: SOMBRA/Z-M - deviceProfileName: window-treatment-battery + deviceProfileName: window-treatment-powerSource zigbeeGeneric: - id: "genericShade" diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua index 233d6cd169..634d7372e3 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua @@ -9,26 +9,63 @@ local window_shade_utils = require "window_shade_utils" local device_management = require "st.zigbee.device_management" local utils = require "st.utils" +local Basic = clusters.Basic local WindowCovering = clusters.WindowCovering local PowerConfiguration = clusters.PowerConfiguration --- manufacturer specific cluster details for motor running direction -local CUS_CLU = 0xFCCC -local RUN_DIR_ATTR = 0x0012 - -local MOTOR_STATE = "motorState" -local MOTOR_STATE_IDLE = "idle" -local MOTOR_STATE_OPENING = "opening" -local MOTOR_STATE_CLOSING = "closing" +-- Sombra motors report position in the shade-industry convention +-- (0% = open, 100% = closed), which is passed straight through to +-- windowShadeLevel.shadeLevel with no inversion. +-- +-- Opening/Closing status is inferred from the direction the position is +-- changing, so it works on every Sombra motor variant -- battery and DC -- +-- regardless of any manufacturer-specific motion cluster. When position +-- reports stop arriving, a short settle timer emits the resting state. +local SETTLE_DELAY = 3 +local SETTLE_TIMER = "shade_settle_timer" ----------------------------------------------------------------- -- local functions ----------------------------------------------------------------- +-- emit the resting windowShade state for a level (0 = open, 100 = closed) +local function emit_resting_state(device, level) + if level <= 0 then + device:emit_event(capabilities.windowShade.windowShade.open()) + elseif level >= 100 then + device:emit_event(capabilities.windowShade.windowShade.closed()) + else + device:emit_event(capabilities.windowShade.windowShade.partially_open()) + end +end + +-- cancel any pending settle timer +local function cancel_settle_timer(device) + local timer = device:get_field(SETTLE_TIMER) + if timer then + device.thread:cancel_timer(timer) + device:set_field(SETTLE_TIMER, nil) + end +end + +-- (re)start the settle timer; when it fires without a newer position report +-- the shade has stopped moving, so emit the resting state +local function schedule_settle(device) + cancel_settle_timer(device) + local timer = device.thread:call_with_delay(SETTLE_DELAY, function() + device:set_field(SETTLE_TIMER, nil) + local level = device:get_latest_state("main", capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + emit_resting_state(device, level) + end) + device:set_field(SETTLE_TIMER, timer) +end + -- this is do_refresh local do_refresh = function(self, device) device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:read(device)) device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) + device:send(Basic.attributes.PowerSource:read(device)) end -- this is window_shade_level_cmd @@ -48,57 +85,34 @@ end -- this is device_added local function device_added(self, device) device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) - -- initialize motor state - device:set_field(MOTOR_STATE, MOTOR_STATE_IDLE) device.thread:call_with_delay(3, function(d) do_refresh(self, device) end) end -- this is current_position_attr_handler +-- position is 0 = open, 100 = closed; direction is inferred from the change local function current_position_attr_handler(driver, device, value, zb_rx) local level = value.value - local event = nil - local motor_state_value = device:get_field(MOTOR_STATE) or MOTOR_STATE_IDLE - - -- when the device is in action - if motor_state_value == MOTOR_STATE_OPENING then - event = capabilities.windowShade.windowShade.opening() - end - - if motor_state_value == MOTOR_STATE_CLOSING then - event = capabilities.windowShade.windowShade.closing() - end - - -- when the device is idle - if motor_state_value == MOTOR_STATE_IDLE then - if level == 0 then - event = capabilities.windowShade.windowShade.open() - elseif level == 100 then - event = capabilities.windowShade.windowShade.closed() - else - event = capabilities.windowShade.windowShade.partially_open() - end - end + local previous = device:get_latest_state("main", capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + local windowShade = capabilities.windowShade.windowShade - -- update status - if event ~= nil then - device:emit_event(event) - end - - -- update level device:emit_event(capabilities.windowShadeLevel.shadeLevel(level)) -end --- this is motor running_direction_attr_handler -local function running_direction_attr_handler(driver, device, value, zb_rx) - local status = value.value - if status == 1 then - device:set_field(MOTOR_STATE, MOTOR_STATE_OPENING) - elseif status == 2 then - device:set_field(MOTOR_STATE, MOTOR_STATE_CLOSING) + if level <= 0 then + cancel_settle_timer(device) + device:emit_event(windowShade.open()) + elseif level >= 100 then + cancel_settle_timer(device) + device:emit_event(windowShade.closed()) else - device:set_field(MOTOR_STATE, MOTOR_STATE_IDLE) + if level < previous then + device:emit_event(windowShade.opening()) + elseif level > previous then + device:emit_event(windowShade.closing()) + end + schedule_settle(device) end end @@ -109,6 +123,8 @@ local function do_configure(self, device) device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:configure_reporting(device, 1, 3600, 1)) device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 1, 3600, 1)) + device:send(device_management.build_bind_request(device, Basic.ID, self.environment_info.hub_zigbee_eui)) + device:send(Basic.attributes.PowerSource:configure_reporting(device, 1, 3600)) -- read elements device.thread:call_with_delay(3, function(d) @@ -120,12 +136,8 @@ end local function battery_perc_attr_handler(driver, device, value, zb_rx) local converted_value = value.value / 2 converted_value = utils.round(converted_value) - local motor_state_value = device:get_field(MOTOR_STATE) or "" - -- update battery percentage only when the motor is idle - if motor_state_value == MOTOR_STATE_IDLE then - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, - capabilities.battery.battery(utils.clamp_value(converted_value, 0, 100))) - end + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, + capabilities.battery.battery(utils.clamp_value(converted_value, 0, 100))) end -- create the handler object @@ -153,9 +165,6 @@ local sombra_roller_shade_handler = { }, [PowerConfiguration.ID] = { [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_perc_attr_handler, - }, - [CUS_CLU] = { - [RUN_DIR_ATTR] = running_direction_attr_handler, } } }, diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua index 46dca7b87a..4f28ff5bef 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua @@ -8,18 +8,14 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" -local data_types = require "st.zigbee.data_types" +local Basic = clusters.Basic local WindowCovering = clusters.WindowCovering local PowerConfiguration = clusters.PowerConfiguration --- manufacturer specific cluster details for motor running direction -local PRIVATE_CLUSTER_ID = 0xFCCC -local PRIVATE_ATTRIBUTE_ID = 0x0012 - local mock_device = test.mock_device.build_test_zigbee_device( { - profile = t_utils.get_profile_definition("window-treatment-battery.yml"), + profile = t_utils.get_profile_definition("window-treatment-powerSource.yml"), fingerprinted_endpoint_id = 0x01, zigbee_endpoints = { [1] = { @@ -90,28 +86,87 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Motor direction idle with Window Shade state partially open", + "A falling position reports opening, then settles to partially open", function() test.socket.capability:__set_channel_ordering("relaxed") - local attr_report_data = { - { PRIVATE_ATTRIBUTE_ID, data_types.Uint8.ID, 0 }-- device sends 0 for idle - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data) - }) + -- establish a known starting position (fully closed) test.socket.zigbee:__queue_receive( { mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 25) + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 100) } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.wait_for_events() + -- a lower position than before => opening + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 60) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(60)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + test.wait_for_events() + -- no further reports for the settle delay => partially open + test.mock_time.advance_time(3) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "A rising position reports closing, then settles to partially open", + function() + test.socket.capability:__set_channel_ordering("relaxed") + -- establish a known starting position (fully open) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 0) + } + ) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(25)) + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + ) + test.wait_for_events() + -- a higher position than before => closing + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 40) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(40)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + test.wait_for_events() + -- no further reports for the settle delay => partially open + test.mock_time.advance_time(3) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() end, { min_api_version = 17 @@ -178,10 +233,53 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "Set shade level command", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 45 } } + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 45) + } + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Preset position command", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) + } + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + test.register_coroutine_test( "Battery Percentage Remaining test cases", function() - mock_device:set_field("motorState", "idle") local battery_test_map = { [200] = 100, [100] = 50, @@ -199,6 +297,31 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "Power Source test cases", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__queue_receive({ mock_device.id, + Basic.attributes.PowerSource:build_test_attr_report(mock_device, 3) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery())) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, + Basic.attributes.PowerSource:build_test_attr_report(mock_device, 4) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerSource.powerSource.dc())) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, + Basic.attributes.PowerSource:build_test_attr_report(mock_device, 0) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerSource.powerSource.unknown())) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + test.register_coroutine_test( "Refresh should generate expected messages", function() @@ -212,6 +335,10 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + }) end, { min_api_version = 17 @@ -224,7 +351,7 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.zigbee:__set_channel_ordering("relaxed") mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, WindowCovering.ID) @@ -241,6 +368,14 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 1, 3600, 1) }) + 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, 1, 3600) + }) -- read values after delay test.mock_time.advance_time(3) @@ -252,6 +387,10 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + }) end, { min_api_version = 17 @@ -266,7 +405,7 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" },{ visibility = { displayed = false }})) ) - test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -280,135 +419,10 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end, - { - min_api_version = 17 - } -) - -test.register_coroutine_test( - "Motor direction opening with window_shade_level_cmd", - function() - test.socket.zigbee:__set_channel_ordering("relaxed") - local attr_report_data = { - { PRIVATE_ATTRIBUTE_ID, data_types.Uint8.ID, 1 }-- device sends 1 for opening - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data) - }) - - test.socket.capability:__queue_receive( - { - mock_device.id, - { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 45 } } - } - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - WindowCovering.server.commands.GoToLiftPercentage(mock_device, 45) - } - ) - - test.socket.zigbee:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 45) - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(45)) - ) - end, - { - min_api_version = 17 - } -) - -test.register_coroutine_test( - "Motor direction closing with window_shade_level_cmd", - function() - test.socket.zigbee:__set_channel_ordering("relaxed") - local attr_report_data = { - { PRIVATE_ATTRIBUTE_ID, data_types.Uint8.ID, 2 }-- device sends 2 for closing - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data) - }) - - test.socket.capability:__queue_receive( - { - mock_device.id, - { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 85 } } - } - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - WindowCovering.server.commands.GoToLiftPercentage(mock_device, 85) - } - ) - - test.socket.zigbee:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 85) - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(85)) - ) - end, - { - min_api_version = 17 - } -) - -test.register_coroutine_test( - "Motor direction closing with window_shade_preset_cmd", - function() - test.socket.zigbee:__set_channel_ordering("relaxed") - local attr_report_data = { - { PRIVATE_ATTRIBUTE_ID, data_types.Uint8.ID, 2 }-- device sends 2 for closing - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) }) - - test.socket.capability:__queue_receive( - { - mock_device.id, - {capability = "windowShadePreset", component = "main", command = "presetPosition", args = {}}, - } - ) - test.socket.zigbee:__expect_send( - { - mock_device.id, - WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) - } - ) - - test.socket.zigbee:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) - ) end, { min_api_version = 17