From 5518991b334a67b6274497b07d296a462647a16f Mon Sep 17 00:00:00 2001 From: JS Woo Date: Tue, 13 Oct 2026 15:39:36 +0900 Subject: [PATCH] Matter Sensor: add the ThingsOne Presence Sensor sub-driver --- .../matter-sensor/fingerprints.yml | 6 + .../profiles/thingsone-presence-sensor.yml | 29 ++ .../matter-sensor/src/sensor_utils/fields.lua | 3 + .../matter-sensor/src/sub_drivers.lua | 1 + .../thingsone_presence_sensor/can_handle.lua | 12 + .../thingsone_presence_sensor/init.lua | 131 +++++++ .../test_matter_thingsone_presence_sensor.lua | 344 ++++++++++++++++++ 7 files changed, 526 insertions(+) create mode 100644 drivers/SmartThings/matter-sensor/profiles/thingsone-presence-sensor.yml create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/thingsone_presence_sensor/can_handle.lua create mode 100644 drivers/SmartThings/matter-sensor/src/sub_drivers/thingsone_presence_sensor/init.lua create mode 100644 drivers/SmartThings/matter-sensor/src/test/test_matter_thingsone_presence_sensor.lua diff --git a/drivers/SmartThings/matter-sensor/fingerprints.yml b/drivers/SmartThings/matter-sensor/fingerprints.yml index dc42ad4b07..efe618fca2 100644 --- a/drivers/SmartThings/matter-sensor/fingerprints.yml +++ b/drivers/SmartThings/matter-sensor/fingerprints.yml @@ -186,6 +186,12 @@ matterManufacturer: vendorId: 0x134E productId: 0x0006 deviceProfileName: temperature-humidity + #ThingsOne + - id: "4673/32784" + deviceLabel: ThingsOne Presence Sensor + vendorId: 0x1241 + productId: 0x8010 + deviceProfileName: thingsone-presence-sensor #Tuo - id: "5150/3" deviceLabel: "TUO Temperature Sensor" diff --git a/drivers/SmartThings/matter-sensor/profiles/thingsone-presence-sensor.yml b/drivers/SmartThings/matter-sensor/profiles/thingsone-presence-sensor.yml new file mode 100644 index 0000000000..99f4d14193 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/thingsone-presence-sensor.yml @@ -0,0 +1,29 @@ +name: thingsone-presence-sensor +components: +- id: main + capabilities: + - id: presenceSensor + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: PresenceSensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true + - preferenceId: stse.sensitivity + explicit: true + - preferenceId: stse.presenceHoldTime + explicit: true diff --git a/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua index 232e431277..1d7388aaf5 100644 --- a/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua @@ -52,6 +52,9 @@ SensorFields.BOOLEAN_CAP_EVENT_MAP = { SensorFields.vendor_overrides = { [0x115F] = { -- AQARA_MANUFACTURER_ID [0x2009] = { is_aqara_fp400 = true } + }, + [0x1241] = { -- SAMJIN_MANUFACTURER_ID + [0x8010] = { is_thingsone_presence_sensor = true } } } diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers.lua index aa8b046927..406185d0fa 100644 --- a/drivers/SmartThings/matter-sensor/src/sub_drivers.lua +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers.lua @@ -7,5 +7,6 @@ local sub_drivers = { lazy_load_if_possible("sub_drivers.smoke_co_alarm"), lazy_load_if_possible("sub_drivers.bosch_button_contact"), lazy_load_if_possible("sub_drivers.aqara_fp400"), + lazy_load_if_possible("sub_drivers.thingsone_presence_sensor"), } return sub_drivers diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/thingsone_presence_sensor/can_handle.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/thingsone_presence_sensor/can_handle.lua new file mode 100644 index 0000000000..7cd433bdec --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/thingsone_presence_sensor/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_thingsone_presence_sensor(opts, driver, device) + local sensor_utils = require "sensor_utils.utils" + if sensor_utils.get_product_override_field(device, "is_thingsone_presence_sensor") then + return true, require("sub_drivers.thingsone_presence_sensor") + end + return false +end + +return is_thingsone_presence_sensor diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/thingsone_presence_sensor/init.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/thingsone_presence_sensor/init.lua new file mode 100644 index 0000000000..9c1e34567c --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/thingsone_presence_sensor/init.lua @@ -0,0 +1,131 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local st_utils = require "st.utils" + +local PROFILE = "thingsone-presence-sensor" +local RETRY_LIMIT = 3 +-- nil on hub libraries that predate this attribute +local HOLD_TIME_ATTR = clusters.OccupancySensing.attributes.HoldTime + +local settings = { + { + -- The firmware reads this attribute as its operation mode: 1 low power, 2 normal, 3 high power + attr = clusters.OccupancySensing.attributes.PIRUnoccupiedToOccupiedThreshold, + pref = "stse.sensitivity", + default = 2, min = 1, max = 3, + pending = "__thingsone_mode_pending", + }, +} +if HOLD_TIME_ATTR ~= nil then + table.insert(settings, { + -- HoldTime is the device's shipped 10 s plus the presence hold time; max keeps it within HoldTimeLimits 1..300 + attr = HOLD_TIME_ATTR, + pref = "stse.presenceHoldTime", + default = 0, min = 0, max = 290, + to_device = function(seconds) return 10 + seconds end, + pending = "__thingsone_hold_time_pending", + }) +end + +-- An unset preference stands for the preference default, which setting.default must equal +local function preference_value(setting, preferences) + local value = tonumber((preferences or {})[setting.pref]) or setting.default + return st_utils.clamp_value(math.floor(value), setting.min, setting.max) +end + +local function write_setting(device, setting, value, tries) + local ep = device:get_endpoints(clusters.OccupancySensing.ID)[1] + if ep == nil then + return + end + device:set_field(setting.pending, {value = value, tries = tries}) + device:send(setting.attr:write(device, ep, value)) + device:send(setting.attr:read(device)) +end + +-- Only a write made here is checked and retried; values written by other controllers are left alone +local function setting_report_handler(setting) + return function(driver, device, ib, response) + local pending = device:get_field(setting.pending) + if pending == nil or ib.data.value == nil then + return + end + if ib.data.value == pending.value then + device:set_field(setting.pending, nil) + elseif pending.tries < RETRY_LIMIT then + device.log.info(string.format("%s: device reports %d, rewriting %d (try %d)", setting.pref, ib.data.value, pending.value, pending.tries + 1)) + write_setting(device, setting, pending.value, pending.tries + 1) + else + device.log.warn(string.format("%s: device still reports %d, giving up on %d", setting.pref, ib.data.value, pending.value)) + device:set_field(setting.pending, nil) + end + end +end + +local occupancy_attr_handlers = {} +for _, setting in ipairs(settings) do + occupancy_attr_handlers[setting.attr.ID] = setting_report_handler(setting) +end + +local ThingsOneLifecycleHandlers = {} + +-- Keeps the base driver from re-matching the device to a profile without these settings +function ThingsOneLifecycleHandlers.do_configure(driver, device) + device:try_update_metadata({profile = PROFILE}) +end + +function ThingsOneLifecycleHandlers.driver_switched(driver, device) + device:try_update_metadata({profile = PROFILE}) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + +function ThingsOneLifecycleHandlers.device_init(driver, device) + if HOLD_TIME_ATTR == nil then + device.log.warn("OccupancySensing.HoldTime is missing from the hub libraries; the presence hold time setting has no effect") + end + for _, setting in ipairs(settings) do + device:add_subscribed_attribute(setting.attr) + end + device:subscribe() +end + +function ThingsOneLifecycleHandlers.info_changed(driver, device, event, args) + if device.profile.id ~= args.old_st_store.profile.id then + device:subscribe() + end + local old = args.old_st_store.preferences or {} + local new = device.preferences or {} + -- The platform adds the offset to emitted values, so re-read to show it before the next report + if new.tempOffset ~= nil and new.tempOffset ~= old.tempOffset then + device:send(clusters.TemperatureMeasurement.attributes.MeasuredValue:read(device)) + end + if new.humidityOffset ~= nil and new.humidityOffset ~= old.humidityOffset then + device:send(clusters.RelativeHumidityMeasurement.attributes.MeasuredValue:read(device)) + end + for _, setting in ipairs(settings) do + local value = preference_value(setting, new) + if value ~= preference_value(setting, old) then + write_setting(device, setting, setting.to_device and setting.to_device(value) or value, 0) + end + end +end + +local thingsone_presence_sensor_handler = { + NAME = "thingsone-presence-sensor", + lifecycle_handlers = { + doConfigure = ThingsOneLifecycleHandlers.do_configure, + driverSwitched = ThingsOneLifecycleHandlers.driver_switched, + infoChanged = ThingsOneLifecycleHandlers.info_changed, + init = ThingsOneLifecycleHandlers.device_init, + }, + matter_handlers = { + attr = { + [clusters.OccupancySensing.ID] = occupancy_attr_handlers, + }, + }, + can_handle = require("sub_drivers.thingsone_presence_sensor.can_handle"), +} + +return thingsone_presence_sensor_handler diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_thingsone_presence_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_thingsone_presence_sensor.lua new file mode 100644 index 0000000000..35b9ac1b44 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_thingsone_presence_sensor.lua @@ -0,0 +1,344 @@ +-- 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.matter.clusters" +local t_utils = require "integration_test.utils" + +local MODE = clusters.OccupancySensing.attributes.PIRUnoccupiedToOccupiedThreshold +-- nil on hub libraries that predate this attribute, as the sub-driver allows +local HOLD_TIME = clusters.OccupancySensing.attributes.HoldTime + +-- Endpoint and cluster layout of the device firmware +local matter_endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1}, -- RootNode + {device_type_id = 0x0011, device_type_revision = 1} -- PowerSource + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.OccupancySensing.ID, + cluster_type = "SERVER", + feature_map = clusters.OccupancySensing.types.Feature.PASSIVE_INFRARED | clusters.OccupancySensing.types.Feature.RADAR + }, + }, + device_types = { + {device_type_id = 0x0107, device_type_revision = 1} -- OccupancySensor + } + }, + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0302, device_type_revision = 1} -- TemperatureSensor + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.IlluminanceMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0106, device_type_revision = 1} -- LightSensor + } + }, + { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0307, device_type_revision = 1} -- HumiditySensor + } + }, +} + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("thingsone-presence-sensor.yml"), + manufacturer_info = {vendor_id = 0x1241, product_id = 0x8010}, + endpoints = matter_endpoints +}) + +local function subscribe_request(dev) + local request = clusters.OccupancySensing.attributes.Occupancy:subscribe(dev) + request:merge(clusters.TemperatureMeasurement.attributes.MeasuredValue:subscribe(dev)) + request:merge(clusters.TemperatureMeasurement.attributes.MinMeasuredValue:subscribe(dev)) + request:merge(clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:subscribe(dev)) + request:merge(clusters.IlluminanceMeasurement.attributes.MeasuredValue:subscribe(dev)) + request:merge(clusters.RelativeHumidityMeasurement.attributes.MeasuredValue:subscribe(dev)) + request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(dev)) + request:merge(MODE:subscribe(dev)) + if HOLD_TIME ~= nil then + request:merge(HOLD_TIME:subscribe(dev)) + end + return request +end + +local function test_init() + test.socket.matter:__expect_send({mock_device.id, subscribe_request(mock_device)}) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function preference(id, value) + return mock_device:generate_info_changed({preferences = {[id] = value}}) +end + +local function expect_write(attr, value) + test.socket.matter:__expect_send({mock_device.id, attr:write(mock_device, 1, value)}) + test.socket.matter:__expect_send({mock_device.id, attr:read(mock_device)}) +end + +local function report(attr, value) + return {mock_device.id, attr:build_test_report_data(mock_device, 1, value)} +end + +test.register_coroutine_test( + "doConfigure should keep the ThingsOne profile instead of reading the PowerSource AttributeList", + function() + test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({profile = "thingsone-presence-sensor"}) + -- When doConfigure completes successfully, the framework automatically provisions the device + mock_device:expect_metadata_update({provisioning_state = "PROVISIONED"}) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "driverSwitched should set the ThingsOne profile and provision the device", + function() + test.socket.device_lifecycle:__queue_receive({mock_device.id, "driverSwitched"}) + mock_device:expect_metadata_update({profile = "thingsone-presence-sensor"}) + mock_device:expect_metadata_update({provisioning_state = "PROVISIONED"}) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "A profile change should renew the subscription", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({profile = {id = "00000000-1111-2222-3333-000000000004"}})) + test.socket.matter:__expect_send({mock_device.id, subscribe_request(mock_device)}) + test.wait_for_events() + end +) + +test.register_message_test( + "Occupancy reports should be passed on at once by the base driver", + { + { + channel = "matter", + direction = "receive", + message = report(clusters.OccupancySensing.attributes.Occupancy, 1) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.presenceSensor.presence("present")) + }, + { + channel = "matter", + direction = "receive", + message = report(clusters.OccupancySensing.attributes.Occupancy, 0) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.presenceSensor.presence("not present")) + } + } +) + +local function expect_no_write(attr, value) + test.socket.matter:__queue_receive(report(attr, value)) + test.wait_for_events() +end + +local function expect_rewrites_then_give_up(attr, wanted, other) + for _ = 1, 3 do + test.socket.matter:__queue_receive(report(attr, other)) + expect_write(attr, wanted) + test.wait_for_events() + end + expect_no_write(attr, other) +end + +test.register_coroutine_test( + "The first and later tempOffset changes should re-read only the temperature", + function() + test.socket.device_lifecycle:__queue_receive(preference("tempOffset", "1")) + test.socket.matter:__expect_send({mock_device.id, clusters.TemperatureMeasurement.attributes.MeasuredValue:read(mock_device)}) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(preference("tempOffset", "2")) + test.socket.matter:__expect_send({mock_device.id, clusters.TemperatureMeasurement.attributes.MeasuredValue:read(mock_device)}) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(preference("tempOffset", "2")) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "The first and later humidityOffset changes should re-read only the humidity", + function() + test.socket.device_lifecycle:__queue_receive(preference("humidityOffset", "-3")) + test.socket.matter:__expect_send({mock_device.id, clusters.RelativeHumidityMeasurement.attributes.MeasuredValue:read(mock_device)}) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(preference("humidityOffset", "2")) + test.socket.matter:__expect_send({mock_device.id, clusters.RelativeHumidityMeasurement.attributes.MeasuredValue:read(mock_device)}) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "A sensitivity change should write the operation mode on EP1 and read it back", + function() + test.socket.device_lifecycle:__queue_receive(preference("stse.sensitivity", "3")) + expect_write(MODE, 3) + test.wait_for_events() + expect_no_write(MODE, 3) + test.socket.device_lifecycle:__queue_receive(preference("stse.sensitivity", "1")) + expect_write(MODE, 1) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "A sensitivity that maps to the current target should not be written", + function() + test.socket.device_lifecycle:__queue_receive(preference("stse.sensitivity", "2")) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(preference("stse.sensitivity", "3")) + expect_write(MODE, 3) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(preference("stse.sensitivity", "3")) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "A sensitivity outside 1..3 should be clamped", + function() + test.socket.device_lifecycle:__queue_receive(preference("stse.sensitivity", "5")) + expect_write(MODE, 3) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(preference("stse.sensitivity", "0")) + expect_write(MODE, 1) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "A write that does not take should be retried three times, then given up", + function() + test.socket.device_lifecycle:__queue_receive(preference("stse.sensitivity", "3")) + expect_write(MODE, 3) + test.wait_for_events() + expect_rewrites_then_give_up(MODE, 3, 2) + end +) + +test.register_coroutine_test( + "A new setting after giving up should be written and retried again", + function() + test.socket.device_lifecycle:__queue_receive(preference("stse.sensitivity", "3")) + expect_write(MODE, 3) + test.wait_for_events() + expect_rewrites_then_give_up(MODE, 3, 2) + test.socket.device_lifecycle:__queue_receive(preference("stse.sensitivity", "1")) + expect_write(MODE, 1) + test.wait_for_events() + test.socket.matter:__queue_receive(report(MODE, 2)) + expect_write(MODE, 1) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "A value written by another controller should be left alone", + function() + expect_no_write(MODE, 3) + test.socket.device_lifecycle:__queue_receive(preference("stse.sensitivity", "1")) + expect_write(MODE, 1) + test.wait_for_events() + expect_no_write(MODE, 1) + expect_no_write(MODE, 3) + end +) + +if HOLD_TIME ~= nil then + test.register_coroutine_test( + "A presence hold time change should write HoldTime as 10 s plus the setting on EP1 and read it back", + function() + test.socket.device_lifecycle:__queue_receive(preference("stse.presenceHoldTime", "60")) + expect_write(HOLD_TIME, 70) + test.wait_for_events() + expect_no_write(HOLD_TIME, 70) + expect_no_write(HOLD_TIME, 120) + test.socket.device_lifecycle:__queue_receive(preference("stse.presenceHoldTime", "5")) + expect_write(HOLD_TIME, 15) + test.wait_for_events() + end + ) + + test.register_coroutine_test( + "The default presence hold time of 0 should not be written, the device already holds 10 s", + function() + test.socket.device_lifecycle:__queue_receive(preference("stse.presenceHoldTime", "0")) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(preference("stse.presenceHoldTime", "30")) + expect_write(HOLD_TIME, 40) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(preference("stse.presenceHoldTime", "0")) + expect_write(HOLD_TIME, 10) + test.wait_for_events() + end + ) + + test.register_coroutine_test( + "A numeric or fractional presence hold time should be written as whole seconds", + function() + test.socket.device_lifecycle:__queue_receive(preference("stse.presenceHoldTime", 15)) + expect_write(HOLD_TIME, 25) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(preference("stse.presenceHoldTime", 30.0)) + expect_write(HOLD_TIME, 40) + test.wait_for_events() + end + ) + + test.register_coroutine_test( + "A presence hold time outside 0..290 s should be clamped so HoldTime stays within 10..300 s", + function() + test.socket.device_lifecycle:__queue_receive(preference("stse.presenceHoldTime", "600")) + expect_write(HOLD_TIME, 300) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(preference("stse.presenceHoldTime", "-5")) + expect_write(HOLD_TIME, 10) + test.wait_for_events() + end + ) + + test.register_coroutine_test( + "A HoldTime write that does not take should be retried three times, then given up", + function() + test.socket.device_lifecycle:__queue_receive(preference("stse.presenceHoldTime", "60")) + expect_write(HOLD_TIME, 70) + test.wait_for_events() + expect_rewrites_then_give_up(HOLD_TIME, 70, 10) + end + ) +end + +test.run_registered_tests()