From 834ee18ae6001d2437b8a3f9cf60ba7894f85cc1 Mon Sep 17 00:00:00 2001 From: cjswedes Date: Fri, 17 Jul 2026 09:52:46 -0500 Subject: [PATCH 1/6] zigbee-range-extender: guard against nil device in health check Driver:get_device_info can return (nil, err) when the devices API request fails or the device is no longer known to the driver (e.g. transient device_api error, or a device removed between building the device list and looking it up). device_health_check ignored the second return value and unconditionally called device:send(...), which internally calls device:get_short_address() in cluster_base.lua, causing 'attempt to call a nil value (method get_short_address)' when device was nil. Impact: the periodic health-check coroutine crashes with an unhandled error, which can disrupt the scheduled health-check loop and spam hub logs, potentially leaving range extender devices marked unhealthy/unresponsive even though the actual issue was just a lookup failure. Fix: check the return value of get_device_info and skip sending the ZCLVersion read (logging a warning) when the device could not be resolved, instead of dereferencing nil. Co-Authored-By: OpenCode --- drivers/SmartThings/zigbee-range-extender/src/init.lua | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/drivers/SmartThings/zigbee-range-extender/src/init.lua b/drivers/SmartThings/zigbee-range-extender/src/init.lua index f6ba3c1ffc..f06988f5cf 100644 --- a/drivers/SmartThings/zigbee-range-extender/src/init.lua +++ b/drivers/SmartThings/zigbee-range-extender/src/init.lua @@ -3,6 +3,7 @@ local capabilities = require "st.capabilities" +local log = require "log" local defaults = require "st.zigbee.defaults" local Basic = (require "st.zigbee.zcl.clusters").Basic local ZigbeeDriver = require "st.zigbee" @@ -33,8 +34,13 @@ local zigbee_range_extender_driver = ZigbeeDriver("zigbee-range-extender", zigbe function zigbee_range_extender_driver:device_health_check() local device_list = self.device_api.get_device_list() for _, device_id in ipairs(device_list) do - local device = self:get_device_info(device_id, false) - device:send(Basic.attributes.ZCLVersion:read(device)) + local device, err = self:get_device_info(device_id, false) + if device == nil then + log.warn_with({ hub_logs = true }, + string.format("device_health_check failed to get device info for device_id %s: %s", device_id, err)) + else + device:send(Basic.attributes.ZCLVersion:read(device)) + end end end From e6811a1f2fac309567d851d5e93dd769a0bced34 Mon Sep 17 00:00:00 2001 From: cjswedes Date: Fri, 17 Jul 2026 09:52:47 -0500 Subject: [PATCH 2/6] jbl: fix coroutine crash from concatenating nil credential in add_header update_connection() fetched the CREDENTIAL device field and passed it directly to jbl_api:add_header without checking for nil, unlike the sibling call site in device_added(). When a device reconnects via find_new_connection() before a credential has been stored on the device, add_header's string concatenation ('.. .. value') throws 'attempt to concatenate a nil value (local value)', which crashes the coroutine handling the device and can leave the device connection in a bad state (no SSE/health-check updates) until the driver restarts. Fix: - Guard update_connection so it logs the missing credential, marks the device offline, and returns early instead of reaching add_header with a nil value. - Harden jbl_api:add_header to tostring() its key/value before concatenation as defense-in-depth, so any other future caller can't trigger the same crash. Co-Authored-By: OpenCode --- drivers/SmartThings/jbl/src/init.lua | 5 +++++ drivers/SmartThings/jbl/src/jbl/api.lua | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/jbl/src/init.lua b/drivers/SmartThings/jbl/src/init.lua index f422ecf84c..830a98a181 100644 --- a/drivers/SmartThings/jbl/src/init.lua +++ b/drivers/SmartThings/jbl/src/init.lua @@ -82,6 +82,11 @@ local function update_connection(driver, device, device_ip, device_info) local conn_info = driver.discovery_helper.get_connection_info(driver, device_dni, device_ip, device_info) local credential = device:get_field(fields.CREDENTIAL) + if not credential then + log.error("update_connection : failed to find credential, dni = " .. tostring(device_dni)) + device:offline() + return + end conn_info:add_header(CREDENTIAL_KEY_HEADER, credential) diff --git a/drivers/SmartThings/jbl/src/jbl/api.lua b/drivers/SmartThings/jbl/src/jbl/api.lua index 95e25d14c3..7479289549 100644 --- a/drivers/SmartThings/jbl/src/jbl/api.lua +++ b/drivers/SmartThings/jbl/src/jbl/api.lua @@ -77,7 +77,7 @@ function jbl_api.new_device_manager(bridge_ip, bridge_info, socket_builder) end function jbl_api:add_header(key, value) - log.info("add_header : " .. key .. ", " .. value) + log.info("add_header : " .. tostring(key) .. ", " .. tostring(value)) self.headers[key] = value end From 57ae75d26dbd59d9bab3f695887a2551bc28ae2e Mon Sep 17 00:00:00 2001 From: cjswedes Date: Fri, 17 Jul 2026 09:53:40 -0500 Subject: [PATCH 3/6] aqara-presence-sensor: guard against receive(0) crashing SSE reader The Aqara Presence Sensor FP2 driver uses an SSE/HTTP chunked-transfer client (lunchbox/sse/eventsource.lua) to stream events from the device. When a chunk-encoded response ends, the server sends a zero-length chunk header ("0\r\n"). The reader parsed this as recv_as_num == 0 and called sock:receive(0), which the shared cosock TCP socket (cosock/socket/tcp.lua) does not handle gracefully: its output transform asserts that the received data is non-nil, so a 0-byte receive raises "socket receive returned nil data" instead of returning a normal result. That assertion failure crashes the coroutine driving the device's LAN connection, which drops presence updates until the hub restarts the driver. Fix: when the parsed chunk size is 0, skip the socket receive call entirely and treat it as an empty read, then continue on to consume the trailing CRLF as before. This avoids ever calling receive(0) on the socket, preventing the crash while preserving existing timeout/ closed/error handling paths. Co-Authored-By: OpenCode --- .../src/lunchbox/sse/eventsource.lua | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/drivers/Aqara/aqara-presence-sensor/src/lunchbox/sse/eventsource.lua b/drivers/Aqara/aqara-presence-sensor/src/lunchbox/sse/eventsource.lua index 7c72f2d81a..9b64844821 100644 --- a/drivers/Aqara/aqara-presence-sensor/src/lunchbox/sse/eventsource.lua +++ b/drivers/Aqara/aqara-presence-sensor/src/lunchbox/sse/eventsource.lua @@ -365,18 +365,26 @@ local function open_action(source) local recv_as_num = tonumber(recv, 16) if recv_as_num ~= nil then - recv, err, partial = source._sock:receive(recv_as_num) - if err then - if err == "timeout" or err == "wantread" then - return - else - --- real error, close the connection. - if source._sock ~= nil then - source._sock:close() - source._sock = nil + -- A chunk size of 0 signals the end of the chunked transfer. Some + -- versions of the underlying socket library will raise a hard error + -- (rather than returning a graceful nil/"closed" pair) if we ask to + -- receive 0 bytes, so avoid calling receive() in that case entirely. + if recv_as_num == 0 then + recv = "" + else + recv, err, partial = source._sock:receive(recv_as_num) + if err then + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + if source._sock ~= nil then + source._sock:close() + source._sock = nil + end + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial end - source.ready_state = EventSource.ReadyStates.CLOSED - return nil, err, partial end end local _, err, partial = source._sock:receive('*l') -- clear the final line From 9d7daafdce5b26a9944690b6a31fdcc29c9c8024 Mon Sep 17 00:00:00 2001 From: cjswedes Date: Fri, 17 Jul 2026 09:54:08 -0500 Subject: [PATCH 4/6] zwave-sensor: guard tamper-clear timer against device removal The timed-tamper-clear sub-driver schedules a delayed callback (via device.thread:call_with_delay) to emit a tamperAlert clear event a fixed time after a tamper notification is received. If the device is removed from the hub before that timer fires, st.device's Device:deleted() swaps the device's metatable so that every field and method lookup returns nil. When the pending timer callback later runs and calls device:emit_event_for_endpoint(...), that method resolves to nil, producing: attempt to call a nil value (method 'emit_event_for_endpoint') This crashed the device's Lua thread coroutine, so any other pending work for that device (or shared device thread, since this driver uses shared_device_thread_enabled) could be disrupted, degrading the user experience for other devices using the same driver/thread. Fix: check device.id (which becomes nil once the device is deleted) before touching the device inside the delayed callback, and skip the event emission/timer cleanup if the device is no longer valid. Co-Authored-By: OpenCode --- .../zwave-sensor/src/timed-tamper-clear/init.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/init.lua b/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/init.lua index 8554dab28f..dd6cc672f4 100644 --- a/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/init.lua @@ -22,6 +22,13 @@ local function handle_tamper_event(driver, device, cmd) device.thread:cancel_timer(tamper_timer) end device:set_field(TAMPER_TIMER, device.thread:call_with_delay(TAMPER_CLEAR, function() + -- The device can be removed while this timer is still pending. Once a device is + -- deleted its metatable is swapped so that all field/method lookups return nil + -- (see Device:deleted() in st.device), so guard against that here to avoid + -- "attempt to call a nil value (method 'emit_event_for_endpoint')" errors. + if device.id == nil then + return + end device:emit_event_for_endpoint(cmd.src_channel, capabilities.tamperAlert.tamper.clear()) device:set_field(TAMPER_TIMER, nil) end)) From e53691e9f6c10253a525263b54ace6e06177f561 Mon Sep 17 00:00:00 2001 From: cjswedes Date: Fri, 17 Jul 2026 09:56:57 -0500 Subject: [PATCH 5/6] zigbee-switch(inovelli): guard nil parent device in child notification handlers Child (notification) devices call device:get_parent_device() and then schedule a 1s-delayed closure that sends a manufacturer-specific command via the captured parent reference. If the parent device is removed (or otherwise not resolvable, e.g. during a join/removal race) at command time, get_parent_device() returns nil. The delayed closure then invokes cluster_base.build_manufacturer_specific_command(nil, ...), which calls device:get_short_address() on nil, crashing the coroutine with: attempt to call a nil value (method 'get_short_address') This meant on/off/level/color/color-temperature commands on an Inovelli notification child device could silently crash the driver (breaking further command handling) instead of just failing to notify the parent. Fix: check the parent device reference for nil immediately after get_parent_device() and bail out before scheduling the send, in on_handler, off_handler, switch_level_handler, set_color_temperature, and set_color. Also updated getNotificationValue to accept the already resolved parent device instead of re-fetching it, avoiding a second nil dereference of the same kind. Co-Authored-By: OpenCode --- .../zigbee-switch/src/inovelli/init.lua | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/src/inovelli/init.lua b/drivers/SmartThings/zigbee-switch/src/inovelli/init.lua index a83a343663..97cd33e821 100644 --- a/drivers/SmartThings/zigbee-switch/src/inovelli/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/inovelli/init.lua @@ -258,11 +258,11 @@ local function huePercentToValue(value) else return utils.round(value / 100 * 255) end end -local function getNotificationValue(device, value) +local function getNotificationValue(device, parent, value) local notificationValue = 0 local level = device:get_latest_state("main", capabilities.switchLevel.ID, capabilities.switchLevel.level.NAME) or 100 local color = utils.round(device:get_latest_state("main", capabilities.colorControl.ID, capabilities.colorControl.hue.NAME) or 100) - local effect = device:get_parent_device().preferences.notificationType or 1 + local effect = (parent and parent.preferences.notificationType) or 1 notificationValue = notificationValue + (effect*16777216) notificationValue = notificationValue + (huePercentToValue(value or color)*65536) notificationValue = notificationValue + (level*256) @@ -276,13 +276,16 @@ local function on_handler(driver, device, command) else device:emit_event(capabilities.switch.switch("on")) local dev = device:get_parent_device() + if dev == nil then + return + end local send_configuration = function() dev:send(cluster_base.build_manufacturer_specific_command( dev, PRIVATE_CLUSTER_ID, PRIVATE_CMD_NOTIF_ID, MFG_CODE, - utils.serialize_int(getNotificationValue(device),4,false,false))) + utils.serialize_int(getNotificationValue(device, dev),4,false,false))) end device.thread:call_with_delay(1,send_configuration) end @@ -294,6 +297,9 @@ local function on_handler(driver, device, command) else device:emit_event(capabilities.switch.switch("off")) local dev = device:get_parent_device() + if dev == nil then + return + end local send_configuration = function() dev:send(cluster_base.build_manufacturer_specific_command( dev, @@ -313,13 +319,16 @@ local function switch_level_handler(driver, device, command) device:emit_event(capabilities.switchLevel.level(command.args.level)) device:emit_event(capabilities.switch.switch(command.args.level ~= 0 and "on" or "off")) local dev = device:get_parent_device() + if dev == nil then + return + end local send_configuration = function() dev:send(cluster_base.build_manufacturer_specific_command( dev, PRIVATE_CLUSTER_ID, PRIVATE_CMD_NOTIF_ID, MFG_CODE, - utils.serialize_int(getNotificationValue(device),4,false,false))) + utils.serialize_int(getNotificationValue(device, dev),4,false,false))) end device.thread:call_with_delay(1,send_configuration) end @@ -330,13 +339,16 @@ local function set_color_temperature(driver, device, command) device:emit_event(capabilities.colorTemperature.colorTemperature(command.args.temperature)) device:emit_event(capabilities.switch.switch("on")) local dev = device:get_parent_device() + if dev == nil then + return + end local send_configuration = function() dev:send(cluster_base.build_manufacturer_specific_command( dev, PRIVATE_CLUSTER_ID, PRIVATE_CMD_NOTIF_ID, MFG_CODE, - utils.serialize_int(getNotificationValue(device, 100),4,false,false))) + utils.serialize_int(getNotificationValue(device, dev, 100),4,false,false))) end device.thread:call_with_delay(1,send_configuration) end @@ -346,13 +358,16 @@ local function set_color_temperature(driver, device, command) device:emit_event(capabilities.colorControl.saturation(command.args.color.saturation)) device:emit_event(capabilities.switch.switch("on")) local dev = device:get_parent_device() + if dev == nil then + return + end local send_configuration = function() dev:send(cluster_base.build_manufacturer_specific_command( dev, PRIVATE_CLUSTER_ID, PRIVATE_CMD_NOTIF_ID, MFG_CODE, - utils.serialize_int(getNotificationValue(device),4,false,false))) + utils.serialize_int(getNotificationValue(device, dev),4,false,false))) end device.thread:call_with_delay(1,send_configuration) end From 93d2b3527b070fe23bd95afb064ae30a10de797e Mon Sep 17 00:00:00 2001 From: cjswedes Date: Fri, 17 Jul 2026 09:57:26 -0500 Subject: [PATCH 6/6] matter-lock: guard nil capability event for unmapped LockState value Co-Authored-By: OpenCode --- drivers/SmartThings/matter-lock/src/init.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/drivers/SmartThings/matter-lock/src/init.lua b/drivers/SmartThings/matter-lock/src/init.lua index 5d82801b67..f94702c496 100755 --- a/drivers/SmartThings/matter-lock/src/init.lua +++ b/drivers/SmartThings/matter-lock/src/init.lua @@ -436,10 +436,10 @@ local function lock_state_handler(driver, device, ib, response) -- In this case, two events occur. To prevent this, when both functions are called, -- it send the event after 1 second so that no event occurs in the lock_state_handler. device.thread:call_with_delay(1, function () - if ib.data.value ~= nil then + if ib.data.value ~= nil and LOCK_STATE[ib.data.value] ~= nil then device:emit_event(LOCK_STATE[ib.data.value]) else - device.log.warn("Lock State is nil") + device.log.warn(string.format("Received unknown Lock State: %s", ib.data.value)) end end) end