diff --git a/drivers/SmartThings/matter-sensor/fingerprints.yml b/drivers/SmartThings/matter-sensor/fingerprints.yml index 47e7477af6..df44ea200d 100644 --- a/drivers/SmartThings/matter-sensor/fingerprints.yml +++ b/drivers/SmartThings/matter-sensor/fingerprints.yml @@ -10,6 +10,12 @@ matterManufacturer: vendorId: 0x115F productId: 0x2003 deviceProfileName: motion-illuminance-battery + #Bosch + - id: 4617/12309 + deviceLabel: "Door/window contact II [M]" + vendorId: 0x1209 + productId: 0x3015 + deviceProfileName: contact-button-battery #Elko - id: "5170/4098" deviceLabel: RFWD-100/MT diff --git a/drivers/SmartThings/matter-sensor/profiles/contact-button-battery.yml b/drivers/SmartThings/matter-sensor/profiles/contact-button-battery.yml new file mode 100644 index 0000000000..f4e1d1ddae --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/contact-button-battery.yml @@ -0,0 +1,16 @@ +name: contact-button-battery +components: +- id: main + capabilities: + - id: contactSensor + version: 1 + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor diff --git a/drivers/SmartThings/matter-sensor/src/bosch-button-contact/init.lua b/drivers/SmartThings/matter-sensor/src/bosch-button-contact/init.lua new file mode 100644 index 0000000000..1dfd3960ce --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/bosch-button-contact/init.lua @@ -0,0 +1,162 @@ +-- Copyright 2025 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local device_lib = require "st.device" +local lua_socket = require "socket" +local log = require "log" + +local START_BUTTON_PRESS = "__start_button_press" + +local BOSCH_VENDOR_ID = 0x1209 +local BOSCH_PRODUCT_ID = 0x3015 + +local function is_bosch_button_contact(opts, driver, device) + if device.network_type == device_lib.NETWORK_TYPE_MATTER and + device.manufacturer_info.vendor_id == BOSCH_VENDOR_ID and + device.manufacturer_info.product_id == BOSCH_PRODUCT_ID then + return true + end + return false +end + +local function get_field_for_endpoint(device, field, endpoint) + return device:get_field(string.format("%s_%d", field, endpoint)) +end + +local function set_field_for_endpoint(device, field, endpoint, value, additional_params) + device:set_field(string.format("%s_%d", field, endpoint), value, additional_params) +end + +local function init_press(device, endpoint) + set_field_for_endpoint(device, START_BUTTON_PRESS, endpoint, lua_socket.gettime(), {persist = false}) +end + +-- Some switches will send a MultiPressComplete event as part of a long press sequence. Normally the driver will create a +-- button capability event on receipt of MultiPressComplete, but in this case that would result in an extra event because +-- the "held" capability event is generated when the LongPress event is received. The IGNORE_NEXT_MPC flag is used +-- to tell the driver to ignore MultiPressComplete if it is received after a long press to avoid this extra event. +local IGNORE_NEXT_MPC = "__ignore_next_mpc" +-- These are essentially storing the supported features of a given endpoint +-- TODO: add an is_feature_supported_for_endpoint function to matter.device that takes an endpoint +local EMULATE_HELD = "__emulate_held" -- for non-MSR (MomentarySwitchRelease) devices we can emulate this on the software side +local SUPPORTS_MULTI_PRESS = "__multi_button" -- for MSM devices (MomentarySwitchMultiPress), create an event on receipt of MultiPressComplete +local INITIAL_PRESS_ONLY = "__initial_press_only" -- for devices that support MS (MomentarySwitch), but not MSR (MomentarySwitchRelease) + +local function initial_press_event_handler(driver, device, ib, response) + if get_field_for_endpoint(device, SUPPORTS_MULTI_PRESS, ib.endpoint_id) then + -- Receipt of an InitialPress event means we do not want to ignore the next MultiPressComplete event + -- or else we would potentially not create the expected button capability event + set_field_for_endpoint(device, IGNORE_NEXT_MPC, ib.endpoint_id, nil) + elseif get_field_for_endpoint(device, INITIAL_PRESS_ONLY, ib.endpoint_id) then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.button.pushed({state_change = true})) + elseif get_field_for_endpoint(device, EMULATE_HELD, ib.endpoint_id) then + -- if our button doesn't differentiate between short and long holds, do it in code by keeping track of the press down time + init_press(device, ib.endpoint_id) + end +end + +local function long_press_event_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.button.held({state_change = true})) + if get_field_for_endpoint(device, SUPPORTS_MULTI_PRESS, ib.endpoint_id) then + -- Ignore the next MultiPressComplete event if it is sent as part of this "long press" event sequence + set_field_for_endpoint(device, IGNORE_NEXT_MPC, ib.endpoint_id, true) + end +end + +--helper function to create list of multi press values +local function create_multi_press_values_list(size, supportsHeld) + local list = {"pushed", "double"} + if supportsHeld then table.insert(list, "held") end + -- add multi press values of 3 or greater to the list + for i=3, size do + table.insert(list, string.format("pushed_%dx", i)) + end + return list +end + +local function tbl_contains(array, value) + for _, element in ipairs(array) do + if element == value then + return true + end + end + return false +end + +local function device_init (driver, device) + device:subscribe() + device:send(clusters.Switch.attributes.MultiPressMax:read(device)) +end + +local function max_press_handler(driver, device, ib, response) + local max = ib.data.value or 1 --get max number of presses + device.log.debug("Device supports "..max.." presses") + -- capability only supports up to 6 presses + if max > 6 then + log.info("Device supports more than 6 presses") + max = 6 + end + local MSL = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS}) + local supportsHeld = tbl_contains(MSL, ib.endpoint_id) + local values = create_multi_press_values_list(max, supportsHeld) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.supportedButtonValues(values, {visibility = {displayed = false}})) +end + +local function multi_press_complete_event_handler(driver, device, ib, response) + -- in the case of multiple button presses + -- emit number of times, multiple presses have been completed + if ib.data and not get_field_for_endpoint(device, IGNORE_NEXT_MPC, ib.endpoint_id) then + local press_value = ib.data.elements.total_number_of_presses_counted.value + --capability only supports up to 6 presses + if press_value < 7 then + local button_event = capabilities.button.button.pushed({state_change = true}) + if press_value == 2 then + button_event = capabilities.button.button.double({state_change = true}) + elseif press_value > 2 then + button_event = capabilities.button.button(string.format("pushed_%dx", press_value), {state_change = true}) + end + + device:emit_event_for_endpoint(ib.endpoint_id, button_event) + else + log.info(string.format("Number of presses (%d) not supported by capability", press_value)) + end + end + set_field_for_endpoint(device, IGNORE_NEXT_MPC, ib.endpoint_id, nil) +end + +local Bosch_Button_Contact_Sensor = { + NAME = "Bosch_Button_Contact_Sensor", + lifecycle_handlers = { + init = device_init + }, + matter_handlers = { + attr = { + [clusters.Switch.ID] = { + [clusters.Switch.attributes.MultiPressMax.ID] = max_press_handler + } + }, + event = { + [clusters.Switch.ID] = { + [clusters.Switch.events.InitialPress.ID] = initial_press_event_handler, + [clusters.Switch.events.LongPress.ID] = long_press_event_handler, + [clusters.Switch.events.MultiPressComplete.ID] = multi_press_complete_event_handler + } + }, + }, + can_handle = is_bosch_button_contact, +} + +return Bosch_Button_Contact_Sensor diff --git a/drivers/SmartThings/matter-sensor/src/init.lua b/drivers/SmartThings/matter-sensor/src/init.lua index 46e40c0db7..2495fc1c9e 100644 --- a/drivers/SmartThings/matter-sensor/src/init.lua +++ b/drivers/SmartThings/matter-sensor/src/init.lua @@ -155,6 +155,10 @@ local function match_profile(driver, device, battery_supported) profile_name = profile_name .. "-flow" end + if device:supports_capability(capabilities.button) then + profile_name = profile_name .. "-button" + end + if battery_supported == battery_support.BATTERY_PERCENTAGE then profile_name = profile_name .. "-battery" elseif battery_supported == battery_support.BATTERY_LEVEL then @@ -574,12 +578,20 @@ local matter_driver_template = { clusters.FlowMeasurement.attributes.MaxMeasuredValue }, }, + subscribed_events = { + [capabilities.button.ID] = { + clusters.Switch.events.InitialPress, + clusters.Switch.events.LongPress, + clusters.Switch.events.MultiPressComplete, + } + }, capability_handlers = { }, supported_capabilities = { capabilities.temperatureMeasurement, capabilities.contactSensor, capabilities.motionSensor, + capabilities.button, capabilities.battery, capabilities.batteryLevel, capabilities.relativeHumidityMeasurement, @@ -589,11 +601,12 @@ local matter_driver_template = { capabilities.temperatureAlarm, capabilities.rainSensor, capabilities.hardwareFault, - capabilities.flowMeasurement + capabilities.flowMeasurement, }, sub_drivers = { require("air-quality-sensor"), - require("smoke-co-alarm") + require("smoke-co-alarm"), + require("bosch-button-contact") } }