diff --git a/drivers/SmartThings/zigbee-range-extender/fingerprints.yml b/drivers/SmartThings/zigbee-range-extender/fingerprints.yml index d8d4bcd5cf..a5a4077fed 100644 --- a/drivers/SmartThings/zigbee-range-extender/fingerprints.yml +++ b/drivers/SmartThings/zigbee-range-extender/fingerprints.yml @@ -33,4 +33,9 @@ zigbeeManufacturer: deviceLabel: frient Zigbee Range Extender manufacturer: frient A/S model: REXZB-111 - deviceProfileName: range-extender-battery-source \ No newline at end of file + deviceProfileName: range-extender-battery-source + - id: "SONOFF/Router" + deviceLabel: "SONOFF Zigbee Range Extender" + manufacturer: "SONOFF" + model: "Dongle-LMG21" + deviceProfileName: range-extender-signal diff --git a/drivers/SmartThings/zigbee-range-extender/profiles/range-extender-signal.yml b/drivers/SmartThings/zigbee-range-extender/profiles/range-extender-signal.yml new file mode 100644 index 0000000000..f32a15e37f --- /dev/null +++ b/drivers/SmartThings/zigbee-range-extender/profiles/range-extender-signal.yml @@ -0,0 +1,10 @@ +name: range-extender-signal +components: +- id: main + capabilities: + - id: signalStrength + version: 1 + - id: refresh + version: 1 + categories: + - name: Networking diff --git a/drivers/SmartThings/zigbee-range-extender/src/init.lua b/drivers/SmartThings/zigbee-range-extender/src/init.lua index f06988f5cf..74a834ec56 100644 --- a/drivers/SmartThings/zigbee-range-extender/src/init.lua +++ b/drivers/SmartThings/zigbee-range-extender/src/init.lua @@ -15,7 +15,8 @@ end local zigbee_range_driver_template = { supported_capabilities = { capabilities.refresh, - capabilities.battery + capabilities.battery, + capabilities.signalStrength }, capability_handlers = { [capabilities.refresh.ID] = { diff --git a/drivers/SmartThings/zigbee-range-extender/src/range-extender-signal/can_handle.lua b/drivers/SmartThings/zigbee-range-extender/src/range-extender-signal/can_handle.lua new file mode 100644 index 0000000000..65c1682432 --- /dev/null +++ b/drivers/SmartThings/zigbee-range-extender/src/range-extender-signal/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle(opts, driver, device) + local FINGERPRINTS = require("range-extender-signal.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("range-extender-signal") + end + end + return false +end + +return can_handle diff --git a/drivers/SmartThings/zigbee-range-extender/src/range-extender-signal/fingerprints.lua b/drivers/SmartThings/zigbee-range-extender/src/range-extender-signal/fingerprints.lua new file mode 100644 index 0000000000..bd95802086 --- /dev/null +++ b/drivers/SmartThings/zigbee-range-extender/src/range-extender-signal/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local RANGE_EXTENDER_SIGNAL_FINGERPRINTS = { + { mfr = "SONOFF", model = "Dongle-LMG21" }, +} + +return RANGE_EXTENDER_SIGNAL_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-range-extender/src/range-extender-signal/init.lua b/drivers/SmartThings/zigbee-range-extender/src/range-extender-signal/init.lua new file mode 100644 index 0000000000..1e2707e237 --- /dev/null +++ b/drivers/SmartThings/zigbee-range-extender/src/range-extender-signal/init.lua @@ -0,0 +1,45 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local Basic = (require "st.zigbee.zcl.clusters").Basic + +local function refresh_signal_strength(driver, device) + -- The reply carries the hub's received-message RSSI and LQI. + device:send(Basic.attributes.ZCLVersion:read(device)) +end + +local function info_changed(driver, device, event, args) + if device.profile.id ~= args.old_st_store.profile.id then + refresh_signal_strength(driver, device) + end +end + +local function signal_strength_handler(driver, device, value, zb_rx) + -- A device can change profiles while this sub-driver is loaded. + if not device:supports_capability(capabilities.signalStrength, "main") then + return + end + + if zb_rx.lqi ~= nil and zb_rx.lqi.value ~= nil then + device:emit_event(capabilities.signalStrength.lqi(zb_rx.lqi.value)) + end + if zb_rx.rssi ~= nil and zb_rx.rssi.value ~= nil then + device:emit_event(capabilities.signalStrength.rssi({ value = zb_rx.rssi.value, unit = "dBm" })) + end +end + +return { + NAME = "Range Extender Signal Strength", + lifecycle_handlers = { + infoChanged = info_changed, + }, + zigbee_handlers = { + attr = { + [Basic.ID] = { + [Basic.attributes.ZCLVersion.ID] = signal_strength_handler, + }, + }, + }, + can_handle = require("range-extender-signal.can_handle"), +} diff --git a/drivers/SmartThings/zigbee-range-extender/src/sub_drivers.lua b/drivers/SmartThings/zigbee-range-extender/src/sub_drivers.lua index 2f30e461ee..1bf3e7d3e5 100644 --- a/drivers/SmartThings/zigbee-range-extender/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-range-extender/src/sub_drivers.lua @@ -4,5 +4,6 @@ local lazy_load_if_possible = require "lazy_load_subdriver" local sub_drivers = { lazy_load_if_possible("frient"), + lazy_load_if_possible("range-extender-signal"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-range-extender/src/test/test_range_extender_signal.lua b/drivers/SmartThings/zigbee-range-extender/src/test/test_range_extender_signal.lua new file mode 100644 index 0000000000..6b8116519d --- /dev/null +++ b/drivers/SmartThings/zigbee-range-extender/src/test/test_range_extender_signal.lua @@ -0,0 +1,222 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local Basic = clusters.Basic + +local function build_device(profile, manufacturer, model) + return test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition(profile), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = manufacturer or "SONOFF", + model = model or "Dongle-LMG21", + server_clusters = { Basic.ID }, + }, + }, + }) +end + +local mock_device = build_device("range-extender-signal.yml") +local base_device = build_device("range-extender.yml") +local other_manufacturer = build_device("range-extender-signal.yml", "Other manufacturer") +local other_model = build_device("range-extender-signal.yml", "SONOFF", "Other model") + +local function read_response(device, lqi, rssi) + local message = Basic.attributes.ZCLVersion:build_test_read_attr_response(device, 3) + message.lqi = data_types.Uint8(lqi) + message.rssi = data_types.Int8(rssi) + return message +end + +local function expect_signal_strength(device, lqi, rssi) + test.socket.capability:__expect_send(device:generate_test_message( + "main", capabilities.signalStrength.lqi(lqi))) + test.socket.capability:__expect_send(device:generate_test_message( + "main", capabilities.signalStrength.rssi({ value = rssi, unit = "dBm" }))) +end + +local function base_test_init() + test.mock_device.add_test_device(base_device) +end + +zigbee_test_utils.prepare_zigbee_env_info() +test.set_test_init_function(function() + test.mock_device.add_test_device(mock_device) +end) + +test.register_coroutine_test( + "Refresh reads the device and publishes received signal strength", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, Basic.attributes.ZCLVersion:read(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, read_response(mock_device, 203, -67) }) + expect_signal_strength(mock_device, 203, -67) + end, + { + min_api_version = 17, + } +) + +test.register_coroutine_test( + "Configuration obtains an initial signal sample without configuring reporting", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ mock_device.id, Basic.attributes.ZCLVersion:read(mock_device) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, read_response(mock_device, 169, -74) }) + expect_signal_strength(mock_device, 169, -74) + end, + { + min_api_version = 17, + } +) + +test.register_coroutine_test( + "Health polling obtains new signal samples every five minutes", + function() + test.wait_for_events() + test.mock_time.advance_time(300) + test.socket.zigbee:__expect_send({ mock_device.id, Basic.attributes.ZCLVersion:read(mock_device) }) + test.wait_for_events() + test.socket.zigbee:__queue_receive({ mock_device.id, read_response(mock_device, 211, -61) }) + expect_signal_strength(mock_device, 211, -61) + test.wait_for_events() + + test.mock_time.advance_time(300) + test.socket.zigbee:__expect_send({ mock_device.id, Basic.attributes.ZCLVersion:read(mock_device) }) + test.wait_for_events() + test.socket.zigbee:__queue_receive({ mock_device.id, read_response(mock_device, 83, -91) }) + expect_signal_strength(mock_device, 83, -91) + end, + { + min_api_version = 17, + test_init = function() + test.mock_device.add_test_device(mock_device) + test.timer.__create_and_queue_test_time_advance_timer(300, "interval", 2) + end, + } +) + +test.register_coroutine_test( + "Zero LQI and zero dBm are preserved in received samples", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, read_response(mock_device, 0, 0) }) + expect_signal_strength(mock_device, 0, 0) + end, + { + min_api_version = 17, + } +) + +test.register_coroutine_test( + "Signal strength preserves Zigbee metadata boundary values", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, read_response(mock_device, 255, -128) }) + expect_signal_strength(mock_device, 255, -128) + end, + { + min_api_version = 17, + } +) + +test.register_coroutine_test( + "Unsolicited Basic reports also update signal strength", + function() + local report = Basic.attributes.ZCLVersion:build_test_attr_report(mock_device, 3) + report.lqi = data_types.Uint8(127) + report.rssi = data_types.Int8(-82) + test.socket.zigbee:__queue_receive({ mock_device.id, report }) + expect_signal_strength(mock_device, 127, -82) + end, + { + min_api_version = 17, + } +) + +test.register_coroutine_test( + "Initializing a base profile does not automatically add signal strength capabilities", + function() + test.wait_for_events() + end, + { + min_api_version = 17, + test_init = base_test_init, + } +) + +test.register_coroutine_test( + "Samples are not published for a base profile without signal strength", + function() + test.socket.zigbee:__queue_receive({ base_device.id, read_response(base_device, 196, -70) }) + end, + { + min_api_version = 17, + test_init = base_test_init, + } +) + +test.register_coroutine_test( + "Changing to the signal strength profile obtains and publishes an initial sample", + function() + test.socket.device_lifecycle:__queue_receive(base_device:generate_info_changed({ + profile = t_utils.get_profile_definition("range-extender-signal.yml"), + })) + test.socket.zigbee:__expect_send({ base_device.id, Basic.attributes.ZCLVersion:read(base_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ base_device.id, read_response(base_device, 184, -72) }) + expect_signal_strength(base_device, 184, -72) + end, + { + min_api_version = 17, + test_init = base_test_init, + } +) + +test.register_coroutine_test( + "Initializing or changing preferences does not request another signal sample", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ preferences = {} })) + end, + { + min_api_version = 17, + } +) + +for _, case in ipairs({ + { name = "a different manufacturer with the supported model", device = other_manufacturer }, + { name = "a different model with the supported manufacturer", device = other_model }, +}) do + local device = case.device + test.register_coroutine_test( + "Signal strength subdriver rejects " .. case.name, + function() + test.socket.zigbee:__queue_receive({ device.id, read_response(device, 196, -70) }) + end, + { + min_api_version = 17, + test_init = function() + test.mock_device.add_test_device(device) + end, + } + ) +end + +test.run_registered_tests()