diff --git a/drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/init.lua similarity index 91% rename from drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/init.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/init.lua index 83bc66aa1b..f2a812055b 100644 --- a/drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/init.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/init.lua @@ -1,6 +1,6 @@ local cluster_base = require "st.matter.cluster_base" -local ElectricalEnergyMeasurementServerAttributes = require "ElectricalEnergyMeasurement.server.attributes" -local ElectricalEnergyMeasurementTypes = require "ElectricalEnergyMeasurement.types" +local ElectricalEnergyMeasurementServerAttributes = require "embedded_clusters.ElectricalEnergyMeasurement.server.attributes" +local ElectricalEnergyMeasurementTypes = require "embedded_clusters.ElectricalEnergyMeasurement.types" local ElectricalEnergyMeasurement = {} ElectricalEnergyMeasurement.ID = 0x0091 diff --git a/drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua similarity index 93% rename from drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua index 3dc58635e1..4e35c264f0 100644 --- a/drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/CumulativeEnergyImported.lua @@ -5,7 +5,7 @@ local TLVParser = require "st.matter.TLV.TLVParser" local CumulativeEnergyImported = { ID = 0x0001, NAME = "CumulativeEnergyImported", - base_type = require "ElectricalEnergyMeasurement.types.EnergyMeasurementStruct", + base_type = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct", } function CumulativeEnergyImported:new_value(...) diff --git a/drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua similarity index 93% rename from drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua index 753b91ea2d..607faa2161 100644 --- a/drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/PeriodicEnergyImported.lua @@ -5,7 +5,7 @@ local TLVParser = require "st.matter.TLV.TLVParser" local PeriodicEnergyImported = { ID = 0x0003, NAME = "PeriodicEnergyImported", - base_type = require "ElectricalEnergyMeasurement.types.EnergyMeasurementStruct", + base_type = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct", } function PeriodicEnergyImported:new_value(...) diff --git a/drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/init.lua similarity index 85% rename from drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/server/attributes/init.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/init.lua index adfdf42bbf..e137f08918 100644 --- a/drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/server/attributes/init.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/server/attributes/init.lua @@ -2,7 +2,7 @@ local attr_mt = {} attr_mt.__attr_cache = {} attr_mt.__index = function(self, key) if attr_mt.__attr_cache[key] == nil then - local req_loc = string.format("ElectricalEnergyMeasurement.server.attributes.%s", key) + local req_loc = string.format("embedded_clusters.ElectricalEnergyMeasurement.server.attributes.%s", key) local raw_def = require(req_loc) local cluster = rawget(self, "_cluster") raw_def:set_parent_cluster(cluster) diff --git a/drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua similarity index 100% rename from drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua diff --git a/drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/types/Feature.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/Feature.lua similarity index 100% rename from drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/types/Feature.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/Feature.lua diff --git a/drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/types/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/init.lua similarity index 75% rename from drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/types/init.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/init.lua index bb0c39fe0e..5241c3b864 100644 --- a/drivers/SmartThings/matter-switch/src/ElectricalEnergyMeasurement/types/init.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/init.lua @@ -2,7 +2,7 @@ local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) if types_mt.__types_cache[key] == nil then - types_mt.__types_cache[key] = require("ElectricalEnergyMeasurement.types." .. key) + types_mt.__types_cache[key] = require("embedded_clusters.ElectricalEnergyMeasurement.types." .. key) end return types_mt.__types_cache[key] end diff --git a/drivers/SmartThings/matter-switch/src/ElectricalPowerMeasurement/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/init.lua similarity index 93% rename from drivers/SmartThings/matter-switch/src/ElectricalPowerMeasurement/init.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/init.lua index 54785d16c6..c9f401dd5c 100644 --- a/drivers/SmartThings/matter-switch/src/ElectricalPowerMeasurement/init.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/init.lua @@ -1,6 +1,6 @@ local cluster_base = require "st.matter.cluster_base" -local ElectricalPowerMeasurementServerAttributes = require "ElectricalPowerMeasurement.server.attributes" -local ElectricalPowerMeasurementTypes = require "ElectricalPowerMeasurement.types" +local ElectricalPowerMeasurementServerAttributes = require "embedded_clusters.ElectricalPowerMeasurement.server.attributes" +local ElectricalPowerMeasurementTypes = require "embedded_clusters.ElectricalPowerMeasurement.types" local ElectricalPowerMeasurement = {} diff --git a/drivers/SmartThings/matter-switch/src/ElectricalPowerMeasurement/server/attributes/ActivePower.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/ActivePower.lua similarity index 100% rename from drivers/SmartThings/matter-switch/src/ElectricalPowerMeasurement/server/attributes/ActivePower.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/ActivePower.lua diff --git a/drivers/SmartThings/matter-switch/src/ElectricalPowerMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/init.lua similarity index 85% rename from drivers/SmartThings/matter-switch/src/ElectricalPowerMeasurement/server/attributes/init.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/init.lua index 0c30fa8dd4..b1a8d57bde 100644 --- a/drivers/SmartThings/matter-switch/src/ElectricalPowerMeasurement/server/attributes/init.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/server/attributes/init.lua @@ -2,7 +2,7 @@ local attr_mt = {} attr_mt.__attr_cache = {} attr_mt.__index = function(self, key) if attr_mt.__attr_cache[key] == nil then - local req_loc = string.format("ElectricalPowerMeasurement.server.attributes.%s", key) + local req_loc = string.format("embedded_clusters.ElectricalPowerMeasurement.server.attributes.%s", key) local raw_def = require(req_loc) local cluster = rawget(self, "_cluster") raw_def:set_parent_cluster(cluster) diff --git a/drivers/SmartThings/matter-switch/src/ElectricalPowerMeasurement/types/Feature.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/types/Feature.lua similarity index 100% rename from drivers/SmartThings/matter-switch/src/ElectricalPowerMeasurement/types/Feature.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/types/Feature.lua diff --git a/drivers/SmartThings/matter-switch/src/ElectricalPowerMeasurement/types/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/types/init.lua similarity index 75% rename from drivers/SmartThings/matter-switch/src/ElectricalPowerMeasurement/types/init.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/types/init.lua index 16d13a0688..8016a487c3 100644 --- a/drivers/SmartThings/matter-switch/src/ElectricalPowerMeasurement/types/init.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalPowerMeasurement/types/init.lua @@ -2,7 +2,7 @@ local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) if types_mt.__types_cache[key] == nil then - types_mt.__types_cache[key] = require("ElectricalPowerMeasurement.types." .. key) + types_mt.__types_cache[key] = require("embedded_clusters.ElectricalPowerMeasurement.types." .. key) end return types_mt.__types_cache[key] end diff --git a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/init.lua similarity index 92% rename from drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/init.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/init.lua index d8ab93412f..0535e3267c 100644 --- a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/init.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/init.lua @@ -1,7 +1,7 @@ local cluster_base = require "st.matter.cluster_base" -local ValveConfigurationAndControlServerAttributes = require "ValveConfigurationAndControl.server.attributes" -local ValveConfigurationAndControlServerCommands = require "ValveConfigurationAndControl.server.commands" -local ValveConfigurationAndControlTypes = require "ValveConfigurationAndControl.types" +local ValveConfigurationAndControlServerAttributes = require "embedded_clusters.ValveConfigurationAndControl.server.attributes" +local ValveConfigurationAndControlServerCommands = require "embedded_clusters.ValveConfigurationAndControl.server.commands" +local ValveConfigurationAndControlTypes = require "embedded_clusters.ValveConfigurationAndControl.types" local ValveConfigurationAndControl = {} ValveConfigurationAndControl.ID = 0x0081 diff --git a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/attributes/CurrentLevel.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/CurrentLevel.lua similarity index 100% rename from drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/attributes/CurrentLevel.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/CurrentLevel.lua diff --git a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/attributes/CurrentState.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/CurrentState.lua similarity index 93% rename from drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/attributes/CurrentState.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/CurrentState.lua index 76f156d2a7..514659aa70 100644 --- a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/attributes/CurrentState.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/CurrentState.lua @@ -5,7 +5,7 @@ local TLVParser = require "st.matter.TLV.TLVParser" local CurrentState = { ID = 0x0004, NAME = "CurrentState", - base_type = require "ValveConfigurationAndControl.types.ValveStateEnum", + base_type = require "embedded_clusters.ValveConfigurationAndControl.types.ValveStateEnum", } function CurrentState:new_value(...) diff --git a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/attributes/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/init.lua similarity index 85% rename from drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/attributes/init.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/init.lua index 237818d98f..dbffea4e8d 100644 --- a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/attributes/init.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/attributes/init.lua @@ -2,7 +2,7 @@ local attr_mt = {} attr_mt.__attr_cache = {} attr_mt.__index = function(self, key) if attr_mt.__attr_cache[key] == nil then - local req_loc = string.format("ValveConfigurationAndControl.server.attributes.%s", key) + local req_loc = string.format("embedded_clusters.ValveConfigurationAndControl.server.attributes.%s", key) local raw_def = require(req_loc) local cluster = rawget(self, "_cluster") raw_def:set_parent_cluster(cluster) diff --git a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/commands/Close.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/Close.lua similarity index 100% rename from drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/commands/Close.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/Close.lua diff --git a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/commands/Open.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/Open.lua similarity index 100% rename from drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/commands/Open.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/Open.lua diff --git a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/commands/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/init.lua similarity index 85% rename from drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/commands/init.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/init.lua index 330e35bba3..74c402c3fd 100644 --- a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/server/commands/init.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/server/commands/init.lua @@ -2,7 +2,7 @@ local command_mt = {} command_mt.__command_cache = {} command_mt.__index = function(self, key) if command_mt.__command_cache[key] == nil then - local req_loc = string.format("ValveConfigurationAndControl.server.commands.%s", key) + local req_loc = string.format("embedded_clusters.ValveConfigurationAndControl.server.commands.%s", key) local raw_def = require(req_loc) local cluster = rawget(self, "_cluster") command_mt.__command_cache[key] = raw_def:set_parent_cluster(cluster) diff --git a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/types/Feature.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/Feature.lua similarity index 100% rename from drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/types/Feature.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/Feature.lua diff --git a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/types/ValveStateEnum.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/ValveStateEnum.lua similarity index 100% rename from drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/types/ValveStateEnum.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/ValveStateEnum.lua diff --git a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/types/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/init.lua similarity index 75% rename from drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/types/init.lua rename to drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/init.lua index 835167f485..22aa362e99 100644 --- a/drivers/SmartThings/matter-switch/src/ValveConfigurationAndControl/types/init.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ValveConfigurationAndControl/types/init.lua @@ -2,7 +2,7 @@ local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) if types_mt.__types_cache[key] == nil then - types_mt.__types_cache[key] = require("ValveConfigurationAndControl.types." .. key) + types_mt.__types_cache[key] = require("embedded_clusters.ValveConfigurationAndControl.types." .. key) end return types_mt.__types_cache[key] end diff --git a/drivers/SmartThings/matter-switch/src/generic_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/generic_handlers/attribute_handlers.lua new file mode 100644 index 0000000000..3976c2c8a3 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/generic_handlers/attribute_handlers.lua @@ -0,0 +1,504 @@ +-- 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 version = require "version" +local im = require "st.matter.interaction_model" + +local st_utils = require "st.utils" +local fields = require "utils.switch_fields" +local switch_utils = require "utils.switch_utils" +local color_utils = require "utils.color_utils" + +local power_consumption_reporting = require "generic_handlers.power_consumption_report" + +local AttributeHandlers = {} + +-- [[ ON OFF CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.on_off_attr_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.on()) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.off()) + end + if type(device.register_native_capability_attr_handler) == "function" then + device:register_native_capability_attr_handler("switch", "switch") + end +end + + +-- [[ LEVEL CONTROL CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.level_control_current_level_handler(driver, device, ib, response) + if ib.data.value ~= nil then + local level = math.floor((ib.data.value / 254.0 * 100) + 0.5) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switchLevel.level(level)) + if type(device.register_native_capability_attr_handler) == "function" then + device:register_native_capability_attr_handler("switchLevel", "level") + end + end +end + +function AttributeHandlers.level_bounds_handler_factory(minOrMax) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local lighting_endpoints = device:get_endpoints(clusters.LevelControl.ID, {feature_bitmap = clusters.LevelControl.FeatureMap.LIGHTING}) + local lighting_support = switch_utils.tbl_contains(lighting_endpoints, ib.endpoint_id) + -- If the lighting feature is supported then we should check if the reported level is at least 1. + if lighting_support and ib.data.value < fields.SWITCH_LEVEL_LIGHTING_MIN then + device.log.warn_with({hub_logs = true}, string.format("Lighting device reported a switch level %d outside of supported capability range", ib.data.value)) + return + end + -- Convert level from given range of 0-254 to range of 0-100. + local level = st_utils.round(ib.data.value / 254.0 * 100) + -- If the device supports the lighting feature, the minimum capability level should be 1 so we do not send a 0 value for the level attribute + if lighting_support and level == 0 then + level = 1 + end + switch_utils.set_field_for_endpoint(device, fields.LEVEL_BOUND_RECEIVED..minOrMax, ib.endpoint_id, level) + local min = switch_utils.get_field_for_endpoint(device, fields.LEVEL_BOUND_RECEIVED..fields.LEVEL_MIN, ib.endpoint_id) + local max = switch_utils.get_field_for_endpoint(device, fields.LEVEL_BOUND_RECEIVED..fields.LEVEL_MAX, ib.endpoint_id) + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switchLevel.levelRange({ value = {minimum = min, maximum = max} })) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min level value %d that is not lower than the reported max level value %d", min, max)) + end + switch_utils.set_field_for_endpoint(device, fields.LEVEL_BOUND_RECEIVED..fields.LEVEL_MAX, ib.endpoint_id, nil) + switch_utils.set_field_for_endpoint(device, fields.LEVEL_BOUND_RECEIVED..fields.LEVEL_MIN, ib.endpoint_id, nil) + end + end +end + + +-- [[ COLOR CONTROL CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.current_hue_handler(driver, device, ib, response) + if device:get_field(fields.COLOR_MODE) == fields.X_Y_COLOR_MODE or ib.data.value == nil then + return + end + local hue = math.floor((ib.data.value / 0xFE * 100) + 0.5) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.hue(hue)) +end + +function AttributeHandlers.current_saturation_handler(driver, device, ib, response) + if device:get_field(fields.COLOR_MODE) == fields.X_Y_COLOR_MODE or ib.data.value == nil then + return + end + local sat = math.floor((ib.data.value / 0xFE * 100) + 0.5) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.saturation(sat)) +end + +function AttributeHandlers.color_temperature_mireds_handler(driver, device, ib, response) + local temp_in_mired = ib.data.value + if temp_in_mired == nil then + return + end + if (temp_in_mired < fields.COLOR_TEMPERATURE_MIRED_MIN or temp_in_mired > fields.COLOR_TEMPERATURE_MIRED_MAX) then + device.log.warn_with({hub_logs = true}, string.format("Device reported color temperature %d mired outside of sane range of %.2f-%.2f", temp_in_mired, fields.COLOR_TEMPERATURE_MIRED_MIN, fields.COLOR_TEMPERATURE_MIRED_MAX)) + return + end + local min_temp_mired = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MIN, ib.endpoint_id) + local max_temp_mired = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MAX, ib.endpoint_id) + + local temp = st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT/temp_in_mired) + if min_temp_mired ~= nil and temp_in_mired <= min_temp_mired then + temp = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MAX, ib.endpoint_id) + elseif max_temp_mired ~= nil and temp_in_mired >= max_temp_mired then + temp = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MIN, ib.endpoint_id) + end + + local temp_device = device + if device:get_field(fields.IS_PARENT_CHILD_DEVICE) == true then + temp_device = switch_utils.find_child(device, ib.endpoint_id) or device + end + local most_recent_temp = temp_device:get_field(fields.MOST_RECENT_TEMP) + -- this is to avoid rounding errors from the round-trip conversion of Kelvin to mireds + if most_recent_temp ~= nil and + most_recent_temp <= st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT/(temp_in_mired - 1)) and + most_recent_temp >= st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT/(temp_in_mired + 1)) then + temp = most_recent_temp + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorTemperature.colorTemperature(temp)) +end + +function AttributeHandlers.current_x_handler(driver, device, ib, response) + if device:get_field(fields.COLOR_MODE) == fields.HUE_SAT_COLOR_MODE then + return + end + local y = device:get_field(fields.RECEIVED_Y) + --TODO it is likely that both x and y attributes are in the response (not guaranteed though) + -- if they are we can avoid setting fields on the device. + if y == nil then + device:set_field(fields.RECEIVED_X, ib.data.value) + else + local x = ib.data.value + local h, s, _ = color_utils.safe_xy_to_hsv(x, y, nil) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.hue(h)) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.saturation(s)) + device:set_field(fields.RECEIVED_Y, nil) + end +end + +function AttributeHandlers.current_y_handler(driver, device, ib, response) + if device:get_field(fields.COLOR_MODE) == fields.HUE_SAT_COLOR_MODE then + return + end + local x = device:get_field(fields.RECEIVED_X) + if x == nil then + device:set_field(fields.RECEIVED_Y, ib.data.value) + else + local y = ib.data.value + local h, s, _ = color_utils.safe_xy_to_hsv(x, y, nil) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.hue(h)) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.saturation(s)) + device:set_field(fields.RECEIVED_X, nil) + end +end + +function AttributeHandlers.color_mode_handler(driver, device, ib, response) + if ib.data.value == device:get_field(fields.COLOR_MODE) or (ib.data.value ~= fields.HUE_SAT_COLOR_MODE and ib.data.value ~= fields.X_Y_COLOR_MODE) then + return + end + device:set_field(fields.COLOR_MODE, ib.data.value) + local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + if ib.data.value == fields.HUE_SAT_COLOR_MODE then + req:merge(clusters.ColorControl.attributes.CurrentHue:read()) + req:merge(clusters.ColorControl.attributes.CurrentSaturation:read()) + elseif ib.data.value == fields.X_Y_COLOR_MODE then + req:merge(clusters.ColorControl.attributes.CurrentX:read()) + req:merge(clusters.ColorControl.attributes.CurrentY:read()) + end + if #req.info_blocks > 0 then + device:send(req) + end +end + +--TODO setup configure handler to read this attribute. +function AttributeHandlers.color_capabilities_handler(driver, device, ib, response) + if ib.data.value ~= nil then + if ib.data.value & 0x1 then + device:set_field(fields.HUESAT_SUPPORT, true) + end + end +end + +function AttributeHandlers.color_temp_physical_mireds_bounds_factory(minOrMax) + return function(driver, device, ib, response) + local temp_in_mired = ib.data.value + if temp_in_mired == nil then + return + end + if (temp_in_mired < fields.COLOR_TEMPERATURE_MIRED_MIN or temp_in_mired > fields.COLOR_TEMPERATURE_MIRED_MAX) then + device.log.warn_with({hub_logs = true}, string.format("Device reported a color temperature %d mired outside of sane range of %.2f-%.2f", temp_in_mired, fields.COLOR_TEMPERATURE_MIRED_MIN, fields.COLOR_TEMPERATURE_MIRED_MAX)) + return + end + local temp_in_kelvin = switch_utils.mired_to_kelvin(temp_in_mired, minOrMax) + switch_utils.set_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..minOrMax, ib.endpoint_id, temp_in_kelvin) + -- the minimum color temp in kelvin corresponds to the maximum temp in mireds + if minOrMax == fields.COLOR_TEMP_MIN then + switch_utils.set_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MAX, ib.endpoint_id, temp_in_mired) + else + switch_utils.set_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MIN, ib.endpoint_id, temp_in_mired) + end + local min = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MIN, ib.endpoint_id) + local max = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MAX, ib.endpoint_id) + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = min, maximum = max} })) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min color temperature %d K that is not lower than the reported max color temperature %d K", min, max)) + end + end + end +end + + +-- [[ ILLUMINANCE CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.illuminance_measured_value_handler(driver, device, ib, response) + local lux = math.floor(10 ^ ((ib.data.value - 1) / 10000)) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.illuminanceMeasurement.illuminance(lux)) +end + + +-- [[ OCCUPANCY CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.occupancy_handler(driver, device, ib, response) + device:emit_event(ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) +end + + +-- [[ ELECTRICAL POWER MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.active_power_handler(driver, device, ib, response) + if ib.data.value then + local watt_value = ib.data.value / fields.CONVERSION_CONST_MILLIWATT_TO_WATT + if ib.endpoint_id ~= 0 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.powerMeter.power({ value = watt_value, unit = "W"})) + else + -- when energy management is defined in the root endpoint(0), replace it with the first switch endpoint and process it. + device:emit_event_for_endpoint(device:get_field(fields.ENERGY_MANAGEMENT_ENDPOINT), capabilities.powerMeter.power({ value = watt_value, unit = "W"})) + end + if type(device.register_native_capability_attr_handler) == "function" then + device:register_native_capability_attr_handler("powerMeter","power") + end + end +end + + +-- [[ VALVE CONFIGURATION AND CONTROL CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.valve_configuration_current_state_handler(driver, device, ib, response) + if ib.data.value == 0 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.valve.valve.closed()) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.valve.valve.open()) + end +end + +function AttributeHandlers.valve_configuration_current_level_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.level.level(ib.data.value)) + end +end + + +-- [[ ELECTRICAL ENERGY MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.cumul_energy_imported_handler(driver, device, ib, response) + if ib.data.elements.energy then + local watt_hour_value = ib.data.elements.energy.value / fields.CONVERSION_CONST_MILLIWATT_TO_WATT + device:set_field(fields.TOTAL_IMPORTED_ENERGY, watt_hour_value, {persist = true}) + if ib.endpoint_id ~= 0 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.energyMeter.energy({ value = watt_hour_value, unit = "Wh" })) + else + -- when energy management is defined in the root endpoint(0), replace it with the first switch endpoint and process it. + device:emit_event_for_endpoint(device:get_field(fields.ENERGY_MANAGEMENT_ENDPOINT), capabilities.energyMeter.energy({ value = watt_hour_value, unit = "Wh" })) + end + end +end + +function AttributeHandlers.per_energy_imported_handler(driver, device, ib, response) + if ib.data.elements.energy then + local watt_hour_value = ib.data.elements.energy.value / fields.CONVERSION_CONST_MILLIWATT_TO_WATT + local latest_energy_report = device:get_field(fields.TOTAL_IMPORTED_ENERGY) or 0 + local summed_energy_report = latest_energy_report + watt_hour_value + device:set_field(fields.TOTAL_IMPORTED_ENERGY, summed_energy_report, {persist = true}) + device:emit_event(capabilities.energyMeter.energy({ value = summed_energy_report, unit = "Wh" })) + end +end + +function AttributeHandlers.energy_imported_factory(is_cumulative_report) + return function(driver, device, ib, response) + if not device:get_field(fields.IMPORT_POLL_TIMER_SETTING_ATTEMPTED) then + power_consumption_reporting.set_poll_report_timer_and_schedule(device, is_cumulative_report) + end + if is_cumulative_report then + AttributeHandlers.cumul_energy_imported_handler(driver, device, ib, response) + elseif device:get_field(fields.CUMULATIVE_REPORTS_NOT_SUPPORTED) then + AttributeHandlers.per_energy_imported_handler(driver, device, ib, response) + end + end +end + + +-- [[ POWER SOURCE CLUSTER ATTRIBUTES ]] -- + + +function AttributeHandlers.bat_percent_remaining_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) + end +end + +function AttributeHandlers.bat_charge_level_handler(driver, device, ib, response) + if ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.OK then + device:emit_event(capabilities.batteryLevel.battery.normal()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.WARNING then + device:emit_event(capabilities.batteryLevel.battery.warning()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.CRITICAL then + device:emit_event(capabilities.batteryLevel.battery.critical()) + end +end + +function AttributeHandlers.power_source_attribute_list_handler(driver, device, ib, response) + local profile_name = "" + + local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + for _, attr in ipairs(ib.data.elements) do + -- Re-profile the device if BatPercentRemaining (Attribute ID 0x0C) or + -- BatChargeLevel (Attribute ID 0x0E) is present. + if attr.value == 0x0C then + profile_name = "button-battery" + break + elseif attr.value == 0x0E then + profile_name = "button-batteryLevel" + break + end + end + if profile_name ~= "" then + if #button_eps > 1 then + profile_name = string.format("%d-", #button_eps) .. profile_name + end + + if device.manufacturer_info.vendor_id == fields.AQARA_MANUFACTURER_ID and + device.manufacturer_info.product_id == fields.AQARA_CLIMATE_SENSOR_W100_ID then + profile_name = profile_name .. "-temperature-humidity" + end + device:try_update_metadata({ profile = profile_name }) + end +end + + +-- [[ SWITCH CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.multi_press_max_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 + device.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 = switch_utils.tbl_contains(MSL, ib.endpoint_id) + local values = switch_utils.create_multi_press_values_list(max, supportsHeld) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.supportedButtonValues(values, {visibility = {displayed = false}})) +end + + +-- [[ TEMPERATURE MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.temperature_measured_value_handler(driver, device, ib, response) + local measured_value = ib.data.value + if measured_value ~= nil then + local temp = measured_value / 100.0 + local unit = "C" + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperature({value = temp, unit = unit})) + end +end + +function AttributeHandlers.temperature_measured_value_bounds_factory(minOrMax) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local temp = ib.data.value / 100.0 + local unit = "C" + switch_utils.set_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..minOrMax, ib.endpoint_id, temp) + local min = switch_utils.get_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..fields.TEMP_MIN, ib.endpoint_id) + local max = switch_utils.get_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..fields.TEMP_MAX, ib.endpoint_id) + if min ~= nil and max ~= nil then + if min < max then + -- Only emit the capability for RPC version >= 5 (unit conversion for + -- temperature range capability is only supported for RPC >= 5) + if version.rpc >= 5 then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = unit })) + end + switch_utils.set_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..fields.TEMP_MIN, ib.endpoint_id, nil) + switch_utils.set_field_for_endpoint(device, fields.TEMP_BOUND_RECEIVED..fields.TEMP_MAX, ib.endpoint_id, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min temperature %d that is not lower than the reported max temperature %d", min, max)) + end + end + end +end + + +-- [[ RELATIVE HUMIDITY MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.relative_humidity_measured_value_handler(driver, device, ib, response) + local measured_value = ib.data.value + if measured_value ~= nil then + local humidity = st_utils.round(measured_value / 100.0) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.relativeHumidityMeasurement.humidity(humidity)) + end +end + + +-- [[ FAN CONTROL CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.fan_mode_handler(driver, device, ib, response) + if ib.data.value == clusters.FanControl.attributes.FanMode.OFF then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("off")) + elseif ib.data.value == clusters.FanControl.attributes.FanMode.LOW then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("low")) + elseif ib.data.value == clusters.FanControl.attributes.FanMode.MEDIUM then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("medium")) + elseif ib.data.value == clusters.FanControl.attributes.FanMode.HIGH then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("high")) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("auto")) + end +end + +function AttributeHandlers.fan_mode_sequence_handler(driver, device, ib, response) + local supportedFanModes + if ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.medium.NAME, + capabilities.fanMode.fanMode.high.NAME + } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.high.NAME + } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH_AUTO then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.medium.NAME, + capabilities.fanMode.fanMode.high.NAME, + capabilities.fanMode.fanMode.auto.NAME + } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH_AUTO then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.low.NAME, + capabilities.fanMode.fanMode.high.NAME, + capabilities.fanMode.fanMode.auto.NAME + } + elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_ON_AUTO then + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.high.NAME, + capabilities.fanMode.fanMode.auto.NAME + } + else + supportedFanModes = { + capabilities.fanMode.fanMode.off.NAME, + capabilities.fanMode.fanMode.high.NAME + } + end + local event = capabilities.fanMode.supportedFanModes(supportedFanModes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +function AttributeHandlers.percent_current_handler(driver, device, ib, response) + if ib.data.value == nil or ib.data.value < 0 or ib.data.value > 100 then + return + end + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanSpeedPercent.percent(ib.data.value)) +end + +return AttributeHandlers \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/src/generic_handlers/capability_handlers.lua b/drivers/SmartThings/matter-switch/src/generic_handlers/capability_handlers.lua new file mode 100644 index 0000000000..05a781bc3d --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/generic_handlers/capability_handlers.lua @@ -0,0 +1,182 @@ +-- 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 st_utils = require "st.utils" +local switch_utils = require "utils.switch_utils" +local fields = require "utils.switch_fields" +local version = require "version" + +local CapabilityHandlers = {} + +-- Include driver-side definitions when lua libs api version is < 11 +if version.api < 11 then + clusters.ValveConfigurationAndControl = require "embedded_clusters.ValveConfigurationAndControl" +end + +-- [[ SWITCH CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_switch_on(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local endpoint_id = device:component_to_endpoint(cmd.component) + --TODO use OnWithRecallGlobalScene for devices with the LT feature + device:send(clusters.OnOff.server.commands.On(device, endpoint_id)) +end + +function CapabilityHandlers.handle_switch_off(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.OnOff.server.commands.Off(device, endpoint_id)) +end + + +-- [[ SWITCH LEVEL CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_switch_set_level(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local endpoint_id = device:component_to_endpoint(cmd.component) + local level = math.floor(cmd.args.level/100.0 * 254) + device:send(clusters.LevelControl.server.commands.MoveToLevelWithOnOff(device, endpoint_id, level, cmd.args.rate, 0, 0)) +end + + +-- [[ COLOR CONTROL CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_set_color(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req + local huesat_endpoints = device:get_endpoints(clusters.ColorControl.ID, {feature_bitmap = clusters.ColorControl.FeatureMap.HUE_AND_SATURATION}) + if switch_utils.tbl_contains(huesat_endpoints, endpoint_id) then + local hue = switch_utils.convert_huesat_st_to_matter(cmd.args.color.hue) + local sat = switch_utils.convert_huesat_st_to_matter(cmd.args.color.saturation) + req = clusters.ColorControl.server.commands.MoveToHueAndSaturation(device, endpoint_id, hue, sat, fields.TRANSITION_TIME, fields.OPTIONS_MASK, fields.OPTIONS_OVERRIDE) + else + local x, y, _ = st_utils.safe_hsv_to_xy(cmd.args.color.hue, cmd.args.color.saturation) + req = clusters.ColorControl.server.commands.MoveToColor(device, endpoint_id, x, y, fields.TRANSITION_TIME, fields.OPTIONS_MASK, fields.OPTIONS_OVERRIDE) + end + device:send(req) +end + +function CapabilityHandlers.handle_set_hue(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local huesat_endpoints = device:get_endpoints(clusters.ColorControl.ID, {feature_bitmap = clusters.ColorControl.FeatureMap.HUE_AND_SATURATION}) + if switch_utils.tbl_contains(huesat_endpoints, endpoint_id) then + local hue = switch_utils.convert_huesat_st_to_matter(cmd.args.hue) + local req = clusters.ColorControl.server.commands.MoveToHue(device, endpoint_id, hue, 0, fields.TRANSITION_TIME, fields.OPTIONS_MASK, fields.OPTIONS_OVERRIDE) + device:send(req) + else + device.log.warn("Device does not support huesat features on its color control cluster") + end +end + +function CapabilityHandlers.handle_set_saturation(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local huesat_endpoints = device:get_endpoints(clusters.ColorControl.ID, {feature_bitmap = clusters.ColorControl.FeatureMap.HUE_AND_SATURATION}) + if switch_utils.tbl_contains(huesat_endpoints, endpoint_id) then + local sat = switch_utils.convert_huesat_st_to_matter(cmd.args.saturation) + local req = clusters.ColorControl.server.commands.MoveToSaturation(device, endpoint_id, sat, fields.TRANSITION_TIME, fields.OPTIONS_MASK, fields.OPTIONS_OVERRIDE) + device:send(req) + else + device.log.warn("Device does not support huesat features on its color control cluster") + end +end + + +-- [[ COLOR TEMPERATURE CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_set_color_temperature(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local temp_in_kelvin = cmd.args.temperature + local min_temp_kelvin = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MIN, endpoint_id) + local max_temp_kelvin = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MAX, endpoint_id) + + local temp_in_mired = st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT/temp_in_kelvin) + if min_temp_kelvin ~= nil and temp_in_kelvin <= min_temp_kelvin then + temp_in_mired = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MAX, endpoint_id) + elseif max_temp_kelvin ~= nil and temp_in_kelvin >= max_temp_kelvin then + temp_in_mired = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MIN, endpoint_id) + end + local req = clusters.ColorControl.server.commands.MoveToColorTemperature(device, endpoint_id, temp_in_mired, fields.TRANSITION_TIME, fields.OPTIONS_MASK, fields.OPTIONS_OVERRIDE) + device:set_field(fields.MOST_RECENT_TEMP, cmd.args.temperature, {persist = true}) + device:send(req) +end + + +-- [[ VALVE CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_valve_open(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.ValveConfigurationAndControl.server.commands.Open(device, endpoint_id)) +end + +function CapabilityHandlers.handle_valve_close(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.ValveConfigurationAndControl.server.commands.Close(device, endpoint_id)) +end + + +-- [[ LEVEL CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_set_level(driver, device, cmd) + local commands = clusters.ValveConfigurationAndControl.server.commands + local endpoint_id = device:component_to_endpoint(cmd.component) + local level = cmd.args.level + if not level then + return + elseif level == 0 then + device:send(commands.Close(device, endpoint_id)) + else + device:send(commands.Open(device, endpoint_id, nil, level)) + end +end + + +-- [[ FAN MODE CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_set_fan_mode(driver, device, cmd) + local fan_mode_id + if cmd.args.fanMode == capabilities.fanMode.fanMode.low.NAME then + fan_mode_id = clusters.FanControl.attributes.FanMode.LOW + elseif cmd.args.fanMode == capabilities.fanMode.fanMode.medium.NAME then + fan_mode_id = clusters.FanControl.attributes.FanMode.MEDIUM + elseif cmd.args.fanMode == capabilities.fanMode.fanMode.high.NAME then + fan_mode_id = clusters.FanControl.attributes.FanMode.HIGH + elseif cmd.args.fanMode == capabilities.fanMode.fanMode.auto.NAME then + fan_mode_id = clusters.FanControl.attributes.FanMode.AUTO + else + fan_mode_id = clusters.FanControl.attributes.FanMode.OFF + end + if fan_mode_id then + local fan_ep = device:get_endpoints(clusters.FanControl.ID)[1] + device:send(clusters.FanControl.attributes.FanMode:write(device, fan_ep, fan_mode_id)) + end +end + + +-- [[ FAN SPEED PERCENT CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_fan_speed_set_percent(driver, device, cmd) + local speed = math.floor(cmd.args.percent) + local fan_ep = device:get_endpoints(clusters.FanControl.ID)[1] + device:send(clusters.FanControl.attributes.PercentSetting:write(device, fan_ep, speed)) +end + +return CapabilityHandlers \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/src/generic_handlers/event_handlers.lua b/drivers/SmartThings/matter-switch/src/generic_handlers/event_handlers.lua new file mode 100644 index 0000000000..c16a3bd2d5 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/generic_handlers/event_handlers.lua @@ -0,0 +1,93 @@ +-- 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 lua_socket = require "socket" +local fields = require "utils.switch_fields" +local switch_utils = require "utils.switch_utils" + +local EventHandlers = {} + + +-- [[ SWITCH CLUSTER EVENTS ]] -- + +function EventHandlers.initial_press_handler(driver, device, ib, response) + if switch_utils.get_field_for_endpoint(device, fields.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 + switch_utils.set_field_for_endpoint(device, fields.IGNORE_NEXT_MPC, ib.endpoint_id, nil) + elseif switch_utils.get_field_for_endpoint(device, fields.INITIAL_PRESS_ONLY, ib.endpoint_id) then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.button.pushed({state_change = true})) + elseif switch_utils.get_field_for_endpoint(device, fields.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 + switch_utils.set_field_for_endpoint(device, fields.START_BUTTON_PRESS, ib.endpoint_id, lua_socket.gettime(), {persist = false}) + end +end + +-- if the device distinguishes a long press event, it will always be a "held" +-- there's also a "long release" event, but this event is required to come first +function EventHandlers.long_press_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.button.held({state_change = true})) + if switch_utils.get_field_for_endpoint(device, fields.SUPPORTS_MULTI_PRESS, ib.endpoint_id) then + -- Ignore the next MultiPressComplete event if it is sent as part of this "long press" event sequence + switch_utils.set_field_for_endpoint(device, fields.IGNORE_NEXT_MPC, ib.endpoint_id, true) + end +end + +function EventHandlers.multi_press_complete_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 switch_utils.get_field_for_endpoint(device, fields.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 + device.log.info(string.format("Number of presses (%d) not supported by capability", press_value)) + end + end + switch_utils.set_field_for_endpoint(device, fields.IGNORE_NEXT_MPC, ib.endpoint_id, nil) +end + +local function emulate_held_event(device, ep) + local now = lua_socket.gettime() + local press_init = switch_utils.get_field_for_endpoint(device, fields.START_BUTTON_PRESS, ep) or now -- if we don't have an init time, assume instant release + if (now - press_init) < fields.TIMEOUT_THRESHOLD then + if (now - press_init) > fields.HELD_THRESHOLD then + device:emit_event_for_endpoint(ep, capabilities.button.button.held({state_change = true})) + else + device:emit_event_for_endpoint(ep, capabilities.button.button.pushed({state_change = true})) + end + end + switch_utils.set_field_for_endpoint(device, fields.START_BUTTON_PRESS, ep, nil, {persist = false}) +end + +function EventHandlers.short_release_handler(driver, device, ib, response) + if not switch_utils.get_field_for_endpoint(device, fields.SUPPORTS_MULTI_PRESS, ib.endpoint_id) then + if switch_utils.get_field_for_endpoint(device, fields.EMULATE_HELD, ib.endpoint_id) then + emulate_held_event(device, ib.endpoint_id) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.button.pushed({state_change = true})) + end + end +end + +return EventHandlers \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/src/generic_handlers/power_consumption_report.lua b/drivers/SmartThings/matter-switch/src/generic_handlers/power_consumption_report.lua new file mode 100644 index 0000000000..8692d7b262 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/generic_handlers/power_consumption_report.lua @@ -0,0 +1,114 @@ +-- 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 fields = require "utils.switch_fields" +local embedded_cluster_utils = require "utils.embedded_cluster_utils" +local version = require "version" + +local PowerConsumptionReporting = {} + +-- Include driver-side definitions when lua libs api version is < 11 +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" +end + +-- [[ POWER CONSUMPTION REPORT HELPER FUNCTIONS ]] -- + +-- Return an ISO-8061 timestamp in UTC +local function iso8061Timestamp(time) + return os.date("!%Y-%m-%dT%H:%M:%SZ", time) +end + +-- Emit the capability event capturing the latest energy delta and timestamps +local function send_import_poll_report(device, latest_total_imported_energy_wh) + local current_time = os.time() + local last_time = device:get_field(fields.LAST_IMPORTED_REPORT_TIMESTAMP) or 0 + device:set_field(fields.LAST_IMPORTED_REPORT_TIMESTAMP, current_time, { persist = true }) + + -- Calculate the energy delta between reports + local energy_delta_wh = 0.0 + local previous_imported_report = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, + capabilities.powerConsumptionReport.powerConsumption.NAME) + if previous_imported_report and previous_imported_report.energy then + energy_delta_wh = math.max(latest_total_imported_energy_wh - previous_imported_report.energy, 0.0) + end + + -- Report the energy consumed during the time interval. The unit of these values should be 'Wh' + if not device:get_field(fields.ENERGY_MANAGEMENT_ENDPOINT) then + device:emit_event(capabilities.powerConsumptionReport.powerConsumption({ + start = iso8061Timestamp(last_time), + ["end"] = iso8061Timestamp(current_time - 1), + deltaEnergy = energy_delta_wh, + energy = latest_total_imported_energy_wh + })) + else + device:emit_event_for_endpoint(device:get_field(fields.ENERGY_MANAGEMENT_ENDPOINT),capabilities.powerConsumptionReport.powerConsumption({ + start = iso8061Timestamp(last_time), + ["end"] = iso8061Timestamp(current_time - 1), + deltaEnergy = energy_delta_wh, + energy = latest_total_imported_energy_wh + })) + end +end + +-- Set the poll report schedule on the timer defined by IMPORT_REPORT_TIMEOUT +local function create_poll_report_schedule(device) + local import_timer = device.thread:call_on_schedule( + device:get_field(fields.IMPORT_REPORT_TIMEOUT), function() + send_import_poll_report(device, device:get_field(fields.TOTAL_IMPORTED_ENERGY)) + end, "polling_import_report_schedule_timer" + ) + device:set_field(fields.RECURRING_IMPORT_REPORT_POLL_TIMER, import_timer) +end + +function PowerConsumptionReporting.set_poll_report_timer_and_schedule(device, is_cumulative_report) + local cumul_eps = embedded_cluster_utils.get_endpoints(device, + clusters.ElectricalEnergyMeasurement.ID, + {feature_bitmap = clusters.ElectricalEnergyMeasurement.types.Feature.CUMULATIVE_ENERGY }) + if #cumul_eps == 0 then + device:set_field(fields.CUMULATIVE_REPORTS_NOT_SUPPORTED, true, {persist = true}) + end + if #cumul_eps > 0 and not is_cumulative_report then + return + elseif not device:get_field(fields.SUBSCRIPTION_REPORT_OCCURRED) then + device:set_field(fields.SUBSCRIPTION_REPORT_OCCURRED, true) + elseif not device:get_field(fields.FIRST_IMPORT_REPORT_TIMESTAMP) then + device:set_field(fields.FIRST_IMPORT_REPORT_TIMESTAMP, os.time()) + else + local first_timestamp = device:get_field(fields.FIRST_IMPORT_REPORT_TIMESTAMP) + local second_timestamp = os.time() + local report_interval_secs = second_timestamp - first_timestamp + device:set_field(fields.IMPORT_REPORT_TIMEOUT, math.max(report_interval_secs, fields.MINIMUM_ST_ENERGY_REPORT_INTERVAL)) + -- the poll schedule is only needed for devices that support powerConsumption + -- and enable powerConsumption when energy management is defined in root endpoint(0). + if device:supports_capability(capabilities.powerConsumptionReport) or + device:get_field(fields.ENERGY_MANAGEMENT_ENDPOINT) then + create_poll_report_schedule(device) + end + device:set_field(fields.IMPORT_POLL_TIMER_SETTING_ATTEMPTED, true) + end +end + +function PowerConsumptionReporting.delete_import_poll_schedule(device) + local import_poll_timer = device:get_field(fields.RECURRING_IMPORT_REPORT_POLL_TIMER) + if import_poll_timer then + device.thread:cancel_timer(import_poll_timer) + device:set_field(fields.RECURRING_IMPORT_REPORT_POLL_TIMER, nil) + device:set_field(fields.IMPORT_POLL_TIMER_SETTING_ATTEMPTED, nil) + end +end + +return PowerConsumptionReporting diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index a4bf6e64f9..6deb8ab603 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -12,704 +12,97 @@ -- See the License for the specific language governing permissions and -- limitations under the License. -local capabilities = require "st.capabilities" -local log = require "log" -local clusters = require "st.matter.clusters" -local im = require "st.matter.interaction_model" local MatterDriver = require "st.matter.driver" -local lua_socket = require "socket" -local utils = require "st.utils" +local capabilities = require "st.capabilities" local device_lib = require "st.device" -local embedded_cluster_utils = require "embedded-cluster-utils" +local clusters = require "st.matter.clusters" +local log = require "log" local version = require "version" --- Include driver-side definitions when lua libs api version is < 11 -if version.api < 11 then - clusters.ElectricalEnergyMeasurement = require "ElectricalEnergyMeasurement" - clusters.ElectricalPowerMeasurement = require "ElectricalPowerMeasurement" - clusters.ValveConfigurationAndControl = require "ValveConfigurationAndControl" -end - -local MOST_RECENT_TEMP = "mostRecentTemp" -local RECEIVED_X = "receivedX" -local RECEIVED_Y = "receivedY" -local HUESAT_SUPPORT = "huesatSupport" -local MIRED_KELVIN_CONVERSION_CONSTANT = 1000000 --- These values are a "sanity check" to check that values we are getting are reasonable -local COLOR_TEMPERATURE_KELVIN_MAX = 15000 -local COLOR_TEMPERATURE_KELVIN_MIN = 1000 -local COLOR_TEMPERATURE_MIRED_MAX = MIRED_KELVIN_CONVERSION_CONSTANT/COLOR_TEMPERATURE_KELVIN_MIN -local COLOR_TEMPERATURE_MIRED_MIN = MIRED_KELVIN_CONVERSION_CONSTANT/COLOR_TEMPERATURE_KELVIN_MAX -local SWITCH_LEVEL_LIGHTING_MIN = 1 -local CURRENT_HUESAT_ATTR_MIN = 0 -local CURRENT_HUESAT_ATTR_MAX = 254 - --- COMPONENT_TO_ENDPOINT_MAP is here to preserve the endpoint mapping for --- devices that were joined to this driver as MCD devices before the transition --- to join switch devices as parent-child. This value will exist in the device --- table for devices that joined prior to this transition, and is also used for --- button devices that require component mapping. -local COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map" -local ENERGY_MANAGEMENT_ENDPOINT = "__energy_management_endpoint" -local IS_PARENT_CHILD_DEVICE = "__is_parent_child_device" -local COLOR_TEMP_BOUND_RECEIVED_KELVIN = "__colorTemp_bound_received_kelvin" -local COLOR_TEMP_BOUND_RECEIVED_MIRED = "__colorTemp_bound_received_mired" -local COLOR_TEMP_MIN = "__color_temp_min" -local COLOR_TEMP_MAX = "__color_temp_max" -local LEVEL_BOUND_RECEIVED = "__level_bound_received" -local LEVEL_MIN = "__level_min" -local LEVEL_MAX = "__level_max" -local COLOR_MODE = "__color_mode" - -local updated_fields = { - { current_field_name = "__component_to_endpoint_map_button", updated_field_name = COMPONENT_TO_ENDPOINT_MAP }, - { current_field_name = "__switch_intialized", updated_field_name = nil } -} - -local HUE_SAT_COLOR_MODE = clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION -local X_Y_COLOR_MODE = clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY - -local AGGREGATOR_DEVICE_TYPE_ID = 0x000E -local ON_OFF_LIGHT_DEVICE_TYPE_ID = 0x0100 -local DIMMABLE_LIGHT_DEVICE_TYPE_ID = 0x0101 -local COLOR_TEMP_LIGHT_DEVICE_TYPE_ID = 0x010C -local EXTENDED_COLOR_LIGHT_DEVICE_TYPE_ID = 0x010D -local ON_OFF_PLUG_DEVICE_TYPE_ID = 0x010A -local DIMMABLE_PLUG_DEVICE_TYPE_ID = 0x010B -local ON_OFF_SWITCH_ID = 0x0103 -local ON_OFF_DIMMER_SWITCH_ID = 0x0104 -local ON_OFF_COLOR_DIMMER_SWITCH_ID = 0x0105 -local MOUNTED_ON_OFF_CONTROL_ID = 0x010F -local MOUNTED_DIMMABLE_LOAD_CONTROL_ID = 0x0110 -local GENERIC_SWITCH_ID = 0x000F -local ELECTRICAL_SENSOR_ID = 0x0510 -local device_type_profile_map = { - [ON_OFF_LIGHT_DEVICE_TYPE_ID] = "light-binary", - [DIMMABLE_LIGHT_DEVICE_TYPE_ID] = "light-level", - [COLOR_TEMP_LIGHT_DEVICE_TYPE_ID] = "light-level-colorTemperature", - [EXTENDED_COLOR_LIGHT_DEVICE_TYPE_ID] = "light-color-level", - [ON_OFF_PLUG_DEVICE_TYPE_ID] = "plug-binary", - [DIMMABLE_PLUG_DEVICE_TYPE_ID] = "plug-level", - [ON_OFF_SWITCH_ID] = "switch-binary", - [ON_OFF_DIMMER_SWITCH_ID] = "switch-level", - [ON_OFF_COLOR_DIMMER_SWITCH_ID] = "switch-color-level", - [MOUNTED_ON_OFF_CONTROL_ID] = "switch-binary", - [MOUNTED_DIMMABLE_LOAD_CONTROL_ID] = "switch-level", -} +local fields = require "utils.switch_fields" +local switch_utils = require "utils.switch_utils" +local cfg = require "utils.device_configuration" +local device_cfg = cfg.DeviceCfg +local switch_cfg = cfg.SwitchCfg +local button_cfg = cfg.ButtonCfg -local device_type_attribute_map = { - [ON_OFF_LIGHT_DEVICE_TYPE_ID] = { - clusters.OnOff.attributes.OnOff - }, - [DIMMABLE_LIGHT_DEVICE_TYPE_ID] = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel - }, - [COLOR_TEMP_LIGHT_DEVICE_TYPE_ID] = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds - }, - [EXTENDED_COLOR_LIGHT_DEVICE_TYPE_ID] = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - clusters.ColorControl.attributes.CurrentHue, - clusters.ColorControl.attributes.CurrentSaturation, - clusters.ColorControl.attributes.CurrentX, - clusters.ColorControl.attributes.CurrentY - }, - [ON_OFF_PLUG_DEVICE_TYPE_ID] = { - clusters.OnOff.attributes.OnOff - }, - [DIMMABLE_PLUG_DEVICE_TYPE_ID] = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel - }, - [ON_OFF_SWITCH_ID] = { - clusters.OnOff.attributes.OnOff - }, - [ON_OFF_DIMMER_SWITCH_ID] = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel - }, - [ON_OFF_COLOR_DIMMER_SWITCH_ID] = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - clusters.ColorControl.attributes.CurrentHue, - clusters.ColorControl.attributes.CurrentSaturation, - clusters.ColorControl.attributes.CurrentX, - clusters.ColorControl.attributes.CurrentY - }, - [GENERIC_SWITCH_ID] = { - clusters.PowerSource.attributes.BatPercentRemaining, - clusters.Switch.events.InitialPress, - clusters.Switch.events.LongPress, - clusters.Switch.events.ShortRelease, - clusters.Switch.events.MultiPressComplete - }, - [ELECTRICAL_SENSOR_ID] = { - clusters.ElectricalPowerMeasurement.attributes.ActivePower, - clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, - clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported - } -} +local attribute_handlers = require "generic_handlers.attribute_handlers" +local event_handlers = require "generic_handlers.event_handlers" +local capability_handlers = require "generic_handlers.capability_handlers" +local power_consumption_reporting = require "generic_handlers.power_consumption_report" -local child_device_profile_overrides_per_vendor_id = { - [0x1321] = { - { product_id = 0x000C, target_profile = "switch-binary", initial_profile = "plug-binary" }, - { product_id = 0x000D, target_profile = "switch-binary", initial_profile = "plug-binary" }, - }, - [0x115F] = { - { product_id = 0x1003, target_profile = "light-power-energy-powerConsumption" }, -- 2 Buttons(Generic Switch), 1 Channel(On/Off Light) - { product_id = 0x1004, target_profile = "light-power-energy-powerConsumption" }, -- 2 Buttons(Generic Switch), 2 Channels(On/Off Light) - { product_id = 0x1005, target_profile = "light-power-energy-powerConsumption" }, -- 4 Buttons(Generic Switch), 3 Channels(On/Off Light) - { product_id = 0x1006, target_profile = "light-level-power-energy-powerConsumption" }, -- 3 Buttons(Generic Switch), 1 Channels(Dimmable Light) - { product_id = 0x1008, target_profile = "light-power-energy-powerConsumption" }, -- 2 Buttons(Generic Switch), 1 Channel(On/Off Light) - { product_id = 0x1009, target_profile = "light-power-energy-powerConsumption" }, -- 4 Buttons(Generic Switch), 2 Channels(On/Off Light) - { product_id = 0x100A, target_profile = "light-level-power-energy-powerConsumption" }, -- 1 Buttons(Generic Switch), 1 Channels(Dimmable Light) - } -} - -local detect_matter_thing - -local CUMULATIVE_REPORTS_NOT_SUPPORTED = "__cumulative_reports_not_supported" -local FIRST_IMPORT_REPORT_TIMESTAMP = "__first_import_report_timestamp" -local IMPORT_POLL_TIMER_SETTING_ATTEMPTED = "__import_poll_timer_setting_attempted" -local IMPORT_REPORT_TIMEOUT = "__import_report_timeout" -local TOTAL_IMPORTED_ENERGY = "__total_imported_energy" -local LAST_IMPORTED_REPORT_TIMESTAMP = "__last_imported_report_timestamp" -local RECURRING_IMPORT_REPORT_POLL_TIMER = "__recurring_import_report_poll_timer" -local MINIMUM_ST_ENERGY_REPORT_INTERVAL = (15 * 60) -- 15 minutes, reported in seconds -local SUBSCRIPTION_REPORT_OCCURRED = "__subscription_report_occurred" -local CONVERSION_CONST_MILLIWATT_TO_WATT = 1000 -- A milliwatt is 1/1000th of a watt - --- Return an ISO-8061 timestamp in UTC -local function iso8061Timestamp(time) - return os.date("!%Y-%m-%dT%H:%M:%SZ", time) -end - -local function delete_import_poll_schedule(device) - local import_poll_timer = device:get_field(RECURRING_IMPORT_REPORT_POLL_TIMER) - if import_poll_timer then - device.thread:cancel_timer(import_poll_timer) - device:set_field(RECURRING_IMPORT_REPORT_POLL_TIMER, nil) - device:set_field(IMPORT_POLL_TIMER_SETTING_ATTEMPTED, nil) - end -end - -local function send_import_poll_report(device, latest_total_imported_energy_wh) - local current_time = os.time() - local last_time = device:get_field(LAST_IMPORTED_REPORT_TIMESTAMP) or 0 - device:set_field(LAST_IMPORTED_REPORT_TIMESTAMP, current_time, { persist = true }) - - -- Calculate the energy delta between reports - local energy_delta_wh = 0.0 - local previous_imported_report = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, - capabilities.powerConsumptionReport.powerConsumption.NAME) - if previous_imported_report and previous_imported_report.energy then - energy_delta_wh = math.max(latest_total_imported_energy_wh - previous_imported_report.energy, 0.0) - end - - -- Report the energy consumed during the time interval. The unit of these values should be 'Wh' - if not device:get_field(ENERGY_MANAGEMENT_ENDPOINT) then - device:emit_event(capabilities.powerConsumptionReport.powerConsumption({ - start = iso8061Timestamp(last_time), - ["end"] = iso8061Timestamp(current_time - 1), - deltaEnergy = energy_delta_wh, - energy = latest_total_imported_energy_wh - })) - else - device:emit_event_for_endpoint(device:get_field(ENERGY_MANAGEMENT_ENDPOINT),capabilities.powerConsumptionReport.powerConsumption({ - start = iso8061Timestamp(last_time), - ["end"] = iso8061Timestamp(current_time - 1), - deltaEnergy = energy_delta_wh, - energy = latest_total_imported_energy_wh - })) - end -end - -local function create_poll_report_schedule(device) - local import_timer = device.thread:call_on_schedule( - device:get_field(IMPORT_REPORT_TIMEOUT), function() - send_import_poll_report(device, device:get_field(TOTAL_IMPORTED_ENERGY)) - end, "polling_import_report_schedule_timer" - ) - device:set_field(RECURRING_IMPORT_REPORT_POLL_TIMER, import_timer) -end - -local function set_poll_report_timer_and_schedule(device, is_cumulative_report) - local cumul_eps = embedded_cluster_utils.get_endpoints(device, - clusters.ElectricalEnergyMeasurement.ID, - {feature_bitmap = clusters.ElectricalEnergyMeasurement.types.Feature.CUMULATIVE_ENERGY }) - if #cumul_eps == 0 then - device:set_field(CUMULATIVE_REPORTS_NOT_SUPPORTED, true, {persist = true}) - end - if #cumul_eps > 0 and not is_cumulative_report then - return - elseif not device:get_field(SUBSCRIPTION_REPORT_OCCURRED) then - device:set_field(SUBSCRIPTION_REPORT_OCCURRED, true) - elseif not device:get_field(FIRST_IMPORT_REPORT_TIMESTAMP) then - device:set_field(FIRST_IMPORT_REPORT_TIMESTAMP, os.time()) - else - local first_timestamp = device:get_field(FIRST_IMPORT_REPORT_TIMESTAMP) - local second_timestamp = os.time() - local report_interval_secs = second_timestamp - first_timestamp - device:set_field(IMPORT_REPORT_TIMEOUT, math.max(report_interval_secs, MINIMUM_ST_ENERGY_REPORT_INTERVAL)) - -- the poll schedule is only needed for devices that support powerConsumption - -- and enable powerConsumption when energy management is defined in root endpoint(0). - if device:supports_capability(capabilities.powerConsumptionReport) or - device:get_field(ENERGY_MANAGEMENT_ENDPOINT) then - create_poll_report_schedule(device) - end - device:set_field(IMPORT_POLL_TIMER_SETTING_ATTEMPTED, true) - end -end - -local START_BUTTON_PRESS = "__start_button_press" -local TIMEOUT_THRESHOLD = 10 --arbitrary timeout -local HELD_THRESHOLD = 1 --- this is the number of buttons for which we have a static profile already made -local STATIC_BUTTON_PROFILE_SUPPORTED = {1, 2, 3, 4, 5, 6, 7, 8, 9} - --- 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 TEMP_BOUND_RECEIVED = "__temp_bound_received" -local TEMP_MIN = "__temp_min" -local TEMP_MAX = "__temp_max" - -local AQARA_MANUFACTURER_ID = 0x115F -local AQARA_CLIMATE_SENSOR_W100_ID = 0x2004 - ---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 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 - -local function emulate_held_event(device, ep) - local now = lua_socket.gettime() - local press_init = get_field_for_endpoint(device, START_BUTTON_PRESS, ep) or now -- if we don't have an init time, assume instant release - if (now - press_init) < TIMEOUT_THRESHOLD then - if (now - press_init) > HELD_THRESHOLD then - device:emit_event_for_endpoint(ep, capabilities.button.button.held({state_change = true})) - else - device:emit_event_for_endpoint(ep, capabilities.button.button.pushed({state_change = true})) - end - end - set_field_for_endpoint(device, START_BUTTON_PRESS, ep, nil, {persist = false}) -end - -local function convert_huesat_st_to_matter(val) - return utils.clamp_value(math.floor((val * 0xFE) / 100.0 + 0.5), CURRENT_HUESAT_ATTR_MIN, CURRENT_HUESAT_ATTR_MAX) -end - -local function mired_to_kelvin(value, minOrMax) - if value == 0 then -- shouldn't happen, but has - value = 1 - log.warn(string.format("Received a color temperature of 0 mireds. Using a color temperature of 1 mired to avoid divide by zero")) - end - -- We divide inside the rounding and multiply outside of it because we expect these - -- bounds to be multiples of 100. For the maximum mired value (minimum K value), - -- add 1 before converting and round up to nearest hundreds. For the minimum mired - -- (maximum K value) value, subtract 1 before converting and round down to nearest - -- hundreds. Note that 1 is added/subtracted from the mired value in order to avoid - -- rounding errors from the conversion of Kelvin to mireds. - local kelvin_step_size = 100 - local rounding_value = 0.5 - if minOrMax == COLOR_TEMP_MIN then - return utils.round(MIRED_KELVIN_CONVERSION_CONSTANT / (kelvin_step_size * (value + 1)) + rounding_value) * kelvin_step_size - elseif minOrMax == COLOR_TEMP_MAX then - return utils.round(MIRED_KELVIN_CONVERSION_CONSTANT / (kelvin_step_size * (value - 1)) - rounding_value) * kelvin_step_size - else - log.warn_with({hub_logs = true}, "Attempted to convert temperature unit for an undefined value") - end -end - ---- device_type_supports_button_switch_combination helper function used to check ---- whether the device type for an endpoint is currently supported by a profile for ---- combination button/switch devices. -local function device_type_supports_button_switch_combination(device, endpoint_id) - for _, ep in ipairs(device.endpoints) do - if ep.endpoint_id == endpoint_id then - for _, dt in ipairs(ep.device_types) do - if dt.device_type_id == DIMMABLE_LIGHT_DEVICE_TYPE_ID then - for _, fingerprint in ipairs(child_device_profile_overrides_per_vendor_id[0x115F]) do - if device.manufacturer_info.product_id == fingerprint.product_id then - return false -- For Aqara Dimmer Switch with Button. - end - end - return true - end - end - end - end - return false -end - -local function get_first_non_zero_endpoint(endpoints) - table.sort(endpoints) - for _,ep in ipairs(endpoints) do - if ep ~= 0 then -- 0 is the matter RootNode endpoint - return ep - end - end - return nil -end - ---- find_default_endpoint is a helper function to handle situations where ---- device does not have endpoint ids in sequential order from 1 -local function find_default_endpoint(device) - if device.manufacturer_info.vendor_id == AQARA_MANUFACTURER_ID and - device.manufacturer_info.product_id == AQARA_CLIMATE_SENSOR_W100_ID then - -- In case of Aqara Climate Sensor W100, in order to sequentially set the button name to button 1, 2, 3 - return device.MATTER_DEFAULT_ENDPOINT - end - - local switch_eps = device:get_endpoints(clusters.OnOff.ID) - local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) - - -- Return the first switch endpoint as the default endpoint if no button endpoints are present - if #button_eps == 0 and #switch_eps > 0 then - return get_first_non_zero_endpoint(switch_eps) - end - - -- Return the first button endpoint as the default endpoint if no switch endpoints are present - if #switch_eps == 0 and #button_eps > 0 then - return get_first_non_zero_endpoint(button_eps) - end - - -- If both switch and button endpoints are present, check the device type on the main switch - -- endpoint. If it is not a supported device type, return the first button endpoint as the - -- default endpoint. - if #switch_eps > 0 and #button_eps > 0 then - local main_endpoint = get_first_non_zero_endpoint(switch_eps) - if device_type_supports_button_switch_combination(device, main_endpoint) then - return main_endpoint - else - device.log.warn("The main switch endpoint does not contain a supported device type for a component configuration with buttons") - return get_first_non_zero_endpoint(button_eps) - end - end - - device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) - return device.MATTER_DEFAULT_ENDPOINT -end - -local function component_to_endpoint(device, component) - local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {} - if map[component] then - return map[component] - end - return find_default_endpoint(device) -end - -local function endpoint_to_component(device, ep) - local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {} - for component, endpoint in pairs(map) do - if endpoint == ep then - return component - end - end - return "main" -end - -local function check_field_name_updates(device) - for _, field in ipairs(updated_fields) do - if device:get_field(field.current_field_name) then - if field.updated_field_name ~= nil then - device:set_field(field.updated_field_name, device:get_field(field.current_field_name), {persist = true}) - end - device:set_field(field.current_field_name, nil) - end - end -end - -local function assign_child_profile(device, child_ep) - local profile - - for _, ep in ipairs(device.endpoints) do - if ep.endpoint_id == child_ep then - -- Some devices report multiple device types which are a subset of - -- a superset device type (For example, Dimmable Light is a superset of - -- On/Off light). This mostly applies to the four light types, so we will want - -- to match the profile for the superset device type. This can be done by - -- matching to the device type with the highest ID - local id = 0 - for _, dt in ipairs(ep.device_types) do - id = math.max(id, dt.device_type_id) - end - profile = device_type_profile_map[id] - break - end - end - - -- Check if device has an overridden child profile that differs from the profile that would match - -- the child's device type for the following two cases: - -- 1. To add Electrical Sensor only to the first EDGE_CHILD (light-power-energy-powerConsumption) - -- for the Aqara Light Switch H2. The profile of the second EDGE_CHILD for this device is - -- determined in the "for" loop above (e.g., light-binary) - -- 2. The selected profile for the child device matches the initial profile defined in - -- child_device_profile_overrides - for id, vendor in pairs(child_device_profile_overrides_per_vendor_id) do - for _, fingerprint in ipairs(vendor) do - if device.manufacturer_info.product_id == fingerprint.product_id and - ((device.manufacturer_info.vendor_id == AQARA_MANUFACTURER_ID and child_ep == 1) or profile == fingerprint.initial_profile) then - return fingerprint.target_profile - end - end - end - - -- default to "switch-binary" if no profile is found - return profile or "switch-binary" +-- Include driver-side definitions when lua libs api version is < 11 +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" + clusters.ValveConfigurationAndControl = require "embedded_clusters.ValveConfigurationAndControl" end -local function configure_buttons(device) - local ms_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) - local msr_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_RELEASE}) - local msl_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS}) - local msm_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS}) - - for _, ep in ipairs(ms_eps) do - if device.profile.components[endpoint_to_component(device, ep)] then - device.log.info_with({hub_logs=true}, string.format("Configuring Supported Values for generic switch endpoint %d", ep)) - local supportedButtonValues_event - -- this ordering is important, since MSM & MSL devices must also support MSR - if tbl_contains(msm_eps, ep) then - supportedButtonValues_event = nil -- deferred to the max press handler - device:send(clusters.Switch.attributes.MultiPressMax:read(device, ep)) - set_field_for_endpoint(device, SUPPORTS_MULTI_PRESS, ep, true, {persist = true}) - elseif tbl_contains(msl_eps, ep) then - supportedButtonValues_event = capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}) - elseif tbl_contains(msr_eps, ep) then - supportedButtonValues_event = capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}) - set_field_for_endpoint(device, EMULATE_HELD, ep, true, {persist = true}) - else -- this switch endpoint only supports momentary switch, no release events - supportedButtonValues_event = capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}) - set_field_for_endpoint(device, INITIAL_PRESS_ONLY, ep, true, {persist = true}) - end +local SwitchLifecycleHandlers = {} - if supportedButtonValues_event then - device:emit_event_for_endpoint(ep, supportedButtonValues_event) - end - device:emit_event_for_endpoint(ep, capabilities.button.button.pushed({state_change = false})) - else - device.log.info_with({hub_logs=true}, string.format("Component not found for generic switch endpoint %d. Skipping Supported Value configuration", ep)) - end +function SwitchLifecycleHandlers.device_added(driver, device) + -- refresh child devices to get an initial attribute state for OnOff in case child device + -- was created after the initial subscription report + if device.network_type == device_lib.NETWORK_TYPE_CHILD then + device:send(clusters.OnOff.attributes.OnOff:read(device)) end -end - -local function find_child(parent, ep_id) - return parent:get_child_by_parent_assigned_key(string.format("%d", ep_id)) -end -local function build_button_component_map(device, main_endpoint, button_eps) - -- create component mapping on the main profile button endpoints - table.sort(button_eps) - local component_map = {} - component_map["main"] = main_endpoint - for component_num, ep in ipairs(button_eps) do - if ep ~= main_endpoint then - local button_component = "button" - if #button_eps > 1 then - button_component = button_component .. component_num - end - component_map[button_component] = ep - end - end - device:set_field(COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) + -- call device init in case init is not called after added due to device caching + SwitchLifecycleHandlers.device_init(driver, device) end -local function build_button_profile(device, main_endpoint, num_button_eps) - local profile_name = string.gsub(num_button_eps .. "-button", "1%-", "") -- remove the "1-" in a device with 1 button ep - if device_type_supports_button_switch_combination(device, main_endpoint) then - profile_name = "light-level-" .. profile_name - end - local battery_supported = #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) > 0 - if battery_supported then -- battery profiles are configured later, in power_source_attribute_list_handler - device:send(clusters.PowerSource.attributes.AttributeList:read(device)) - else - device:try_update_metadata({profile = profile_name}) +function SwitchLifecycleHandlers.do_configure(driver, device) + if device.network_type == device_lib.NETWORK_TYPE_MATTER and not switch_utils.detect_bridge(device) then + device_cfg.match_profile(driver, device) end end -local function build_child_switch_profiles(driver, device, main_endpoint) - local num_switch_server_eps = 0 - local parent_child_device = false - local switch_eps = device:get_endpoints(clusters.OnOff.ID) - table.sort(switch_eps) - for idx, ep in ipairs(switch_eps) do - if device:supports_server_cluster(clusters.OnOff.ID, ep) then - num_switch_server_eps = num_switch_server_eps + 1 - if ep ~= main_endpoint then -- don't create a child device that maps to the main endpoint - local name = string.format("%s %d", device.label, num_switch_server_eps) - local child_profile = assign_child_profile(device, ep) - driver:try_create_device( - { - type = "EDGE_CHILD", - label = name, - profile = child_profile, - parent_device_id = device.id, - parent_assigned_child_key = string.format("%d", ep), - vendor_provided_label = name - } - ) - parent_child_device = true - if idx == 1 and string.find(child_profile, "energy") then - -- when energy management is defined in the root endpoint(0), replace it with the first switch endpoint and process it. - device:set_field(ENERGY_MANAGEMENT_ENDPOINT, ep, {persist = true}) - end - end - end - end - - -- If the device is a parent child device, set the find_child function on init. This is persisted because initialize_buttons_and_switches - -- is only run once, but find_child function should be set on each driver init. - if parent_child_device then - device:set_field(IS_PARENT_CHILD_DEVICE, true, {persist = true}) +function SwitchLifecycleHandlers.driver_switched(driver, device) + if device.network_type == device_lib.NETWORK_TYPE_MATTER and not switch_utils.detect_bridge(device) then + device_cfg.match_profile(driver, device) end - - -- this is needed in initialize_buttons_and_switches - return num_switch_server_eps end -local function handle_light_switch_with_onOff_server_clusters(device, main_endpoint) - local cluster_id = 0 - for _, ep in ipairs(device.endpoints) do - -- main_endpoint only supports server cluster by definition of get_endpoints() - if main_endpoint == ep.endpoint_id then - for _, dt in ipairs(ep.device_types) do - -- no device type that is not in the switch subset should be considered. - if (ON_OFF_SWITCH_ID <= dt.device_type_id and dt.device_type_id <= ON_OFF_COLOR_DIMMER_SWITCH_ID) then - cluster_id = math.max(cluster_id, dt.device_type_id) - end - end - break +function SwitchLifecycleHandlers.info_changed(driver, device, event, args) + if device.profile.id ~= args.old_st_store.profile.id then + device:subscribe() + local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + if #button_eps > 0 and device.network_type == device_lib.NETWORK_TYPE_MATTER then + button_cfg.configure_buttons(device) end end - - if device_type_profile_map[cluster_id] then - device:try_update_metadata({profile = device_type_profile_map[cluster_id]}) - end end -local function initialize_buttons_and_switches(driver, device, main_endpoint) - local profile_found = false - local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) - if tbl_contains(STATIC_BUTTON_PROFILE_SUPPORTED, #button_eps) then - build_button_profile(device, main_endpoint, #button_eps) - -- All button endpoints found will be added as additional components in the profile containing the main_endpoint. - -- The resulting endpoint to component map is saved in the COMPONENT_TO_ENDPOINT_MAP field - build_button_component_map(device, main_endpoint, button_eps) - configure_buttons(device) - profile_found = true - end - - -- Without support for bindings, only clusters that are implemented as server are counted. This count is handled - -- while building switch child profiles - local num_switch_server_eps = build_child_switch_profiles(driver, device, main_endpoint) - - -- We do not support the Light Switch device types because they require OnOff to be implemented as 'client', which requires us to support bindings. - -- However, this workaround profiles devices that claim to be Light Switches, but that break spec and implement OnOff as 'server'. - -- Note: since their device type isn't supported, these devices join as a matter-thing. - if num_switch_server_eps > 0 and detect_matter_thing(device) then - handle_light_switch_with_onOff_server_clusters(device, main_endpoint) - profile_found = true - end - return profile_found +function SwitchLifecycleHandlers.device_removed(driver, device) + device.log.info("device removed") + power_consumption_reporting.delete_import_poll_schedule(device) end -local function detect_bridge(device) - for _, ep in ipairs(device.endpoints) do - for _, dt in ipairs(ep.device_types) do - if dt.device_type_id == AGGREGATOR_DEVICE_TYPE_ID then - return true - end - end - end - return false -end - -local function device_init(driver, device) +function SwitchLifecycleHandlers.device_init(driver, device) if device.network_type == device_lib.NETWORK_TYPE_MATTER then - check_field_name_updates(device) - device:set_component_to_endpoint_fn(component_to_endpoint) - device:set_endpoint_to_component_fn(endpoint_to_component) - if device:get_field(IS_PARENT_CHILD_DEVICE) then - device:set_find_child(find_child) + switch_utils.check_field_name_updates(device) + device:set_component_to_endpoint_fn(switch_utils.component_to_endpoint) + device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) + if device:get_field(fields.IS_PARENT_CHILD_DEVICE) then + device:set_find_child(switch_utils.find_child) end - local main_endpoint = find_default_endpoint(device) + local main_endpoint = switch_utils.find_default_endpoint(device) -- ensure subscription to all endpoint attributes- including those mapped to child devices for idx, ep in ipairs(device.endpoints) do if ep.endpoint_id ~= main_endpoint then if device:supports_server_cluster(clusters.OnOff.ID, ep) then - local child_profile = assign_child_profile(device, ep) + local child_profile = switch_cfg.assign_child_profile(device, ep) if idx == 1 and string.find(child_profile, "energy") then -- when energy management is defined in the root endpoint(0), replace it with the first switch endpoint and process it. - device:set_field(ENERGY_MANAGEMENT_ENDPOINT, ep, {persist = true}) + device:set_field(fields.ENERGY_MANAGEMENT_ENDPOINT, ep, {persist = true}) end end local id = 0 for _, dt in ipairs(ep.device_types) do id = math.max(id, dt.device_type_id) end - for _, attr in pairs(device_type_attribute_map[id] or {}) do - if id == GENERIC_SWITCH_ID and + for _, attr in pairs(fields.device_type_attribute_map[id] or {}) do + if id == fields.GENERIC_SWITCH_ID and attr ~= clusters.PowerSource.attributes.BatPercentRemaining and attr ~= clusters.PowerSource.attributes.BatChargeLevel then device:add_subscribed_event(attr) @@ -719,816 +112,96 @@ local function device_init(driver, device) end end end - device:subscribe() - end -end - -local function match_profile(driver, device) - local main_endpoint = find_default_endpoint(device) - -- initialize the main device card with buttons if applicable, and create child devices as needed for multi-switch devices. - local profile_found = initialize_buttons_and_switches(driver, device, main_endpoint) - if device:get_field(IS_PARENT_CHILD_DEVICE) then - device:set_find_child(find_child) - end - if profile_found then - return - end - - local fan_eps = device:get_endpoints(clusters.FanControl.ID) - local level_eps = device:get_endpoints(clusters.LevelControl.ID) - local energy_eps = embedded_cluster_utils.get_endpoints(device, clusters.ElectricalEnergyMeasurement.ID) - local power_eps = embedded_cluster_utils.get_endpoints(device, clusters.ElectricalPowerMeasurement.ID) - local valve_eps = embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID) - local profile_name = nil - local level_support = "" - if #level_eps > 0 then - level_support = "-level" - end - if #energy_eps > 0 and #power_eps > 0 then - profile_name = "plug" .. level_support .. "-power-energy-powerConsumption" - elseif #energy_eps > 0 then - profile_name = "plug" .. level_support .. "-energy-powerConsumption" - elseif #power_eps > 0 then - profile_name = "plug" .. level_support .. "-power" - elseif #valve_eps > 0 then - profile_name = "water-valve" - if #embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID, - {feature_bitmap = clusters.ValveConfigurationAndControl.types.Feature.LEVEL}) > 0 then - profile_name = profile_name .. "-level" - end - elseif #fan_eps > 0 then - profile_name = "light-color-level-fan" - end - if profile_name then - device:try_update_metadata({ profile = profile_name }) - end -end - -local function do_configure(driver, device) - if device.network_type == device_lib.NETWORK_TYPE_MATTER and not detect_bridge(device) then - match_profile(driver, device) - end -end - -local function driver_switched(driver, device) - if device.network_type == device_lib.NETWORK_TYPE_MATTER and not detect_bridge(device) then - match_profile(driver, device) - end -end - -local function device_removed(driver, device) - log.info("device removed") - delete_import_poll_schedule(device) -end - -local function handle_switch_on(driver, device, cmd) - if type(device.register_native_capability_cmd_handler) == "function" then - device:register_native_capability_cmd_handler(cmd.capability, cmd.command) - end - local endpoint_id = device:component_to_endpoint(cmd.component) - --TODO use OnWithRecallGlobalScene for devices with the LT feature - local req = clusters.OnOff.server.commands.On(device, endpoint_id) - device:send(req) -end - -local function handle_switch_off(driver, device, cmd) - if type(device.register_native_capability_cmd_handler) == "function" then - device:register_native_capability_cmd_handler(cmd.capability, cmd.command) - end - local endpoint_id = device:component_to_endpoint(cmd.component) - local req = clusters.OnOff.server.commands.Off(device, endpoint_id) - device:send(req) -end - -local function handle_set_switch_level(driver, device, cmd) - if type(device.register_native_capability_cmd_handler) == "function" then - device:register_native_capability_cmd_handler(cmd.capability, cmd.command) - end - local endpoint_id = device:component_to_endpoint(cmd.component) - local level = math.floor(cmd.args.level/100.0 * 254) - local req = clusters.LevelControl.server.commands.MoveToLevelWithOnOff(device, endpoint_id, level, cmd.args.rate, 0, 0) - device:send(req) -end - -local TRANSITION_TIME = 0 --1/10ths of a second --- When sent with a command, these options mask and override bitmaps cause the command --- to take effect when the switch/light is off. -local OPTIONS_MASK = 0x01 -local OPTIONS_OVERRIDE = 0x01 - -local function handle_set_color(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local req - local huesat_endpoints = device:get_endpoints(clusters.ColorControl.ID, {feature_bitmap = clusters.ColorControl.FeatureMap.HUE_AND_SATURATION}) - if tbl_contains(huesat_endpoints, endpoint_id) then - local hue = convert_huesat_st_to_matter(cmd.args.color.hue) - local sat = convert_huesat_st_to_matter(cmd.args.color.saturation) - req = clusters.ColorControl.server.commands.MoveToHueAndSaturation(device, endpoint_id, hue, sat, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - else - local x, y, _ = utils.safe_hsv_to_xy(cmd.args.color.hue, cmd.args.color.saturation) - req = clusters.ColorControl.server.commands.MoveToColor(device, endpoint_id, x, y, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - end - device:send(req) -end - -local function handle_set_hue(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local huesat_endpoints = device:get_endpoints(clusters.ColorControl.ID, {feature_bitmap = clusters.ColorControl.FeatureMap.HUE_AND_SATURATION}) - if tbl_contains(huesat_endpoints, endpoint_id) then - local hue = convert_huesat_st_to_matter(cmd.args.hue) - local req = clusters.ColorControl.server.commands.MoveToHue(device, endpoint_id, hue, 0, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - device:send(req) - else - log.warn("Device does not support huesat features on its color control cluster") - end -end - -local function handle_set_saturation(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local huesat_endpoints = device:get_endpoints(clusters.ColorControl.ID, {feature_bitmap = clusters.ColorControl.FeatureMap.HUE_AND_SATURATION}) - if tbl_contains(huesat_endpoints, endpoint_id) then - local sat = convert_huesat_st_to_matter(cmd.args.saturation) - local req = clusters.ColorControl.server.commands.MoveToSaturation(device, endpoint_id, sat, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - device:send(req) - else - log.warn("Device does not support huesat features on its color control cluster") - end -end - -local function handle_set_color_temperature(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local temp_in_kelvin = cmd.args.temperature - local min_temp_kelvin = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_KELVIN..COLOR_TEMP_MIN, endpoint_id) - local max_temp_kelvin = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_KELVIN..COLOR_TEMP_MAX, endpoint_id) - - local temp_in_mired = utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/temp_in_kelvin) - if min_temp_kelvin ~= nil and temp_in_kelvin <= min_temp_kelvin then - temp_in_mired = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_MIRED..COLOR_TEMP_MAX, endpoint_id) - elseif max_temp_kelvin ~= nil and temp_in_kelvin >= max_temp_kelvin then - temp_in_mired = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_MIRED..COLOR_TEMP_MIN, endpoint_id) - end - local req = clusters.ColorControl.server.commands.MoveToColorTemperature(device, endpoint_id, temp_in_mired, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - device:set_field(MOST_RECENT_TEMP, cmd.args.temperature, {persist = true}) - device:send(req) -end - -local function handle_valve_open(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local req = clusters.ValveConfigurationAndControl.server.commands.Open(device, endpoint_id) - device:send(req) -end - -local function handle_valve_close(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local req = clusters.ValveConfigurationAndControl.server.commands.Close(device, endpoint_id) - device:send(req) -end - -local function handle_set_level(driver, device, cmd) - local commands = clusters.ValveConfigurationAndControl.server.commands - local endpoint_id = device:component_to_endpoint(cmd.component) - local level = cmd.args.level - if not level then - return - elseif level == 0 then - device:send(commands.Close(device, endpoint_id)) - else - device:send(commands.Open(device, endpoint_id, nil, level)) - end -end - -local function set_fan_mode(driver, device, cmd) - local fan_mode_id - if cmd.args.fanMode == capabilities.fanMode.fanMode.low.NAME then - fan_mode_id = clusters.FanControl.attributes.FanMode.LOW - elseif cmd.args.fanMode == capabilities.fanMode.fanMode.medium.NAME then - fan_mode_id = clusters.FanControl.attributes.FanMode.MEDIUM - elseif cmd.args.fanMode == capabilities.fanMode.fanMode.high.NAME then - fan_mode_id = clusters.FanControl.attributes.FanMode.HIGH - elseif cmd.args.fanMode == capabilities.fanMode.fanMode.auto.NAME then - fan_mode_id = clusters.FanControl.attributes.FanMode.AUTO - else - fan_mode_id = clusters.FanControl.attributes.FanMode.OFF - end - if fan_mode_id then - local fan_ep = device:get_endpoints(clusters.FanControl.ID)[1] - device:send(clusters.FanControl.attributes.FanMode:write(device, fan_ep, fan_mode_id)) - end -end - -local function set_fan_speed_percent(driver, device, cmd) - local speed = math.floor(cmd.args.percent) - local fan_ep = device:get_endpoints(clusters.FanControl.ID)[1] - device:send(clusters.FanControl.attributes.PercentSetting:write(device, fan_ep, speed)) -end - --- Fallback handler for responses that dont have their own handler -local function matter_handler(driver, device, response_block) - log.info(string.format("Fallback handler for %s", response_block)) -end - -local function on_off_attr_handler(driver, device, ib, response) - if ib.data.value then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.on()) - else - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.off()) - end - if type(device.register_native_capability_attr_handler) == "function" then - device:register_native_capability_attr_handler("switch", "switch") - end -end - -local function level_attr_handler(driver, device, ib, response) - if ib.data.value ~= nil then - local level = math.floor((ib.data.value / 254.0 * 100) + 0.5) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switchLevel.level(level)) - if type(device.register_native_capability_attr_handler) == "function" then - device:register_native_capability_attr_handler("switchLevel", "level") - end - end -end - -local function hue_attr_handler(driver, device, ib, response) - if device:get_field(COLOR_MODE) == X_Y_COLOR_MODE or ib.data.value == nil then - return - end - local hue = math.floor((ib.data.value / 0xFE * 100) + 0.5) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.hue(hue)) -end -local function sat_attr_handler(driver, device, ib, response) - if device:get_field(COLOR_MODE) == X_Y_COLOR_MODE or ib.data.value == nil then - return - end - local sat = math.floor((ib.data.value / 0xFE * 100) + 0.5) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.saturation(sat)) -end - -local function temp_attr_handler(driver, device, ib, response) - local temp_in_mired = ib.data.value - if temp_in_mired == nil then - return - end - if (temp_in_mired < COLOR_TEMPERATURE_MIRED_MIN or temp_in_mired > COLOR_TEMPERATURE_MIRED_MAX) then - device.log.warn_with({hub_logs = true}, string.format("Device reported color temperature %d mired outside of sane range of %.2f-%.2f", temp_in_mired, COLOR_TEMPERATURE_MIRED_MIN, COLOR_TEMPERATURE_MIRED_MAX)) - return - end - local min_temp_mired = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_MIRED..COLOR_TEMP_MIN, ib.endpoint_id) - local max_temp_mired = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_MIRED..COLOR_TEMP_MAX, ib.endpoint_id) - - local temp = utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/temp_in_mired) - if min_temp_mired ~= nil and temp_in_mired <= min_temp_mired then - temp = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_KELVIN..COLOR_TEMP_MAX, ib.endpoint_id) - elseif max_temp_mired ~= nil and temp_in_mired >= max_temp_mired then - temp = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_KELVIN..COLOR_TEMP_MIN, ib.endpoint_id) - end - - local temp_device = device - if device:get_field(IS_PARENT_CHILD_DEVICE) == true then - temp_device = find_child(device, ib.endpoint_id) or device - end - local most_recent_temp = temp_device:get_field(MOST_RECENT_TEMP) - -- this is to avoid rounding errors from the round-trip conversion of Kelvin to mireds - if most_recent_temp ~= nil and - most_recent_temp <= utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/(temp_in_mired - 1)) and - most_recent_temp >= utils.round(MIRED_KELVIN_CONVERSION_CONSTANT/(temp_in_mired + 1)) then - temp = most_recent_temp - end - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorTemperature.colorTemperature(temp)) -end - -local mired_bounds_handler_factory = function(minOrMax) - return function(driver, device, ib, response) - local temp_in_mired = ib.data.value - if temp_in_mired == nil then - return - end - if (temp_in_mired < COLOR_TEMPERATURE_MIRED_MIN or temp_in_mired > COLOR_TEMPERATURE_MIRED_MAX) then - device.log.warn_with({hub_logs = true}, string.format("Device reported a color temperature %d mired outside of sane range of %.2f-%.2f", temp_in_mired, COLOR_TEMPERATURE_MIRED_MIN, COLOR_TEMPERATURE_MIRED_MAX)) - return - end - local temp_in_kelvin = mired_to_kelvin(temp_in_mired, minOrMax) - set_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_KELVIN..minOrMax, ib.endpoint_id, temp_in_kelvin) - -- the minimum color temp in kelvin corresponds to the maximum temp in mireds - if minOrMax == COLOR_TEMP_MIN then - set_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_MIRED..COLOR_TEMP_MAX, ib.endpoint_id, temp_in_mired) - else - set_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_MIRED..COLOR_TEMP_MIN, ib.endpoint_id, temp_in_mired) - end - local min = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_KELVIN..COLOR_TEMP_MIN, ib.endpoint_id) - local max = get_field_for_endpoint(device, COLOR_TEMP_BOUND_RECEIVED_KELVIN..COLOR_TEMP_MAX, ib.endpoint_id) - if min ~= nil and max ~= nil then - if min < max then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = min, maximum = max} })) - else - device.log.warn_with({hub_logs = true}, string.format("Device reported a min color temperature %d K that is not lower than the reported max color temperature %d K", min, max)) - end - end - end -end - -local level_bounds_handler_factory = function(minOrMax) - return function(driver, device, ib, response) - if ib.data.value == nil then - return - end - local lighting_endpoints = device:get_endpoints(clusters.LevelControl.ID, {feature_bitmap = clusters.LevelControl.FeatureMap.LIGHTING}) - local lighting_support = tbl_contains(lighting_endpoints, ib.endpoint_id) - -- If the lighting feature is supported then we should check if the reported level is at least 1. - if lighting_support and ib.data.value < SWITCH_LEVEL_LIGHTING_MIN then - device.log.warn_with({hub_logs = true}, string.format("Lighting device reported a switch level %d outside of supported capability range", ib.data.value)) - return - end - -- Convert level from given range of 0-254 to range of 0-100. - local level = utils.round(ib.data.value / 254.0 * 100) - -- If the device supports the lighting feature, the minimum capability level should be 1 so we do not send a 0 value for the level attribute - if lighting_support and level == 0 then - level = 1 - end - set_field_for_endpoint(device, LEVEL_BOUND_RECEIVED..minOrMax, ib.endpoint_id, level) - local min = get_field_for_endpoint(device, LEVEL_BOUND_RECEIVED..LEVEL_MIN, ib.endpoint_id) - local max = get_field_for_endpoint(device, LEVEL_BOUND_RECEIVED..LEVEL_MAX, ib.endpoint_id) - if min ~= nil and max ~= nil then - if min < max then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switchLevel.levelRange({ value = {minimum = min, maximum = max} })) - else - device.log.warn_with({hub_logs = true}, string.format("Device reported a min level value %d that is not lower than the reported max level value %d", min, max)) - end - set_field_for_endpoint(device, LEVEL_BOUND_RECEIVED..LEVEL_MAX, ib.endpoint_id, nil) - set_field_for_endpoint(device, LEVEL_BOUND_RECEIVED..LEVEL_MIN, ib.endpoint_id, nil) - end - end -end - -local color_utils = require "color_utils" - -local function x_attr_handler(driver, device, ib, response) - if device:get_field(COLOR_MODE) == HUE_SAT_COLOR_MODE then - return - end - local y = device:get_field(RECEIVED_Y) - --TODO it is likely that both x and y attributes are in the response (not guaranteed though) - -- if they are we can avoid setting fields on the device. - if y == nil then - device:set_field(RECEIVED_X, ib.data.value) - else - local x = ib.data.value - local h, s, _ = color_utils.safe_xy_to_hsv(x, y) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.hue(h)) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.saturation(s)) - device:set_field(RECEIVED_Y, nil) - end -end - -local function y_attr_handler(driver, device, ib, response) - if device:get_field(COLOR_MODE) == HUE_SAT_COLOR_MODE then - return - end - local x = device:get_field(RECEIVED_X) - if x == nil then - device:set_field(RECEIVED_Y, ib.data.value) - else - local y = ib.data.value - local h, s, _ = color_utils.safe_xy_to_hsv(x, y) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.hue(h)) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorControl.saturation(s)) - device:set_field(RECEIVED_X, nil) - end -end - -local function color_mode_attr_handler(driver, device, ib, response) - if ib.data.value == device:get_field(COLOR_MODE) or (ib.data.value ~= HUE_SAT_COLOR_MODE and ib.data.value ~= X_Y_COLOR_MODE) then - return - end - device:set_field(COLOR_MODE, ib.data.value) - local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) - if ib.data.value == HUE_SAT_COLOR_MODE then - req:merge(clusters.ColorControl.attributes.CurrentHue:read()) - req:merge(clusters.ColorControl.attributes.CurrentSaturation:read()) - elseif ib.data.value == X_Y_COLOR_MODE then - req:merge(clusters.ColorControl.attributes.CurrentX:read()) - req:merge(clusters.ColorControl.attributes.CurrentY:read()) - end - if #req.info_blocks > 0 then - device:send(req) - end -end - ---TODO setup configure handler to read this attribute. -local function color_cap_attr_handler(driver, device, ib, response) - if ib.data.value ~= nil then - if ib.data.value & 0x1 then - device:set_field(HUESAT_SUPPORT, true) - end - end -end - -local function illuminance_attr_handler(driver, device, ib, response) - local lux = math.floor(10 ^ ((ib.data.value - 1) / 10000)) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.illuminanceMeasurement.illuminance(lux)) -end - -local function occupancy_attr_handler(driver, device, ib, response) - device:emit_event(ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) -end - -local function cumul_energy_imported_handler(driver, device, ib, response) - if ib.data.elements.energy then - local watt_hour_value = ib.data.elements.energy.value / CONVERSION_CONST_MILLIWATT_TO_WATT - device:set_field(TOTAL_IMPORTED_ENERGY, watt_hour_value, {persist = true}) - if ib.endpoint_id ~= 0 then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.energyMeter.energy({ value = watt_hour_value, unit = "Wh" })) - else - -- when energy management is defined in the root endpoint(0), replace it with the first switch endpoint and process it. - device:emit_event_for_endpoint(device:get_field(ENERGY_MANAGEMENT_ENDPOINT), capabilities.energyMeter.energy({ value = watt_hour_value, unit = "Wh" })) - end - end -end - -local function per_energy_imported_handler(driver, device, ib, response) - if ib.data.elements.energy then - local watt_hour_value = ib.data.elements.energy.value / CONVERSION_CONST_MILLIWATT_TO_WATT - local latest_energy_report = device:get_field(TOTAL_IMPORTED_ENERGY) or 0 - local summed_energy_report = latest_energy_report + watt_hour_value - device:set_field(TOTAL_IMPORTED_ENERGY, summed_energy_report, {persist = true}) - device:emit_event(capabilities.energyMeter.energy({ value = summed_energy_report, unit = "Wh" })) - end -end - -local function energy_report_handler_factory(is_cumulative_report) - return function(driver, device, ib, response) - if not device:get_field(IMPORT_POLL_TIMER_SETTING_ATTEMPTED) then - set_poll_report_timer_and_schedule(device, is_cumulative_report) - end - if is_cumulative_report then - cumul_energy_imported_handler(driver, device, ib, response) - elseif device:get_field(CUMULATIVE_REPORTS_NOT_SUPPORTED) then - per_energy_imported_handler(driver, device, ib, response) - end - end -end - -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 - --- if the device distinguishes a long press event, it will always be a "held" --- there's also a "long release" event, but this event is required to come first -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 - -local function short_release_event_handler(driver, device, ib, response) - if not get_field_for_endpoint(device, SUPPORTS_MULTI_PRESS, ib.endpoint_id) then - if get_field_for_endpoint(device, EMULATE_HELD, ib.endpoint_id) then - emulate_held_event(device, ib.endpoint_id) - else - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.button.pushed({state_change = true})) - end - end -end - -local function active_power_handler(driver, device, ib, response) - if ib.data.value then - local watt_value = ib.data.value / CONVERSION_CONST_MILLIWATT_TO_WATT - if ib.endpoint_id ~= 0 then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.powerMeter.power({ value = watt_value, unit = "W"})) - else - -- when energy management is defined in the root endpoint(0), replace it with the first switch endpoint and process it. - device:emit_event_for_endpoint(device:get_field(ENERGY_MANAGEMENT_ENDPOINT), capabilities.powerMeter.power({ value = watt_value, unit = "W"})) - end - if type(device.register_native_capability_attr_handler) == "function" then - device:register_native_capability_attr_handler("powerMeter","power") - end - end -end - -local function valve_state_attr_handler(driver, device, ib, response) - if ib.data.value == 0 then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.valve.valve.closed()) - else - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.valve.valve.open()) - end -end - -local function valve_level_attr_handler(driver, device, ib, response) - if ib.data.value then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.level.level(ib.data.value)) - end -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 function battery_percent_remaining_attr_handler(driver, device, ib, response) - if ib.data.value then - device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) - end -end - -local function battery_charge_level_attr_handler(driver, device, ib, response) - if ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.OK then - device:emit_event(capabilities.batteryLevel.battery.normal()) - elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.WARNING then - device:emit_event(capabilities.batteryLevel.battery.warning()) - elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.CRITICAL then - device:emit_event(capabilities.batteryLevel.battery.critical()) - end -end - -local function power_source_attribute_list_handler(driver, device, ib, response) - local profile_name = "" - - local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) - for _, attr in ipairs(ib.data.elements) do - -- Re-profile the device if BatPercentRemaining (Attribute ID 0x0C) or - -- BatChargeLevel (Attribute ID 0x0E) is present. - if attr.value == 0x0C then - profile_name = "button-battery" - break - elseif attr.value == 0x0E then - profile_name = "button-batteryLevel" - break - end - end - if profile_name ~= "" then - if #button_eps > 1 then - profile_name = string.format("%d-", #button_eps) .. profile_name - end - - if device.manufacturer_info.vendor_id == AQARA_MANUFACTURER_ID and - device.manufacturer_info.product_id == AQARA_CLIMATE_SENSOR_W100_ID then - profile_name = profile_name .. "-temperature-humidity" - end - device:try_update_metadata({ profile = profile_name }) - end -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 info_changed(driver, device, event, args) - if device.profile.id ~= args.old_st_store.profile.id then device:subscribe() - local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) - if #button_eps > 0 and device.network_type == device_lib.NETWORK_TYPE_MATTER then - configure_buttons(device) - end - end -end - -local function device_added(driver, device) - -- refresh child devices to get an initial attribute state for OnOff in case child device - -- was created after the initial subscription report - if device.network_type == device_lib.NETWORK_TYPE_CHILD then - local req = clusters.OnOff.attributes.OnOff:read(device) - device:send(req) - end - - -- call device init in case init is not called after added due to device caching - device_init(driver, device) -end - -local function temperature_attr_handler(driver, device, ib, response) - local measured_value = ib.data.value - if measured_value ~= nil then - local temp = measured_value / 100.0 - local unit = "C" - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperature({value = temp, unit = unit})) end end -local temp_attr_handler_factory = function(minOrMax) - return function(driver, device, ib, response) - if ib.data.value == nil then - return - end - local temp = ib.data.value / 100.0 - local unit = "C" - set_field_for_endpoint(device, TEMP_BOUND_RECEIVED..minOrMax, ib.endpoint_id, temp) - local min = get_field_for_endpoint(device, TEMP_BOUND_RECEIVED..TEMP_MIN, ib.endpoint_id) - local max = get_field_for_endpoint(device, TEMP_BOUND_RECEIVED..TEMP_MAX, ib.endpoint_id) - if min ~= nil and max ~= nil then - if min < max then - -- Only emit the capability for RPC version >= 5 (unit conversion for - -- temperature range capability is only supported for RPC >= 5) - if version.rpc >= 5 then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = unit })) - end - set_field_for_endpoint(device, TEMP_BOUND_RECEIVED..TEMP_MIN, ib.endpoint_id, nil) - set_field_for_endpoint(device, TEMP_BOUND_RECEIVED..TEMP_MAX, ib.endpoint_id, nil) - else - device.log.warn_with({hub_logs = true}, string.format("Device reported a min temperature %d that is not lower than the reported max temperature %d", min, max)) - end - end - end -end - -local function humidity_attr_handler(driver, device, ib, response) - local measured_value = ib.data.value - if measured_value ~= nil then - local humidity = utils.round(measured_value / 100.0) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.relativeHumidityMeasurement.humidity(humidity)) - end -end - -local function fan_mode_handler(driver, device, ib, response) - if ib.data.value == clusters.FanControl.attributes.FanMode.OFF then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("off")) - elseif ib.data.value == clusters.FanControl.attributes.FanMode.LOW then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("low")) - elseif ib.data.value == clusters.FanControl.attributes.FanMode.MEDIUM then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("medium")) - elseif ib.data.value == clusters.FanControl.attributes.FanMode.HIGH then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("high")) - else - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("auto")) - end -end - -local function fan_mode_sequence_handler(driver, device, ib, response) - local supportedFanModes - if ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH then - supportedFanModes = { - capabilities.fanMode.fanMode.off.NAME, - capabilities.fanMode.fanMode.low.NAME, - capabilities.fanMode.fanMode.medium.NAME, - capabilities.fanMode.fanMode.high.NAME - } - elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH then - supportedFanModes = { - capabilities.fanMode.fanMode.off.NAME, - capabilities.fanMode.fanMode.low.NAME, - capabilities.fanMode.fanMode.high.NAME - } - elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH_AUTO then - supportedFanModes = { - capabilities.fanMode.fanMode.off.NAME, - capabilities.fanMode.fanMode.low.NAME, - capabilities.fanMode.fanMode.medium.NAME, - capabilities.fanMode.fanMode.high.NAME, - capabilities.fanMode.fanMode.auto.NAME - } - elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH_AUTO then - supportedFanModes = { - capabilities.fanMode.fanMode.off.NAME, - capabilities.fanMode.fanMode.low.NAME, - capabilities.fanMode.fanMode.high.NAME, - capabilities.fanMode.fanMode.auto.NAME - } - elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_ON_AUTO then - supportedFanModes = { - capabilities.fanMode.fanMode.off.NAME, - capabilities.fanMode.fanMode.high.NAME, - capabilities.fanMode.fanMode.auto.NAME - } - else - supportedFanModes = { - capabilities.fanMode.fanMode.off.NAME, - capabilities.fanMode.fanMode.high.NAME - } - end - local event = capabilities.fanMode.supportedFanModes(supportedFanModes, {visibility = {displayed = false}}) - device:emit_event_for_endpoint(ib.endpoint_id, event) -end - -local function fan_speed_percent_attr_handler(driver, device, ib, response) - if ib.data.value == nil or ib.data.value < 0 or ib.data.value > 100 then - return - end - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanSpeedPercent.percent(ib.data.value)) -end - local matter_driver_template = { lifecycle_handlers = { - init = device_init, - added = device_added, - removed = device_removed, - infoChanged = info_changed, - doConfigure = do_configure, - driverSwitched = driver_switched + added = SwitchLifecycleHandlers.device_added, + doConfigure = SwitchLifecycleHandlers.do_configure, + driverSwitched = SwitchLifecycleHandlers.driver_switched, + infoChanged = SwitchLifecycleHandlers.info_changed, + init = SwitchLifecycleHandlers.device_init, + removed = SwitchLifecycleHandlers.device_removed, }, matter_handlers = { attr = { - [clusters.OnOff.ID] = { - [clusters.OnOff.attributes.OnOff.ID] = on_off_attr_handler, + [clusters.ColorControl.ID] = { + [clusters.ColorControl.attributes.ColorCapabilities.ID] = attribute_handlers.color_capabilities_handler, + [clusters.ColorControl.attributes.ColorMode.ID] = attribute_handlers.color_mode_handler, + [clusters.ColorControl.attributes.ColorTemperatureMireds.ID] = attribute_handlers.color_temperature_mireds_handler, + [clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds.ID] = attribute_handlers.color_temp_physical_mireds_bounds_factory(fields.COLOR_TEMP_MIN), -- max mireds = min kelvin + [clusters.ColorControl.attributes.ColorTempPhysicalMinMireds.ID] = attribute_handlers.color_temp_physical_mireds_bounds_factory(fields.COLOR_TEMP_MAX), -- min mireds = max kelvin + [clusters.ColorControl.attributes.CurrentHue.ID] = attribute_handlers.current_hue_handler, + [clusters.ColorControl.attributes.CurrentSaturation.ID] = attribute_handlers.current_saturation_handler, + [clusters.ColorControl.attributes.CurrentX.ID] = attribute_handlers.current_x_handler, + [clusters.ColorControl.attributes.CurrentY.ID] = attribute_handlers.current_y_handler, }, - [clusters.LevelControl.ID] = { - [clusters.LevelControl.attributes.CurrentLevel.ID] = level_attr_handler, - [clusters.LevelControl.attributes.MaxLevel.ID] = level_bounds_handler_factory(LEVEL_MAX), - [clusters.LevelControl.attributes.MinLevel.ID] = level_bounds_handler_factory(LEVEL_MIN), + [clusters.ElectricalEnergyMeasurement.ID] = { + [clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported.ID] = attribute_handlers.energy_imported_factory(true), + [clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported.ID] = attribute_handlers.energy_imported_factory(false), }, - [clusters.ColorControl.ID] = { - [clusters.ColorControl.attributes.CurrentHue.ID] = hue_attr_handler, - [clusters.ColorControl.attributes.CurrentSaturation.ID] = sat_attr_handler, - [clusters.ColorControl.attributes.ColorTemperatureMireds.ID] = temp_attr_handler, - [clusters.ColorControl.attributes.CurrentX.ID] = x_attr_handler, - [clusters.ColorControl.attributes.CurrentY.ID] = y_attr_handler, - [clusters.ColorControl.attributes.ColorMode.ID] = color_mode_attr_handler, - [clusters.ColorControl.attributes.ColorCapabilities.ID] = color_cap_attr_handler, - [clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds.ID] = mired_bounds_handler_factory(COLOR_TEMP_MIN), -- max mireds = min kelvin - [clusters.ColorControl.attributes.ColorTempPhysicalMinMireds.ID] = mired_bounds_handler_factory(COLOR_TEMP_MAX), -- min mireds = max kelvin + [clusters.ElectricalPowerMeasurement.ID] = { + [clusters.ElectricalPowerMeasurement.attributes.ActivePower.ID] = attribute_handlers.active_power_handler, }, - [clusters.IlluminanceMeasurement.ID] = { - [clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID] = illuminance_attr_handler + [clusters.FanControl.ID] = { + [clusters.FanControl.attributes.FanMode.ID] = attribute_handlers.fan_mode_handler, + [clusters.FanControl.attributes.FanModeSequence.ID] = attribute_handlers.fan_mode_sequence_handler, + [clusters.FanControl.attributes.PercentCurrent.ID] = attribute_handlers.percent_current_handler }, - [clusters.OccupancySensing.ID] = { - [clusters.OccupancySensing.attributes.Occupancy.ID] = occupancy_attr_handler, + [clusters.IlluminanceMeasurement.ID] = { + [clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.illuminance_measured_value_handler }, - [clusters.ElectricalPowerMeasurement.ID] = { - [clusters.ElectricalPowerMeasurement.attributes.ActivePower.ID] = active_power_handler, + [clusters.LevelControl.ID] = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = attribute_handlers.level_control_current_level_handler, + [clusters.LevelControl.attributes.MaxLevel.ID] = attribute_handlers.level_bounds_handler_factory(fields.LEVEL_MAX), + [clusters.LevelControl.attributes.MinLevel.ID] = attribute_handlers.level_bounds_handler_factory(fields.LEVEL_MIN), }, - [clusters.ElectricalEnergyMeasurement.ID] = { - [clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported.ID] = energy_report_handler_factory(true), - [clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported.ID] = energy_report_handler_factory(false), + [clusters.OccupancySensing.ID] = { + [clusters.OccupancySensing.attributes.Occupancy.ID] = attribute_handlers.occupancy_handler, }, - [clusters.ValveConfigurationAndControl.ID] = { - [clusters.ValveConfigurationAndControl.attributes.CurrentState.ID] = valve_state_attr_handler, - [clusters.ValveConfigurationAndControl.attributes.CurrentLevel.ID] = valve_level_attr_handler + [clusters.OnOff.ID] = { + [clusters.OnOff.attributes.OnOff.ID] = attribute_handlers.on_off_attr_handler, }, [clusters.PowerSource.ID] = { - [clusters.PowerSource.attributes.AttributeList.ID] = power_source_attribute_list_handler, - [clusters.PowerSource.attributes.BatChargeLevel.ID] = battery_charge_level_attr_handler, - [clusters.PowerSource.attributes.BatPercentRemaining.ID] = battery_percent_remaining_attr_handler, - }, - [clusters.Switch.ID] = { - [clusters.Switch.attributes.MultiPressMax.ID] = max_press_handler + [clusters.PowerSource.attributes.AttributeList.ID] = attribute_handlers.power_source_attribute_list_handler, + [clusters.PowerSource.attributes.BatChargeLevel.ID] = attribute_handlers.bat_charge_level_handler, + [clusters.PowerSource.attributes.BatPercentRemaining.ID] = attribute_handlers.bat_percent_remaining_handler, }, [clusters.RelativeHumidityMeasurement.ID] = { - [clusters.RelativeHumidityMeasurement.attributes.MeasuredValue.ID] = humidity_attr_handler + [clusters.RelativeHumidityMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.relative_humidity_measured_value_handler + }, + [clusters.Switch.ID] = { + [clusters.Switch.attributes.MultiPressMax.ID] = attribute_handlers.multi_press_max_handler }, [clusters.TemperatureMeasurement.ID] = { - [clusters.TemperatureMeasurement.attributes.MeasuredValue.ID] = temperature_attr_handler, - [clusters.TemperatureMeasurement.attributes.MinMeasuredValue.ID] = temp_attr_handler_factory(TEMP_MIN), - [clusters.TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = temp_attr_handler_factory(TEMP_MAX), + [clusters.TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = attribute_handlers.temperature_measured_value_bounds_factory(fields.TEMP_MAX), + [clusters.TemperatureMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.temperature_measured_value_handler, + [clusters.TemperatureMeasurement.attributes.MinMeasuredValue.ID] = attribute_handlers.temperature_measured_value_bounds_factory(fields.TEMP_MIN), + }, + [clusters.ValveConfigurationAndControl.ID] = { + [clusters.ValveConfigurationAndControl.attributes.CurrentLevel.ID] = attribute_handlers.valve_configuration_current_level_handler, + [clusters.ValveConfigurationAndControl.attributes.CurrentState.ID] = attribute_handlers.valve_configuration_current_state_handler, }, - [clusters.FanControl.ID] = { - [clusters.FanControl.attributes.FanModeSequence.ID] = fan_mode_sequence_handler, - [clusters.FanControl.attributes.FanMode.ID] = fan_mode_handler, - [clusters.FanControl.attributes.PercentCurrent.ID] = fan_speed_percent_attr_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.ShortRelease.ID] = short_release_event_handler, - [clusters.Switch.events.MultiPressComplete.ID] = multi_press_complete_event_handler + [clusters.Switch.events.InitialPress.ID] = event_handlers.initial_press_handler, + [clusters.Switch.events.LongPress.ID] = event_handlers.long_press_handler, + [clusters.Switch.events.MultiPressComplete.ID] = event_handlers.multi_press_complete_handler, + [clusters.Switch.events.ShortRelease.ID] = event_handlers.short_release_handler, } }, - fallback = matter_handler, + fallback = switch_utils.matter_handler, }, subscribed_attributes = { - [capabilities.switch.ID] = { - clusters.OnOff.attributes.OnOff + [capabilities.battery.ID] = { + clusters.PowerSource.attributes.BatPercentRemaining, }, - [capabilities.switchLevel.ID] = { - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, + [capabilities.batteryLevel.ID] = { + clusters.PowerSource.attributes.BatChargeLevel, }, [capabilities.colorControl.ID] = { clusters.ColorControl.attributes.ColorMode, @@ -1542,27 +215,28 @@ local matter_driver_template = { clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, }, + [capabilities.energyMeter.ID] = { + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported + }, + [capabilities.fanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, [capabilities.illuminanceMeasurement.ID] = { clusters.IlluminanceMeasurement.attributes.MeasuredValue }, [capabilities.motionSensor.ID] = { clusters.OccupancySensing.attributes.Occupancy }, - [capabilities.valve.ID] = { - clusters.ValveConfigurationAndControl.attributes.CurrentState - }, [capabilities.level.ID] = { clusters.ValveConfigurationAndControl.attributes.CurrentLevel }, - [capabilities.battery.ID] = { - clusters.PowerSource.attributes.BatPercentRemaining, - }, - [capabilities.batteryLevel.ID] = { - clusters.PowerSource.attributes.BatChargeLevel, - }, - [capabilities.energyMeter.ID] = { - clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, - clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff }, [capabilities.powerMeter.ID] = { clusters.ElectricalPowerMeasurement.attributes.ActivePower @@ -1570,18 +244,19 @@ local matter_driver_template = { [capabilities.relativeHumidityMeasurement.ID] = { clusters.RelativeHumidityMeasurement.attributes.MeasuredValue }, + [capabilities.switchLevel.ID] = { + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + }, [capabilities.temperatureMeasurement.ID] = { clusters.TemperatureMeasurement.attributes.MeasuredValue, clusters.TemperatureMeasurement.attributes.MinMeasuredValue, clusters.TemperatureMeasurement.attributes.MaxMeasuredValue }, - [capabilities.fanMode.ID] = { - clusters.FanControl.attributes.FanModeSequence, - clusters.FanControl.attributes.FanMode + [capabilities.valve.ID] = { + clusters.ValveConfigurationAndControl.attributes.CurrentState }, - [capabilities.fanSpeedPercent.ID] = { - clusters.FanControl.attributes.PercentCurrent - } }, subscribed_events = { [capabilities.button.ID] = { @@ -1592,71 +267,43 @@ local matter_driver_template = { }, }, capability_handlers = { - [capabilities.switch.ID] = { - [capabilities.switch.commands.on.NAME] = handle_switch_on, - [capabilities.switch.commands.off.NAME] = handle_switch_off, - }, - [capabilities.switchLevel.ID] = { - [capabilities.switchLevel.commands.setLevel.NAME] = handle_set_switch_level - }, [capabilities.colorControl.ID] = { - [capabilities.colorControl.commands.setColor.NAME] = handle_set_color, - [capabilities.colorControl.commands.setHue.NAME] = handle_set_hue, - [capabilities.colorControl.commands.setSaturation.NAME] = handle_set_saturation, + [capabilities.colorControl.commands.setColor.NAME] = capability_handlers.handle_set_color, + [capabilities.colorControl.commands.setHue.NAME] = capability_handlers.handle_set_hue, + [capabilities.colorControl.commands.setSaturation.NAME] = capability_handlers.handle_set_saturation, }, [capabilities.colorTemperature.ID] = { - [capabilities.colorTemperature.commands.setColorTemperature.NAME] = handle_set_color_temperature, + [capabilities.colorTemperature.commands.setColorTemperature.NAME] = capability_handlers.handle_set_color_temperature, }, - [capabilities.valve.ID] = { - [capabilities.valve.commands.open.NAME] = handle_valve_open, - [capabilities.valve.commands.close.NAME] = handle_valve_close + [capabilities.fanMode.ID] = { + [capabilities.fanMode.commands.setFanMode.NAME] = capability_handlers.handle_set_fan_mode + }, + [capabilities.fanSpeedPercent.ID] = { + [capabilities.fanSpeedPercent.commands.setPercent.NAME] = capability_handlers.handle_fan_speed_set_percent }, [capabilities.level.ID] = { - [capabilities.level.commands.setLevel.NAME] = handle_set_level + [capabilities.level.commands.setLevel.NAME] = capability_handlers.handle_set_level }, - [capabilities.fanMode.ID] = { - [capabilities.fanMode.commands.setFanMode.NAME] = set_fan_mode + [capabilities.switch.ID] = { + [capabilities.switch.commands.off.NAME] = capability_handlers.handle_switch_off, + [capabilities.switch.commands.on.NAME] = capability_handlers.handle_switch_on, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = capability_handlers.handle_switch_set_level + }, + [capabilities.valve.ID] = { + [capabilities.valve.commands.close.NAME] = capability_handlers.handle_valve_close, + [capabilities.valve.commands.open.NAME] = capability_handlers.handle_valve_open, }, - [capabilities.fanSpeedPercent.ID] = { - [capabilities.fanSpeedPercent.commands.setPercent.NAME] = set_fan_speed_percent - } - }, - supported_capabilities = { - capabilities.switch, - capabilities.switchLevel, - capabilities.colorControl, - capabilities.colorTemperature, - capabilities.level, - capabilities.motionSensor, - capabilities.illuminanceMeasurement, - capabilities.powerMeter, - capabilities.energyMeter, - capabilities.powerConsumptionReport, - capabilities.valve, - capabilities.button, - capabilities.battery, - capabilities.batteryLevel, - capabilities.temperatureMeasurement, - capabilities.relativeHumidityMeasurement, - capabilities.fanMode, - capabilities.fanSpeedPercent }, + supported_capabilities = fields.supported_capabilities, sub_drivers = { - require("eve-energy"), - require("aqara-cube"), - require("third-reality-mk1") + require("sub_drivers.aqara_cube"), + require("sub_drivers.eve_energy"), + require("sub_drivers.third_reality_mk1") } } -function detect_matter_thing(device) - for _, capability in ipairs(matter_driver_template.supported_capabilities) do - if device:supports_capability(capability) then - return false - end - end - return device:supports_capability(capabilities.refresh) -end - local matter_driver = MatterDriver("matter-switch", matter_driver_template) log.info_with({hub_logs=true}, string.format("Starting %s driver, with dispatcher: %s", matter_driver.NAME, matter_driver.matter_dispatcher)) matter_driver:run() diff --git a/drivers/SmartThings/matter-switch/src/aqara-cube/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/aqara_cube/init.lua similarity index 100% rename from drivers/SmartThings/matter-switch/src/aqara-cube/init.lua rename to drivers/SmartThings/matter-switch/src/sub_drivers/aqara_cube/init.lua diff --git a/drivers/SmartThings/matter-switch/src/eve-energy/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/init.lua similarity index 100% rename from drivers/SmartThings/matter-switch/src/eve-energy/init.lua rename to drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/init.lua diff --git a/drivers/SmartThings/matter-switch/src/third-reality-mk1/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/init.lua similarity index 100% rename from drivers/SmartThings/matter-switch/src/third-reality-mk1/init.lua rename to drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/init.lua diff --git a/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor.lua b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor.lua index d9759b7add..30e3510e77 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor.lua @@ -19,8 +19,8 @@ local t_utils = require "integration_test.utils" local version = require "version" if version.api < 11 then - clusters.ElectricalEnergyMeasurement = require "ElectricalEnergyMeasurement" - clusters.ElectricalPowerMeasurement = require "ElectricalPowerMeasurement" + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" end local mock_device = test.mock_device.build_test_matter_device({ diff --git a/drivers/SmartThings/matter-switch/src/color_utils.lua b/drivers/SmartThings/matter-switch/src/utils/color_utils.lua similarity index 71% rename from drivers/SmartThings/matter-switch/src/color_utils.lua rename to drivers/SmartThings/matter-switch/src/utils/color_utils.lua index ce73b1eecd..410920258a 100644 --- a/drivers/SmartThings/matter-switch/src/color_utils.lua +++ b/drivers/SmartThings/matter-switch/src/utils/color_utils.lua @@ -1,3 +1,17 @@ +-- 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. + --TODO remove the usage of these color utils once 0.48.x has been distributed -- to all hubs. local color_utils = {} @@ -46,9 +60,9 @@ end --- Convert from x/y/Y to Hue/Saturation --- If every value is missing then [x, y, Y] = [0, 0, 1] --- ---- @param x number red in range [0x0000, 0xFFFF] ---- @param y number green in range [0x0000, 0xFFFF] ---- @param Y number blue in range [0x0000, 0xFFFF] +--- @param x number|nil red in range [0x0000, 0xFFFF] +--- @param y number|nil green in range [0x0000, 0xFFFF] +--- @param Y number|nil blue in range [0x0000, 0xFFFF] --- @returns number, number equivalent hue, saturation, level each in range [0,100]% color_utils.safe_xy_to_hsv = function(x, y, Y) local safe_x = x ~= nil and x / 65536 or 0 diff --git a/drivers/SmartThings/matter-switch/src/utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/utils/device_configuration.lua new file mode 100644 index 0000000000..feb21ac193 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/utils/device_configuration.lua @@ -0,0 +1,273 @@ +-- 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 embedded_cluster_utils = require "utils.embedded_cluster_utils" +local version = require "version" + +local fields = require "utils.switch_fields" +local switch_utils = require "utils.switch_utils" + +-- Include driver-side definitions when lua libs api version is < 11 +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" + clusters.ValveConfigurationAndControl = require "embedded_clusters.ValveConfigurationAndControl" +end + +local DeviceConfiguration = {} +local SwitchDeviceConfiguration = {} +local ButtonDeviceConfiguration = {} + +function SwitchDeviceConfiguration.assign_child_profile(device, child_ep) + local profile + + for _, ep in ipairs(device.endpoints) do + if ep.endpoint_id == child_ep then + -- Some devices report multiple device types which are a subset of + -- a superset device type (For example, Dimmable Light is a superset of + -- On/Off light). This mostly applies to the four light types, so we will want + -- to match the profile for the superset device type. This can be done by + -- matching to the device type with the highest ID + local id = 0 + for _, dt in ipairs(ep.device_types) do + id = math.max(id, dt.device_type_id) + end + profile = fields.device_type_profile_map[id] + break + end + end + + -- Check if device has an overridden child profile that differs from the profile that would match + -- the child's device type for the following two cases: + -- 1. To add Electrical Sensor only to the first EDGE_CHILD (light-power-energy-powerConsumption) + -- for the Aqara Light Switch H2. The profile of the second EDGE_CHILD for this device is + -- determined in the "for" loop above (e.g., light-binary) + -- 2. The selected profile for the child device matches the initial profile defined in + -- child_device_profile_overrides + for id, vendor in pairs(fields.child_device_profile_overrides_per_vendor_id) do + for _, fingerprint in ipairs(vendor) do + if device.manufacturer_info.product_id == fingerprint.product_id and + ((device.manufacturer_info.vendor_id == fields.AQARA_MANUFACTURER_ID and child_ep == 1) or profile == fingerprint.initial_profile) then + return fingerprint.target_profile + end + end + end + + -- default to "switch-binary" if no profile is found + return profile or "switch-binary" +end + +function SwitchDeviceConfiguration.create_child_switch_devices(driver, device, main_endpoint) + local num_switch_server_eps = 0 + local parent_child_device = false + local switch_eps = device:get_endpoints(clusters.OnOff.ID) + table.sort(switch_eps) + for idx, ep in ipairs(switch_eps) do + if device:supports_server_cluster(clusters.OnOff.ID, ep) then + num_switch_server_eps = num_switch_server_eps + 1 + if ep ~= main_endpoint then -- don't create a child device that maps to the main endpoint + local name = string.format("%s %d", device.label, num_switch_server_eps) + local child_profile = SwitchDeviceConfiguration.assign_child_profile(device, ep) + driver:try_create_device( + { + type = "EDGE_CHILD", + label = name, + profile = child_profile, + parent_device_id = device.id, + parent_assigned_child_key = string.format("%d", ep), + vendor_provided_label = name + } + ) + parent_child_device = true + if idx == 1 and string.find(child_profile, "energy") then + -- when energy management is defined in the root endpoint(0), replace it with the first switch endpoint and process it. + device:set_field(fields.ENERGY_MANAGEMENT_ENDPOINT, ep, {persist = true}) + end + end + end + end + + -- If the device is a parent child device, set the find_child function on init. This is persisted because initialize_buttons_and_switches + -- is only run once, but find_child function should be set on each driver init. + if parent_child_device then + device:set_field(fields.IS_PARENT_CHILD_DEVICE, true, {persist = true}) + end + + -- this is needed in initialize_buttons_and_switches + return num_switch_server_eps +end + +function SwitchDeviceConfiguration.update_devices_with_onOff_server_clusters(device, main_endpoint) + local cluster_id = 0 + for _, ep in ipairs(device.endpoints) do + -- main_endpoint only supports server cluster by definition of get_endpoints() + if main_endpoint == ep.endpoint_id then + for _, dt in ipairs(ep.device_types) do + -- no device type that is not in the switch subset should be considered. + if (fields.ON_OFF_SWITCH_ID <= dt.device_type_id and dt.device_type_id <= fields.ON_OFF_COLOR_DIMMER_SWITCH_ID) then + cluster_id = math.max(cluster_id, dt.device_type_id) + end + end + break + end + end + + if fields.device_type_profile_map[cluster_id] then + device:try_update_metadata({profile = fields.device_type_profile_map[cluster_id]}) + end +end + +function ButtonDeviceConfiguration.update_button_profile(device, main_endpoint, num_button_eps) + local profile_name = string.gsub(num_button_eps .. "-button", "1%-", "") -- remove the "1-" in a device with 1 button ep + if switch_utils.device_type_supports_button_switch_combination(device, main_endpoint) then + profile_name = "light-level-" .. profile_name + end + local battery_supported = #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) > 0 + if battery_supported then -- battery profiles are configured later, in power_source_attribute_list_handler + device:send(clusters.PowerSource.attributes.AttributeList:read(device)) + else + device:try_update_metadata({profile = profile_name}) + end +end + +function ButtonDeviceConfiguration.update_button_component_map(device, main_endpoint, button_eps) + -- create component mapping on the main profile button endpoints + table.sort(button_eps) + local component_map = {} + component_map["main"] = main_endpoint + for component_num, ep in ipairs(button_eps) do + if ep ~= main_endpoint then + local button_component = "button" + if #button_eps > 1 then + button_component = button_component .. component_num + end + component_map[button_component] = ep + end + end + device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) +end + + +function ButtonDeviceConfiguration.configure_buttons(device) + local ms_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + local msr_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_RELEASE}) + local msl_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS}) + local msm_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS}) + + for _, ep in ipairs(ms_eps) do + if device.profile.components[switch_utils.endpoint_to_component(device, ep)] then + device.log.info_with({hub_logs=true}, string.format("Configuring Supported Values for generic switch endpoint %d", ep)) + local supportedButtonValues_event + -- this ordering is important, since MSM & MSL devices must also support MSR + if switch_utils.tbl_contains(msm_eps, ep) then + supportedButtonValues_event = nil -- deferred to the max press handler + device:send(clusters.Switch.attributes.MultiPressMax:read(device, ep)) + switch_utils.set_field_for_endpoint(device, fields.SUPPORTS_MULTI_PRESS, ep, true, {persist = true}) + elseif switch_utils.tbl_contains(msl_eps, ep) then + supportedButtonValues_event = capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}) + elseif switch_utils.tbl_contains(msr_eps, ep) then + supportedButtonValues_event = capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}) + switch_utils.set_field_for_endpoint(device, fields.EMULATE_HELD, ep, true, {persist = true}) + else -- this switch endpoint only supports momentary switch, no release events + supportedButtonValues_event = capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}) + switch_utils.set_field_for_endpoint(device, fields.INITIAL_PRESS_ONLY, ep, true, {persist = true}) + end + + if supportedButtonValues_event then + device:emit_event_for_endpoint(ep, supportedButtonValues_event) + end + device:emit_event_for_endpoint(ep, capabilities.button.button.pushed({state_change = false})) + else + device.log.info_with({hub_logs=true}, string.format("Component not found for generic switch endpoint %d. Skipping Supported Value configuration", ep)) + end + end +end + + +-- [[ PROFILE MATCHING AND CONFIGURATIONS ]] -- + +function DeviceConfiguration.initialize_buttons_and_switches(driver, device, main_endpoint) + local profile_found = false + local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + if switch_utils.tbl_contains(fields.STATIC_BUTTON_PROFILE_SUPPORTED, #button_eps) then + ButtonDeviceConfiguration.update_button_profile(device, main_endpoint, #button_eps) + -- All button endpoints found will be added as additional components in the profile containing the main_endpoint. + -- The resulting endpoint to component map is saved in the COMPONENT_TO_ENDPOINT_MAP field + ButtonDeviceConfiguration.update_button_component_map(device, main_endpoint, button_eps) + ButtonDeviceConfiguration.configure_buttons(device) + profile_found = true + end + + -- Without support for bindings, only clusters that are implemented as server are counted. This count is handled + -- while building switch child profiles + local num_switch_server_eps = SwitchDeviceConfiguration.create_child_switch_devices(driver, device, main_endpoint) + + -- We do not support the Light Switch device types because they require OnOff to be implemented as 'client', which requires us to support bindings. + -- However, this workaround profiles devices that claim to be Light Switches, but that break spec and implement OnOff as 'server'. + -- Note: since their device type isn't supported, these devices join as a matter-thing. + if num_switch_server_eps > 0 and switch_utils.detect_matter_thing(device) then + SwitchDeviceConfiguration.update_devices_with_onOff_server_clusters(device, main_endpoint) + profile_found = true + end + return profile_found +end + +function DeviceConfiguration.match_profile(driver, device) + local main_endpoint = switch_utils.find_default_endpoint(device) + -- initialize the main device card with buttons if applicable, and create child devices as needed for multi-switch devices. + local profile_found = DeviceConfiguration.initialize_buttons_and_switches(driver, device, main_endpoint) + if device:get_field(fields.IS_PARENT_CHILD_DEVICE) then + device:set_find_child(switch_utils.find_child) + end + if profile_found then + return + end + + local fan_eps = device:get_endpoints(clusters.FanControl.ID) + local level_eps = device:get_endpoints(clusters.LevelControl.ID) + local energy_eps = embedded_cluster_utils.get_endpoints(device, clusters.ElectricalEnergyMeasurement.ID) + local power_eps = embedded_cluster_utils.get_endpoints(device, clusters.ElectricalPowerMeasurement.ID) + local valve_eps = embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID) + local profile_name = nil + local level_support = "" + if #level_eps > 0 then + level_support = "-level" + end + if #energy_eps > 0 and #power_eps > 0 then + profile_name = "plug" .. level_support .. "-power-energy-powerConsumption" + elseif #energy_eps > 0 then + profile_name = "plug" .. level_support .. "-energy-powerConsumption" + elseif #power_eps > 0 then + profile_name = "plug" .. level_support .. "-power" + elseif #valve_eps > 0 then + profile_name = "water-valve" + if #embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID, + {feature_bitmap = clusters.ValveConfigurationAndControl.types.Feature.LEVEL}) > 0 then + profile_name = profile_name .. "-level" + end + elseif #fan_eps > 0 then + profile_name = "light-color-level-fan" + end + if profile_name then + device:try_update_metadata({ profile = profile_name }) + end +end + +return { + DeviceCfg = DeviceConfiguration, + SwitchCfg = SwitchDeviceConfiguration, + ButtonCfg = ButtonDeviceConfiguration +} diff --git a/drivers/SmartThings/matter-switch/src/embedded-cluster-utils.lua b/drivers/SmartThings/matter-switch/src/utils/embedded_cluster_utils.lua similarity index 69% rename from drivers/SmartThings/matter-switch/src/embedded-cluster-utils.lua rename to drivers/SmartThings/matter-switch/src/utils/embedded_cluster_utils.lua index 66db6097c7..29af9feebd 100644 --- a/drivers/SmartThings/matter-switch/src/embedded-cluster-utils.lua +++ b/drivers/SmartThings/matter-switch/src/utils/embedded_cluster_utils.lua @@ -1,12 +1,26 @@ +-- 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 clusters = require "st.matter.clusters" local utils = require "st.utils" -- Include driver-side definitions when lua libs api version is < 11 local version = require "version" if version.api < 11 then - clusters.ElectricalEnergyMeasurement = require "ElectricalEnergyMeasurement" - clusters.ElectricalPowerMeasurement = require "ElectricalPowerMeasurement" - clusters.ValveConfigurationAndControl = require "ValveConfigurationAndControl" + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" + clusters.ValveConfigurationAndControl = require "embedded_clusters.ValveConfigurationAndControl" end local embedded_cluster_utils = {} diff --git a/drivers/SmartThings/matter-switch/src/utils/switch_fields.lua b/drivers/SmartThings/matter-switch/src/utils/switch_fields.lua new file mode 100644 index 0000000000..411c4c2104 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/utils/switch_fields.lua @@ -0,0 +1,266 @@ +-- 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 clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" +local version = require "version" + +-- Include driver-side definitions when lua libs api version is < 11 +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" +end + +local SwitchFields = {} + +SwitchFields.HUE_SAT_COLOR_MODE = clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION +SwitchFields.X_Y_COLOR_MODE = clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY + +SwitchFields.MOST_RECENT_TEMP = "mostRecentTemp" +SwitchFields.RECEIVED_X = "receivedX" +SwitchFields.RECEIVED_Y = "receivedY" +SwitchFields.HUESAT_SUPPORT = "huesatSupport" + + +SwitchFields.MIRED_KELVIN_CONVERSION_CONSTANT = 1000000 + +-- These values are a "sanity check" to check that values we are getting are reasonable +local COLOR_TEMPERATURE_KELVIN_MAX = 15000 +local COLOR_TEMPERATURE_KELVIN_MIN = 1000 +SwitchFields.COLOR_TEMPERATURE_MIRED_MAX = SwitchFields.MIRED_KELVIN_CONVERSION_CONSTANT/COLOR_TEMPERATURE_KELVIN_MIN +SwitchFields.COLOR_TEMPERATURE_MIRED_MIN = SwitchFields.MIRED_KELVIN_CONVERSION_CONSTANT/COLOR_TEMPERATURE_KELVIN_MAX + +SwitchFields.SWITCH_LEVEL_LIGHTING_MIN = 1 +SwitchFields.CURRENT_HUESAT_ATTR_MIN = 0 +SwitchFields.CURRENT_HUESAT_ATTR_MAX = 254 + + +-- DEVICE TYPES +SwitchFields.AGGREGATOR_DEVICE_TYPE_ID = 0x000E +SwitchFields.ON_OFF_LIGHT_DEVICE_TYPE_ID = 0x0100 +SwitchFields.DIMMABLE_LIGHT_DEVICE_TYPE_ID = 0x0101 +SwitchFields.COLOR_TEMP_LIGHT_DEVICE_TYPE_ID = 0x010C +SwitchFields.EXTENDED_COLOR_LIGHT_DEVICE_TYPE_ID = 0x010D +SwitchFields.ON_OFF_PLUG_DEVICE_TYPE_ID = 0x010A +SwitchFields.DIMMABLE_PLUG_DEVICE_TYPE_ID = 0x010B +SwitchFields.ON_OFF_SWITCH_ID = 0x0103 +SwitchFields.ON_OFF_DIMMER_SWITCH_ID = 0x0104 +SwitchFields.ON_OFF_COLOR_DIMMER_SWITCH_ID = 0x0105 +SwitchFields.MOUNTED_ON_OFF_CONTROL_ID = 0x010F +SwitchFields.MOUNTED_DIMMABLE_LOAD_CONTROL_ID = 0x0110 +SwitchFields.GENERIC_SWITCH_ID = 0x000F +SwitchFields.ELECTRICAL_SENSOR_ID = 0x0510 + +SwitchFields.device_type_profile_map = { + [SwitchFields.ON_OFF_LIGHT_DEVICE_TYPE_ID] = "light-binary", + [SwitchFields.DIMMABLE_LIGHT_DEVICE_TYPE_ID] = "light-level", + [SwitchFields.COLOR_TEMP_LIGHT_DEVICE_TYPE_ID] = "light-level-colorTemperature", + [SwitchFields.EXTENDED_COLOR_LIGHT_DEVICE_TYPE_ID] = "light-color-level", + [SwitchFields.ON_OFF_PLUG_DEVICE_TYPE_ID] = "plug-binary", + [SwitchFields.DIMMABLE_PLUG_DEVICE_TYPE_ID] = "plug-level", + [SwitchFields.ON_OFF_SWITCH_ID] = "switch-binary", + [SwitchFields.ON_OFF_DIMMER_SWITCH_ID] = "switch-level", + [SwitchFields.ON_OFF_COLOR_DIMMER_SWITCH_ID] = "switch-color-level", + [SwitchFields.MOUNTED_ON_OFF_CONTROL_ID] = "switch-binary", + [SwitchFields.MOUNTED_DIMMABLE_LOAD_CONTROL_ID] = "switch-level", +} + + +SwitchFields.CONVERSION_CONST_MILLIWATT_TO_WATT = 1000 -- A milliwatt is 1/1000th of a watt + + +-- COMPONENT_TO_ENDPOINT_MAP is here to preserve the endpoint mapping for +-- devices that were joined to this driver as MCD devices before the transition +-- to join switch devices as parent-child. This value will exist in the device +-- table for devices that joined prior to this transition, and is also used for +-- button devices that require component mapping. +SwitchFields.COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map" +SwitchFields.ENERGY_MANAGEMENT_ENDPOINT = "__energy_management_endpoint" +SwitchFields.IS_PARENT_CHILD_DEVICE = "__is_parent_child_device" +SwitchFields.COLOR_TEMP_BOUND_RECEIVED_KELVIN = "__colorTemp_bound_received_kelvin" +SwitchFields.COLOR_TEMP_BOUND_RECEIVED_MIRED = "__colorTemp_bound_received_mired" +SwitchFields.COLOR_TEMP_MIN = "__color_temp_min" +SwitchFields.COLOR_TEMP_MAX = "__color_temp_max" +SwitchFields.LEVEL_BOUND_RECEIVED = "__level_bound_received" +SwitchFields.LEVEL_MIN = "__level_min" +SwitchFields.LEVEL_MAX = "__level_max" +SwitchFields.COLOR_MODE = "__color_mode" + +SwitchFields.updated_fields = { + { current_field_name = "__component_to_endpoint_map_button", updated_field_name = SwitchFields.COMPONENT_TO_ENDPOINT_MAP }, + { current_field_name = "__switch_intialized", updated_field_name = nil } +} + +SwitchFields.HUE_SAT_COLOR_MODE = clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION +SwitchFields.X_Y_COLOR_MODE = clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY + + +SwitchFields.child_device_profile_overrides_per_vendor_id = { + [0x1321] = { + { product_id = 0x000C, target_profile = "switch-binary", initial_profile = "plug-binary" }, + { product_id = 0x000D, target_profile = "switch-binary", initial_profile = "plug-binary" }, + }, + [0x115F] = { + { product_id = 0x1003, target_profile = "light-power-energy-powerConsumption" }, -- 2 Buttons(Generic Switch), 1 Channel(On/Off Light) + { product_id = 0x1004, target_profile = "light-power-energy-powerConsumption" }, -- 2 Buttons(Generic Switch), 2 Channels(On/Off Light) + { product_id = 0x1005, target_profile = "light-power-energy-powerConsumption" }, -- 4 Buttons(Generic Switch), 3 Channels(On/Off Light) + { product_id = 0x1006, target_profile = "light-level-power-energy-powerConsumption" }, -- 3 Buttons(Generic Switch), 1 Channels(Dimmable Light) + { product_id = 0x1008, target_profile = "light-power-energy-powerConsumption" }, -- 2 Buttons(Generic Switch), 1 Channel(On/Off Light) + { product_id = 0x1009, target_profile = "light-power-energy-powerConsumption" }, -- 4 Buttons(Generic Switch), 2 Channels(On/Off Light) + { product_id = 0x100A, target_profile = "light-level-power-energy-powerConsumption" }, -- 1 Buttons(Generic Switch), 1 Channels(Dimmable Light) + } +} + +SwitchFields.CUMULATIVE_REPORTS_NOT_SUPPORTED = "__cumulative_reports_not_supported" +SwitchFields.FIRST_IMPORT_REPORT_TIMESTAMP = "__first_import_report_timestamp" +SwitchFields.IMPORT_POLL_TIMER_SETTING_ATTEMPTED = "__import_poll_timer_setting_attempted" +SwitchFields.IMPORT_REPORT_TIMEOUT = "__import_report_timeout" +SwitchFields.TOTAL_IMPORTED_ENERGY = "__total_imported_energy" +SwitchFields.LAST_IMPORTED_REPORT_TIMESTAMP = "__last_imported_report_timestamp" +SwitchFields.RECURRING_IMPORT_REPORT_POLL_TIMER = "__recurring_import_report_poll_timer" +SwitchFields.MINIMUM_ST_ENERGY_REPORT_INTERVAL = (15 * 60) -- 15 minutes, reported in seconds +SwitchFields.SUBSCRIPTION_REPORT_OCCURRED = "__subscription_report_occurred" + +SwitchFields.START_BUTTON_PRESS = "__start_button_press" +SwitchFields.TIMEOUT_THRESHOLD = 10 --arbitrary timeout +SwitchFields.HELD_THRESHOLD = 1 + +-- this is the number of buttons for which we have a static profile already made +SwitchFields.STATIC_BUTTON_PROFILE_SUPPORTED = {1, 2, 3, 4, 5, 6, 7, 8, 9} + +-- 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. +SwitchFields.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 +SwitchFields.EMULATE_HELD = "__emulate_held" -- for non-MSR (MomentarySwitchRelease) devices we can emulate this on the software side +SwitchFields.SUPPORTS_MULTI_PRESS = "__multi_button" -- for MSM devices (MomentarySwitchMultiPress), create an event on receipt of MultiPressComplete +SwitchFields.INITIAL_PRESS_ONLY = "__initial_press_only" -- for devices that support MS (MomentarySwitch), but not MSR (MomentarySwitchRelease) + +SwitchFields.TEMP_BOUND_RECEIVED = "__temp_bound_received" +SwitchFields.TEMP_MIN = "__temp_min" +SwitchFields.TEMP_MAX = "__temp_max" + +SwitchFields.AQARA_MANUFACTURER_ID = 0x115F +SwitchFields.AQARA_CLIMATE_SENSOR_W100_ID = 0x2004 + +SwitchFields.TRANSITION_TIME = 0 --1/10ths of a second +-- When sent with a command, these options mask and override bitmaps cause the command +-- to take effect when the switch/light is off. +SwitchFields.OPTIONS_MASK = 0x01 +SwitchFields.OPTIONS_OVERRIDE = 0x01 + + +SwitchFields.supported_capabilities = { + capabilities.battery, + capabilities.batteryLevel, + capabilities.button, + capabilities.colorControl, + capabilities.colorTemperature, + capabilities.energyMeter, + capabilities.fanMode, + capabilities.fanSpeedPercent, + capabilities.illuminanceMeasurement, + capabilities.level, + capabilities.motionSensor, + capabilities.powerMeter, + capabilities.powerConsumptionReport, + capabilities.relativeHumidityMeasurement, + capabilities.switch, + capabilities.switchLevel, + capabilities.temperatureMeasurement, + capabilities.valve, +} + +SwitchFields.device_type_attribute_map = { + [SwitchFields.ON_OFF_LIGHT_DEVICE_TYPE_ID] = { + clusters.OnOff.attributes.OnOff + }, + [SwitchFields.DIMMABLE_LIGHT_DEVICE_TYPE_ID] = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel + }, + [SwitchFields.COLOR_TEMP_LIGHT_DEVICE_TYPE_ID] = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds + }, + [SwitchFields.EXTENDED_COLOR_LIGHT_DEVICE_TYPE_ID] = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, + clusters.ColorControl.attributes.CurrentHue, + clusters.ColorControl.attributes.CurrentSaturation, + clusters.ColorControl.attributes.CurrentX, + clusters.ColorControl.attributes.CurrentY + }, + [SwitchFields.ON_OFF_PLUG_DEVICE_TYPE_ID] = { + clusters.OnOff.attributes.OnOff + }, + [SwitchFields.DIMMABLE_PLUG_DEVICE_TYPE_ID] = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel + }, + [SwitchFields.ON_OFF_SWITCH_ID] = { + clusters.OnOff.attributes.OnOff + }, + [SwitchFields.ON_OFF_DIMMER_SWITCH_ID] = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel + }, + [SwitchFields.ON_OFF_COLOR_DIMMER_SWITCH_ID] = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, + clusters.ColorControl.attributes.CurrentHue, + clusters.ColorControl.attributes.CurrentSaturation, + clusters.ColorControl.attributes.CurrentX, + clusters.ColorControl.attributes.CurrentY + }, + [SwitchFields.GENERIC_SWITCH_ID] = { + clusters.PowerSource.attributes.BatPercentRemaining, + clusters.Switch.events.InitialPress, + clusters.Switch.events.LongPress, + clusters.Switch.events.ShortRelease, + clusters.Switch.events.MultiPressComplete + }, + [SwitchFields.ELECTRICAL_SENSOR_ID] = { + clusters.ElectricalPowerMeasurement.attributes.ActivePower, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported + } +} + +return SwitchFields \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/src/utils/switch_utils.lua b/drivers/SmartThings/matter-switch/src/utils/switch_utils.lua new file mode 100644 index 0000000000..e3f9e667f9 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/utils/switch_utils.lua @@ -0,0 +1,206 @@ +-- 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 fields = require "utils.switch_fields" +local st_utils = require "st.utils" +local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" +local log = require "log" + +local utils = {} + +function utils.tbl_contains(array, value) + for _, element in ipairs(array) do + if element == value then + return true + end + end + return false +end + +function utils.convert_huesat_st_to_matter(val) + return st_utils.clamp_value(math.floor((val * 0xFE) / 100.0 + 0.5), fields.CURRENT_HUESAT_ATTR_MIN, fields.CURRENT_HUESAT_ATTR_MAX) +end + +function utils.get_field_for_endpoint(device, field, endpoint) + return device:get_field(string.format("%s_%d", field, endpoint)) +end + +function utils.set_field_for_endpoint(device, field, endpoint, value, additional_params) + device:set_field(string.format("%s_%d", field, endpoint), value, additional_params) +end + +function utils.mired_to_kelvin(value, minOrMax) + if value == 0 then -- shouldn't happen, but has + value = 1 + log.warn(string.format("Received a color temperature of 0 mireds. Using a color temperature of 1 mired to avoid divide by zero")) + end + -- We divide inside the rounding and multiply outside of it because we expect these + -- bounds to be multiples of 100. For the maximum mired value (minimum K value), + -- add 1 before converting and round up to nearest hundreds. For the minimum mired + -- (maximum K value) value, subtract 1 before converting and round down to nearest + -- hundreds. Note that 1 is added/subtracted from the mired value in order to avoid + -- rounding errors from the conversion of Kelvin to mireds. + local kelvin_step_size = 100 + local rounding_value = 0.5 + if minOrMax == fields.COLOR_TEMP_MIN then + return st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT / (kelvin_step_size * (value + 1)) + rounding_value) * kelvin_step_size + elseif minOrMax == fields.COLOR_TEMP_MAX then + return st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT / (kelvin_step_size * (value - 1)) - rounding_value) * kelvin_step_size + else + log.warn_with({hub_logs = true}, "Attempted to convert temperature unit for an undefined value") + end +end + +function utils.check_field_name_updates(device) + for _, field in ipairs(fields.updated_fields) do + if device:get_field(field.current_field_name) then + if field.updated_field_name ~= nil then + device:set_field(field.updated_field_name, device:get_field(field.current_field_name), {persist = true}) + end + device:set_field(field.current_field_name, nil) + end + end +end + +--- device_type_supports_button_switch_combination helper function used to check +--- whether the device type for an endpoint is currently supported by a profile for +--- combination button/switch devices. +function utils.device_type_supports_button_switch_combination(device, endpoint_id) + for _, ep in ipairs(device.endpoints) do + if ep.endpoint_id == endpoint_id then + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == fields.DIMMABLE_LIGHT_DEVICE_TYPE_ID then + for _, fingerprint in ipairs(fields.child_device_profile_overrides_per_vendor_id[0x115F]) do + if device.manufacturer_info.product_id == fingerprint.product_id then + return false -- For Aqara Dimmer Switch with Button. + end + end + return true + end + end + end + end + return false +end + +--- find_default_endpoint is a helper function to handle situations where +--- device does not have endpoint ids in sequential order from 1 +function utils.find_default_endpoint(device) + if device.manufacturer_info.vendor_id == fields.AQARA_MANUFACTURER_ID and + device.manufacturer_info.product_id == fields.AQARA_CLIMATE_SENSOR_W100_ID then + -- In case of Aqara Climate Sensor W100, in order to sequentially set the button name to button 1, 2, 3 + return device.MATTER_DEFAULT_ENDPOINT + end + + local switch_eps = device:get_endpoints(clusters.OnOff.ID) + local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + + local get_first_non_zero_endpoint = function(endpoints) + table.sort(endpoints) + for _,ep in ipairs(endpoints) do + if ep ~= 0 then -- 0 is the matter RootNode endpoint + return ep + end + end + return nil + end + + -- Return the first switch endpoint as the default endpoint if no button endpoints are present + if #button_eps == 0 and #switch_eps > 0 then + return get_first_non_zero_endpoint(switch_eps) + end + + -- Return the first button endpoint as the default endpoint if no switch endpoints are present + if #switch_eps == 0 and #button_eps > 0 then + return get_first_non_zero_endpoint(button_eps) + end + + -- If both switch and button endpoints are present, check the device type on the main switch + -- endpoint. If it is not a supported device type, return the first button endpoint as the + -- default endpoint. + if #switch_eps > 0 and #button_eps > 0 then + local main_endpoint = get_first_non_zero_endpoint(switch_eps) + if utils.device_type_supports_button_switch_combination(device, main_endpoint) then + return main_endpoint + else + device.log.warn("The main switch endpoint does not contain a supported device type for a component configuration with buttons") + return get_first_non_zero_endpoint(button_eps) + end + end + + device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) + return device.MATTER_DEFAULT_ENDPOINT +end + +function utils.component_to_endpoint(device, component) + local map = device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {} + if map[component] then + return map[component] + end + return utils.find_default_endpoint(device) +end + +function utils.endpoint_to_component(device, ep) + local map = device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {} + for component, endpoint in pairs(map) do + if endpoint == ep then + return component + end + end + return "main" +end + +function utils.find_child(parent, ep_id) + return parent:get_child_by_parent_assigned_key(string.format("%d", ep_id)) +end + +-- Fallback handler for responses that dont have their own handler +function utils.matter_handler(driver, device, response_block) + device.log.info(string.format("Fallback handler for %s", response_block)) +end + +--helper function to create list of multi press values +function utils.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 + + +function utils.detect_bridge(device) + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == fields.AGGREGATOR_DEVICE_TYPE_ID then + return true + end + end + end + return false +end + +function utils.detect_matter_thing(device) + for _, capability in ipairs(fields.supported_capabilities) do + if device:supports_capability(capability) then + return false + end + end + return device:supports_capability(capabilities.refresh) +end + +return utils