diff --git a/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml b/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml index b2e918746b..cbed7f98d8 100644 --- a/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml +++ b/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml @@ -138,16 +138,11 @@ 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 - deviceProfileName: window-treatment-battery + model: SOMBRA/Z-M + deviceProfileName: window-treatment-powerSource zigbeeGeneric: - id: "genericShade" 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..bac50c21bb --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "Sombra Shades", model = "SOMBRA/Z-M" } +} + +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..634d7372e3 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua @@ -0,0 +1,175 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- require st provided libraries +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +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 + +-- 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 +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 + +-- 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}})) + 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 previous = device:get_latest_state("main", capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + local windowShade = capabilities.windowShade.windowShade + + device:emit_event(capabilities.windowShadeLevel.shadeLevel(level)) + + 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 + if level < previous then + device:emit_event(windowShade.opening()) + elseif level > previous then + device:emit_event(windowShade.closing()) + end + schedule_settle(device) + end +end + +-- 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)) + 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) + do_refresh(self, device) + end) +end + +-- 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) + 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 +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_level_cmd + }, + [capabilities.windowShadePreset.ID] = { + [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, + }, + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_perc_attr_handler, + } + } + }, + can_handle = require("sombra.can_handle"), +} + +-- return the handler +return sombra_roller_shade_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..4f28ff5bef --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua @@ -0,0 +1,432 @@ +-- 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 Basic = clusters.Basic +local WindowCovering = clusters.WindowCovering +local PowerConfiguration = clusters.PowerConfiguration + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-powerSource.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( + 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_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, + { + 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( + { + 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( + "A falling position reports opening, then settles to partially open", + function() + test.socket.capability:__set_channel_ordering("relaxed") + -- establish a known starting position (fully closed) + test.socket.zigbee:__queue_receive( + { + 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.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(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 + } +) + +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, + { + 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, + { + 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.Stop(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +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() + 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( + "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() + 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) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource: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(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) + }) + 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) + }) + 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) + 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) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource: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(3, "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) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests()