From 268c9bed39bc58344b9dba030a9a093eb7eb1437 Mon Sep 17 00:00:00 2001 From: cjswedes Date: Wed, 3 Jun 2026 16:03:54 -0500 Subject: [PATCH 1/4] samsung-audio: guard missing UIC fields to avoid coroutine crashes SamsungAudio refresh and notification handlers assumed UPnP responses always included UIC/response data. Some speakers intermittently return partial XML, so command.volume/getMute/getPlayStatus returned nil or attempted to index ret.handler_res.root.UIC, causing coroutine errors at init.lua:97/101 and command.lua:98. When this happened, refresh processing crashed and users saw stale volume/mute state updates, failed refresh cycles, and unreliable audio notification unmute behavior. This change adds nil-safe response extraction for key polling commands, logs when UIC payloads are missing, and hardens refresh/audio-notification handlers to skip emitting volume/mute events when state data is unavailable instead of crashing. --- .../SmartThings/samsung-audio/src/command.lua | 33 ++++++++++++++----- .../samsung-audio/src/handlers.lua | 2 +- .../SmartThings/samsung-audio/src/init.lua | 17 +++++++--- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/drivers/SmartThings/samsung-audio/src/command.lua b/drivers/SmartThings/samsung-audio/src/command.lua index deca7c4416..d03fe07850 100644 --- a/drivers/SmartThings/samsung-audio/src/command.lua +++ b/drivers/SmartThings/samsung-audio/src/command.lua @@ -34,6 +34,19 @@ local function is_empty(t) return not t or (type(t) == "table" and #t == 0) end +local function get_uic_response(ret, command_name) + local root = ret and ret.handler_res and ret.handler_res.root + local uic = root and root.UIC + local response = uic and uic.response + + if not uic then + log.warn(string.format("Missing UIC data in %s response", tostring(command_name))) + return nil, nil + end + + return uic, response +end + local function tr(s,mappings) return string.gsub(s, "(.)", @@ -94,8 +107,9 @@ function Command.volume(ip) if ip then local url = format_url(ip, "/UIC?cmd=GetVolume") local ret = handle_http_request(ip, url) - if ret then - response_map = { volume = ret.handler_res.root.UIC.response.volume, } + local _, response = get_uic_response(ret, "GetVolume") + if response and response.volume ~= nil then + response_map = { volume = response.volume, } end end return response_map @@ -114,8 +128,9 @@ function Command.set_volume(ip, level) local encoded_str_vol = "/UIC?cmd=%3Cpwron%3Eon%3C/pwron%3E%3Cname%3ESetVolume%3C/name%3E%3Cp%20type=%22dec%22%20name=%22volume%22%20val=%22" .. level .. "%22%3E%3C/p%3E" local url = format_url(ip, encoded_str_vol) local ret = handle_http_request(ip, url) - if ret then - response_map = { volume = ret.handler_res.root.UIC.response.volume, } + local _, response = get_uic_response(ret, "SetVolume") + if response and response.volume ~= nil then + response_map = { volume = response.volume, } end end return response_map @@ -326,8 +341,9 @@ function Command.getMute(ip) if ip then local url = format_url(ip, "/UIC?cmd=GetMute") local ret = handle_http_request(ip, url) - if ret then - response_map = { muted = ret.handler_res.root.UIC.response.mute,} + local _, response = get_uic_response(ret, "GetMute") + if response and response.mute ~= nil then + response_map = { muted = response.mute,} end end return response_map @@ -342,8 +358,9 @@ function Command.getPlayStatus(ip) if ip then local url = format_url(ip, "/UIC?cmd=GetPlayStatus") local ret = handle_http_request(ip, url) - if ret then - response_map = { playstatus = ret.handler_res.root.UIC.response.playstatus,} + local _, response = get_uic_response(ret, "GetPlayStatus") + if response and response.playstatus ~= nil then + response_map = { playstatus = response.playstatus,} end end return response_map diff --git a/drivers/SmartThings/samsung-audio/src/handlers.lua b/drivers/SmartThings/samsung-audio/src/handlers.lua index 6233a86326..65a0027bf9 100644 --- a/drivers/SmartThings/samsung-audio/src/handlers.lua +++ b/drivers/SmartThings/samsung-audio/src/handlers.lua @@ -137,7 +137,7 @@ end function CapabilityHandlers.handle_audio_notification(driver, device, cmd) local ip = device:get_field("ip") local mute_status = command.getMute(ip) - if mute_status.muted ~= "off" then + if mute_status and mute_status.muted ~= nil and mute_status.muted ~= "off" then --unmute before playig notification command.unmute(ip) end diff --git a/drivers/SmartThings/samsung-audio/src/init.lua b/drivers/SmartThings/samsung-audio/src/init.lua index de1958ff25..2e51b8e0b4 100644 --- a/drivers/SmartThings/samsung-audio/src/init.lua +++ b/drivers/SmartThings/samsung-audio/src/init.lua @@ -94,14 +94,23 @@ local function emit_refresh_data_to_server(driver, device, cmd) -- get volume local vol = command.volume(device:get_field("ip")) - device:emit_event(capabilities.audioVolume.volume(tonumber(vol.volume))) + local current_volume = vol and tonumber(vol.volume) + if current_volume ~= nil then + device:emit_event(capabilities.audioVolume.volume(current_volume)) + else + log.warn("Unable to read speaker volume from refresh response") + end -- get mute status local muteStatus = command.getMute(device:get_field("ip")) - if muteStatus.muted ~= "off" then - device:emit_event(capabilities.audioMute.mute.muted()) + if muteStatus and muteStatus.muted ~= nil then + if muteStatus.muted ~= "off" then + device:emit_event(capabilities.audioMute.mute.muted()) + else + device:emit_event(capabilities.audioMute.mute.unmuted()) + end else - device:emit_event(capabilities.audioMute.mute.unmuted()) + log.warn("Unable to read speaker mute state from refresh response") end end From 90b5145085de9dff5fab5ea0afd54960b4f24a06 Mon Sep 17 00:00:00 2001 From: cjswedes Date: Wed, 3 Jun 2026 16:04:27 -0500 Subject: [PATCH 2/4] matter-lock: guard unknown lock state capability emissions Matter lock state reports can include values not present in the legacy LOCK_STATE map. When that happens, lock_state_handler passed nil to device:emit_event, which crashes with attempt to index nil capability_event in st/device.lua and interrupts lock event processing for the device. Add a nil check before emit_event and fall back to capabilities.lock.lock.unknown() for unrecognized lock state values. This prevents coroutine failures while still surfacing a safe lock state to users. Also add an integration test that injects an unknown LockState value and verifies the driver emits lock.unknown instead of crashing. --- drivers/SmartThings/matter-lock/src/init.lua | 8 ++++++- .../matter-lock/src/test/test_matter_lock.lua | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/matter-lock/src/init.lua b/drivers/SmartThings/matter-lock/src/init.lua index 4f58e960e9..09f384ee4e 100755 --- a/drivers/SmartThings/matter-lock/src/init.lua +++ b/drivers/SmartThings/matter-lock/src/init.lua @@ -83,7 +83,13 @@ local function lock_state_handler(driver, device, ib, response) } if ib.data.value ~= nil then - device:emit_event(LOCK_STATE[ib.data.value]) + local event = LOCK_STATE[ib.data.value] + if event ~= nil then + device:emit_event(event) + else + device.log.warn(string.format("Received unknown lock state value (%s), emitting unknown", ib.data.value)) + device:emit_event(attr.unknown()) + end else device:emit_event(LOCK_STATE[LockState.NOT_FULLY_LOCKED]) end diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock.lua index 2477e2745b..9693289951 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_matter_lock.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock.lua @@ -191,6 +191,29 @@ test.register_message_test( } ) +test.register_message_test( + "Handle unknown LockState value from Matter device.", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.DoorLock.attributes.LockState:build_test_report_data( + mock_device, 10, 0xFF + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown()), + }, + }, + { + min_api_version = 17 + } +) + test.register_message_test( "Handle received BatPercentRemaining from device.", { { From b5b9a97f017eb14344b5f7164d0914f67457c0bc Mon Sep 17 00:00:00 2001 From: cjswedes Date: Wed, 3 Jun 2026 16:05:03 -0500 Subject: [PATCH 3/4] Fix Sinope valve scheduled battery read device context --- drivers/SmartThings/zigbee-valve/src/sinope/init.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/zigbee-valve/src/sinope/init.lua b/drivers/SmartThings/zigbee-valve/src/sinope/init.lua index ab3786511c..0c6994ad00 100644 --- a/drivers/SmartThings/zigbee-valve/src/sinope/init.lua +++ b/drivers/SmartThings/zigbee-valve/src/sinope/init.lua @@ -12,7 +12,9 @@ local PowerConfiguration = clusters.PowerConfiguration local function device_init(driver, device) battery_defaults.use_battery_voltage_handling(device) -- according to the DTH, this attribute cannot be configured for reporting - device.thread:call_on_schedule(900, function() device:send(PowerConfiguration.attributes.BatteryVoltage:read()) end) + device.thread:call_on_schedule(900, function() + device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) + end) end local function battery_voltage_handler(driver, device, command) From a63f5fd2ccf1d827be35912ef86ea85045e9ac09 Mon Sep 17 00:00:00 2001 From: cjswedes Date: Wed, 3 Jun 2026 16:07:20 -0500 Subject: [PATCH 4/4] Fix ZLL polling coroutine and stale device callbacks Add zll poll time maintenance to avoid stale timers when devices are removed. Add nil check in poll callback --- .../zigbee-switch/src/zll-polling/init.lua | 65 +++++++++++++------ 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/src/zll-polling/init.lua b/drivers/SmartThings/zigbee-switch/src/zll-polling/init.lua index b464b30acc..814e0f70ce 100644 --- a/drivers/SmartThings/zigbee-switch/src/zll-polling/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zll-polling/init.lua @@ -5,36 +5,63 @@ local device_lib = require "st.device" local clusters = require "st.zigbee.zcl.clusters" local configurationMap = require "configurations" -local function set_up_zll_polling(driver, device) - local INFREQUENT_POLL_COUNTER = "_infrequent_poll_counter" - local function poll() - local infrequent_counter = device:get_field(INFREQUENT_POLL_COUNTER) or 1 - if infrequent_counter == 12 then - -- do a full refresh once an hour - device:refresh() - infrequent_counter = 0 - else - -- Read On/Off every poll - for _, ep in pairs(device.zigbee_endpoints) do - if device:supports_server_cluster(clusters.OnOff.ID, ep.id) then - device:send(clusters.OnOff.attributes.OnOff:read(device):to_endpoint(ep.id)) - end +local INFREQUENT_POLL_COUNTER = "_infrequent_poll_counter" +local ZLL_POLL_TIMER = "_zll_poll_timer" + +local function do_zll_poll(device) + if device == nil or type(device.get_field) ~= "function" then + return + end + + local infrequent_counter = device:get_field(INFREQUENT_POLL_COUNTER) or 1 + if infrequent_counter == 12 then + -- do a full refresh once an hour + device:refresh() + infrequent_counter = 0 + else + -- Read On/Off every poll + for _, ep in pairs(device.zigbee_endpoints) do + if device:supports_server_cluster(clusters.OnOff.ID, ep.id) then + device:send(clusters.OnOff.attributes.OnOff:read(device):to_endpoint(ep.id)) end - infrequent_counter = infrequent_counter + 1 end - device:set_field(INFREQUENT_POLL_COUNTER, infrequent_counter) + infrequent_counter = infrequent_counter + 1 end + device:set_field(INFREQUENT_POLL_COUNTER, infrequent_counter) +end +local function set_up_zll_polling(driver, device) -- only set this up for non-child devices - if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then - device.thread:call_on_schedule(5 * 60, poll, "zll_polling") + if device.network_type ~= device_lib.NETWORK_TYPE_ZIGBEE then + return + end + + -- should never happen, but defensive check + local existing_timer = device:get_field(ZLL_POLL_TIMER) + if existing_timer ~= nil then + device.thread:cancel_timer(existing_timer) + end + + local timer = device.thread:call_on_schedule(5 * 60, function() + do_zll_poll(device) + end, "zll_polling") + + device:set_field(ZLL_POLL_TIMER, timer) +end + +local function remove_zll_polling(driver, device) + local existing_timer = device:get_field(ZLL_POLL_TIMER) + if existing_timer ~= nil then + device.thread:cancel_timer(existing_timer) + device:set_field(ZLL_POLL_TIMER, nil) end end local ZLL_polling = { NAME = "ZLL Polling", lifecycle_handlers = { - init = configurationMap.reconfig_wrapper(set_up_zll_polling) + init = configurationMap.reconfig_wrapper(set_up_zll_polling), + removed = remove_zll_polling }, can_handle = require("zll-polling.can_handle"), }