diff --git a/drivers/SmartThings/matter-switch/profiles/mode-only.yml b/drivers/SmartThings/matter-switch/profiles/mode-only.yml new file mode 100644 index 0000000000..d10e39b152 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/mode-only.yml @@ -0,0 +1,57 @@ +name: mode-only +components: +- id: main + capabilities: + - id: mode + version: 1 + - id: refresh + version: 1 + - id: firmwareUpdate + version: 1 + categories: + - name: Thermostat +deviceConfig: + detailView: + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/list/command/supportedValues + value: supportedArguments.value + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/displayType + value: dynamicList + - op: add + path: /0/dynamicList + value: + value: mode.value + supportedValues: + value: supportedArguments.value + - op: remove + path: /0/list + actions: + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/displayType + value: dynamicList + - op: add + path: /0/dynamicList + value: + value: mode.value + supportedValues: + value: supportedArguments.value + - op: remove + path: /0/list diff --git a/drivers/SmartThings/matter-switch/profiles/switch-binary-mode.yml b/drivers/SmartThings/matter-switch/profiles/switch-binary-mode.yml new file mode 100644 index 0000000000..1e811da266 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/switch-binary-mode.yml @@ -0,0 +1,68 @@ +name: switch-binary-mode +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: mode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +deviceConfig: + detailView: + - component: main + capability: switch + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/list/command/supportedValues + value: supportedArguments.value + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: switch + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/displayType + value: dynamicList + - op: add + path: /0/dynamicList + value: + value: mode.value + supportedValues: + value: supportedArguments.value + - op: remove + path: /0/list + actions: + - component: main + capability: switch + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/displayType + value: dynamicList + - op: add + path: /0/dynamicList + value: + value: mode.value + supportedValues: + value: supportedArguments.value + - op: remove + path: /0/list diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/init.lua new file mode 100644 index 0000000000..60e64fd1cd --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/init.lua @@ -0,0 +1,97 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local ModeSelectServerAttributes = require "embedded_clusters.ModeSelect.server.attributes" +local ModeSelectServerCommands = require "embedded_clusters.ModeSelect.server.commands" +local ModeSelectTypes = require "embedded_clusters.ModeSelect.types" + +local ModeSelect = {} + +ModeSelect.ID = 0x0050 +ModeSelect.NAME = "ModeSelect" +ModeSelect.server = {} +ModeSelect.client = {} +ModeSelect.server.attributes = ModeSelectServerAttributes:set_parent_cluster(ModeSelect) +ModeSelect.server.commands = ModeSelectServerCommands:set_parent_cluster(ModeSelect) +ModeSelect.types = ModeSelectTypes + +function ModeSelect:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "Description", + [0x0001] = "StandardNamespace", + [0x0002] = "SupportedModes", + [0x0003] = "CurrentMode", + [0x0004] = "StartUpMode", + [0x0005] = "OnMode", + [0xFFF9] = "AcceptedCommandList", + [0xFFFA] = "EventList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function ModeSelect:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "ChangeToMode", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +ModeSelect.attribute_direction_map = { + ["Description"] = "server", + ["StandardNamespace"] = "server", + ["SupportedModes"] = "server", + ["CurrentMode"] = "server", + ["StartUpMode"] = "server", + ["OnMode"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +ModeSelect.command_direction_map = { + ["ChangeToMode"] = "server", +} + +ModeSelect.FeatureMap = ModeSelect.types.Feature + +function ModeSelect.are_features_supported(feature, feature_map) + if (ModeSelect.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ModeSelect.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ModeSelect.NAME)) + end + return ModeSelect[direction].attributes[key] +end +ModeSelect.attributes = {} +setmetatable(ModeSelect.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = ModeSelect.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, ModeSelect.NAME)) + end + return ModeSelect[direction].commands[key] +end +ModeSelect.commands = {} +setmetatable(ModeSelect.commands, command_helper_mt) + +setmetatable(ModeSelect, {__index = cluster_base}) + +return ModeSelect diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/attributes/CurrentMode.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/attributes/CurrentMode.lua new file mode 100644 index 0000000000..0d2cb44429 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/attributes/CurrentMode.lua @@ -0,0 +1,70 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentMode = { + ID = 0x0003, + NAME = "CurrentMode", + base_type = require "st.matter.data_types.Uint8", +} + +function CurrentMode:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CurrentMode:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentMode:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CurrentMode, {__call = CurrentMode.new_value, __index = CurrentMode.base_type}) +return CurrentMode diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/attributes/SupportedModes.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/attributes/SupportedModes.lua new file mode 100644 index 0000000000..eac4a2a844 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/attributes/SupportedModes.lua @@ -0,0 +1,77 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SupportedModes = { + ID = 0x0002, + NAME = "SupportedModes", + base_type = require "st.matter.data_types.Array", + element_type = require "embedded_clusters.ModeSelect.types.ModeOptionStruct", +} + +function SupportedModes:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, SupportedModes.element_type) + end +end + +function SupportedModes:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SupportedModes:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SupportedModes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SupportedModes:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SupportedModes:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SupportedModes, {__call = SupportedModes.new_value, __index = SupportedModes.base_type}) +return SupportedModes diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/attributes/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/attributes/init.lua new file mode 100644 index 0000000000..355a3abcd8 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/attributes/init.lua @@ -0,0 +1,26 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +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("embedded_clusters.ModeSelect.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local ModeSelectServerAttributes = {} + +function ModeSelectServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ModeSelectServerAttributes, attr_mt) + +return ModeSelectServerAttributes diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/commands/ChangeToMode.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/commands/ChangeToMode.lua new file mode 100644 index 0000000000..3b0bbe6de7 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/commands/ChangeToMode.lua @@ -0,0 +1,89 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ChangeToMode = {} + +ChangeToMode.NAME = "ChangeToMode" +ChangeToMode.ID = 0x0000 +ChangeToMode.field_defs = { + { + name = "new_mode", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, +} + +function ChangeToMode:init(device, endpoint_id, new_mode) + local out = {} + local args = {new_mode} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = ChangeToMode, + __tostring = ChangeToMode.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function ChangeToMode:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ChangeToMode:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function ChangeToMode:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(ChangeToMode, {__call = ChangeToMode.init}) + +return ChangeToMode diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/commands/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/commands/init.lua new file mode 100644 index 0000000000..3af9f0aaa4 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/server/commands/init.lua @@ -0,0 +1,25 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +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("embedded_clusters.ModeSelect.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) + end + return command_mt.__command_cache[key] +end + +local ModeSelectServerCommands = {} + +function ModeSelectServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ModeSelectServerCommands, command_mt) + +return ModeSelectServerCommands diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/Feature.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/Feature.lua new file mode 100644 index 0000000000..51b73d65f1 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/Feature.lua @@ -0,0 +1,56 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.ON_OFF = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + ON_OFF = 0x0001, +} + +Feature.is_on_off_set = function(self) + return (self.value & self.ON_OFF) ~= 0 +end + +Feature.set_on_off = function(self) + if self.value ~= nil then + self.value = self.value | self.ON_OFF + else + self.value = self.ON_OFF + end +end + +Feature.unset_on_off = function(self) + self.value = self.value & (~self.ON_OFF & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.ON_OFF + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_on_off_set = Feature.is_on_off_set, + set_on_off = Feature.set_on_off, + unset_on_off = Feature.unset_on_off, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/ModeOptionStruct.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/ModeOptionStruct.lua new file mode 100644 index 0000000000..4269b1575f --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/ModeOptionStruct.lua @@ -0,0 +1,87 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeOptionStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeOptionStruct", ID = data_types.name_to_id_map["Structure"]}) +ModeOptionStruct.field_defs = { + { + name = "label", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.UTF8String1", + }, + { + name = "mode", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "semantic_tags", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "embedded_clusters.ModeSelect.types.SemanticTagStruct", + }, +} + +ModeOptionStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeOptionStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeOptionStruct.init +new_mt.__index.serialize = ModeOptionStruct.serialize + +ModeOptionStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeOptionStruct, new_mt) + +return ModeOptionStruct diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/ModeTagStruct.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/ModeTagStruct.lua new file mode 100644 index 0000000000..ed5bff42c1 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/ModeTagStruct.lua @@ -0,0 +1,80 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local ModeTagStruct = {} +local new_mt = StructureABC.new_mt({NAME = "ModeTagStruct", ID = data_types.name_to_id_map["Structure"]}) + +ModeTagStruct.field_defs = { + { + name = "mfg_code", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "value", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +ModeTagStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +ModeTagStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = ModeTagStruct.init +new_mt.__index.serialize = ModeTagStruct.serialize + +ModeTagStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(ModeTagStruct, new_mt) + +return ModeTagStruct diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/SemanticTagStruct.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/SemanticTagStruct.lua new file mode 100644 index 0000000000..7482dfbbcd --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/SemanticTagStruct.lua @@ -0,0 +1,87 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local SemanticTagStruct = {} +local new_mt = StructureABC.new_mt({NAME = "SemanticTagStruct", ID = data_types.name_to_id_map["Structure"]}) + +SemanticTagStruct.field_defs = { + { + name = "mfg_code", + field_id = 0, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "namespace_id", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "tag", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +SemanticTagStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +SemanticTagStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = SemanticTagStruct.init +new_mt.__index.serialize = SemanticTagStruct.serialize + +SemanticTagStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(SemanticTagStruct, new_mt) + +return SemanticTagStruct diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/init.lua new file mode 100644 index 0000000000..41fed4a930 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ModeSelect/types/init.lua @@ -0,0 +1,17 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +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("embedded_clusters.ModeSelect.types." .. key) + end + return types_mt.__types_cache[key] +end + +local ModeSelectTypes = {} + +setmetatable(ModeSelectTypes, types_mt) + +return ModeSelectTypes diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index dea56b4a21..dee4b3d137 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -29,6 +29,8 @@ if version.api < 16 then clusters.Descriptor = require "embedded_clusters.Descriptor" end +local ModeSelect = require "embedded_clusters.ModeSelect" + local SwitchLifecycleHandlers = {} function SwitchLifecycleHandlers.device_added(driver, device) @@ -70,6 +72,13 @@ function SwitchLifecycleHandlers.info_changed(driver, device, event, args) button_cfg.configure_buttons(device, device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) ) + local mode_select_eps = device:get_endpoints(ModeSelect.ID) + if #mode_select_eps > 0 then + for _, ep in ipairs(mode_select_eps) do + device:send(ModeSelect.attributes.SupportedModes:read(device, ep)) + device:send(ModeSelect.attributes.CurrentMode:read(device, ep)) + end + end elseif device.network_type == device_lib.NETWORK_TYPE_CHILD then device:get_parent_device():subscribe() -- parent device required to send subscription requests end @@ -108,6 +117,14 @@ function SwitchLifecycleHandlers.device_init(driver, device) switch_utils.handle_electrical_sensor_info(device) device:extend_device("subscribe", switch_utils.subscribe) device:subscribe() + + local mode_select_eps = device:get_endpoints(ModeSelect.ID) + if #mode_select_eps > 0 then + for _, ep in ipairs(mode_select_eps) do + device:send(ModeSelect.attributes.SupportedModes:read(device, ep)) + device:send(ModeSelect.attributes.CurrentMode:read(device, ep)) + end + end end end @@ -199,6 +216,10 @@ local matter_driver_template = { [clusters.ValveConfigurationAndControl.attributes.CurrentLevel.ID] = attribute_handlers.valve_configuration_current_level_handler, [clusters.ValveConfigurationAndControl.attributes.CurrentState.ID] = attribute_handlers.valve_configuration_current_state_handler, }, + [ModeSelect.ID] = { + [ModeSelect.attributes.SupportedModes.ID] = attribute_handlers.mode_select_supported_modes_handler, + [ModeSelect.attributes.CurrentMode.ID] = attribute_handlers.mode_select_current_mode_handler, + }, }, event = { [clusters.Switch.ID] = { @@ -281,6 +302,10 @@ local matter_driver_template = { [capabilities.valve.ID] = { clusters.ValveConfigurationAndControl.attributes.CurrentState }, + [capabilities.mode.ID] = { + ModeSelect.attributes.SupportedModes, + ModeSelect.attributes.CurrentMode, + }, }, subscribed_events = { [capabilities.button.ID] = { @@ -332,6 +357,9 @@ local matter_driver_template = { [capabilities.valve.commands.close.NAME] = capability_handlers.handle_valve_close, [capabilities.valve.commands.open.NAME] = capability_handlers.handle_valve_open, }, + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = capability_handlers.handle_set_mode, + }, }, supported_capabilities = { capabilities.audioMute, @@ -353,6 +381,7 @@ local matter_driver_template = { capabilities.imageControl, capabilities.level, capabilities.localMediaStorage, + capabilities.mode, capabilities.mechanicalPanTiltZoom, capabilities.motionSensor, capabilities.nightVision, diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua index 0fdc9cc822..03b6c34093 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua @@ -625,4 +625,35 @@ function AttributeHandlers.flow_attr_handler_factory(minOrMax) end end + +-- [[ MODE SELECT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.mode_select_supported_modes_handler(driver, device, ib, response) + local ModeSelect = require "embedded_clusters.ModeSelect" + local supportedModes = {} + local supportedModesWithIdx = {} + for _, mode in ipairs(ib.data.elements) do + ModeSelect.types.ModeOptionStruct:augment_type(mode) + table.insert(supportedModes, mode.elements.label.value) + table.insert(supportedModesWithIdx, {mode.elements.mode.value, mode.elements.label.value}) + end + device:set_field(fields.MODE_SELECT_SUPPORTED_MODES, supportedModesWithIdx, { persist = true }) + local event = capabilities.mode.supportedModes(supportedModes, { visibility = { displayed = false } }) + device:emit_event_for_endpoint(ib.endpoint_id, event) + event = capabilities.mode.supportedArguments(supportedModes, { visibility = { displayed = false } }) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +function AttributeHandlers.mode_select_current_mode_handler(driver, device, ib, response) + device.log.info(string.format("mode_select_current_mode_handler mode: %s", ib.data.value)) + local supportedModesWithIdx = device:get_field(fields.MODE_SELECT_SUPPORTED_MODES) or {} + local currentMode = ib.data.value + for _, mode in ipairs(supportedModesWithIdx) do + if mode[1] == currentMode then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.mode.mode(mode[2])) + break + end + end +end + return AttributeHandlers diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua index 6d035c3dc7..82f7a007f8 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua @@ -249,4 +249,23 @@ function CapabilityHandlers.handle_operational_state_pause(driver, device, cmd) device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) end + +-- [[ MODE SELECT CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_set_mode(driver, device, cmd) + local ModeSelect = require "embedded_clusters.ModeSelect" + device.log.info(string.format("handle_set_mode mode: %s", cmd.args.mode)) + + local mode_select_eps = device:get_endpoints(ModeSelect.ID) + local endpoint_id = mode_select_eps[1] or device:component_to_endpoint(cmd.component) + + local supportedModesWithIdx = device:get_field(fields.MODE_SELECT_SUPPORTED_MODES) or {} + for _, mode in ipairs(supportedModesWithIdx) do + if cmd.args.mode == mode[2] then + device:send(ModeSelect.commands.ChangeToMode(device, endpoint_id, mode[1])) + return + end + end +end + return CapabilityHandlers diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua index 085de2d351..da6a81dfb7 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -7,6 +7,7 @@ local version = require "version" local fields = require "switch_utils.fields" local switch_utils = require "switch_utils.utils" local embedded_cluster_utils = require "switch_utils.embedded_cluster_utils" +local ModeSelect = require "embedded_clusters.ModeSelect" -- Include driver-side definitions when lua libs api version is < 11 if version.api < 11 then @@ -303,6 +304,43 @@ function DeviceConfiguration.match_profile(driver, device) ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, momentary_switch_ep_ids) ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep_ids) end + -- Add mode capability if ModeSelect cluster is present + local mode_select_eps = device:get_endpoints(ModeSelect.ID) + if #mode_select_eps > 0 then + if not updated_profile then + updated_profile = "mode-only" + elseif string.find(updated_profile or "", "switch%-binary") then + updated_profile = "switch-binary-mode" + else + optional_component_capabilities = optional_component_capabilities or {} + -- Check if main component already has mode in optional capabilities + local has_mode = false + for _, comp in ipairs(optional_component_capabilities) do + if comp[1] == "main" then + for _, cap in ipairs(comp[2]) do + if cap == capabilities.mode.ID then + has_mode = true + break + end + end + end + end + if not has_mode then + -- Find existing main entry or create new one + local found_main = false + for _, comp in ipairs(optional_component_capabilities) do + if comp[1] == "main" then + table.insert(comp[2], capabilities.mode.ID) + found_main = true + break + end + end + if not found_main then + table.insert(optional_component_capabilities, {"main", {capabilities.mode.ID}}) + end + end + end + end device:try_update_metadata({ profile = updated_profile, optional_component_capabilities = optional_component_capabilities }) end diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index a8749a6480..f675158bf2 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -172,6 +172,8 @@ SwitchFields.battery_support = { BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE", } +SwitchFields.MODE_SELECT_SUPPORTED_MODES = "__mode_select_supported_modes" + SwitchFields.ENERGY_METER_OFFSET = "__energy_meter_offset" SwitchFields.CUMULATIVE_REPORTS_SUPPORTED = "__cumulative_reports_supported" SwitchFields.LAST_IMPORTED_REPORT_TIMESTAMP = "__last_imported_report_timestamp" diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_mode_select.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_mode_select.lua new file mode 100644 index 0000000000..a67a92f766 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_mode_select.lua @@ -0,0 +1,152 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +local ModeSelect = require "embedded_clusters.ModeSelect" + +local MOCK_MODE_SELECT_EP = 1 +local MOCK_MODE_SELECT_CLUSTER_ID = ModeSelect.ID + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("switch-binary-mode.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = 0x001D, cluster_type = "SERVER"}, -- Descriptor + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + {cluster_id = 0x0006, cluster_type = "SERVER"}, -- OnOff + { + cluster_id = MOCK_MODE_SELECT_CLUSTER_ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0 + }, + }, + device_types = { + {device_type_id = 0x0103, device_type_revision = 1} -- On/Off Light Switch + } + } + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + + +test.register_coroutine_test( + "SupportedModes report should generate supportedModes and supportedArguments events", + function() + -- Build a SupportedModes report with 3 modes + local supported_modes_data = { + { + label = "Normal", + mode = 0, + semantic_tags = {} + }, + { + label = "Eco", + mode = 1, + semantic_tags = {} + }, + { + label = "Turbo", + mode = 2, + semantic_tags = {} + }, + } + + test.socket.matter:__queue_receive({ + mock_device.id, + ModeSelect.attributes.SupportedModes:build_test_report_data( + mock_device, MOCK_MODE_SELECT_EP, supported_modes_data + ) + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.mode.supportedModes({"Normal", "Eco", "Turbo"}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.mode.supportedArguments({"Normal", "Eco", "Turbo"}, { visibility = { displayed = false } }) + ) + ) + end +) + + +test.register_coroutine_test( + "CurrentMode report should generate mode event with correct label", + function() + -- Pre-populate supported modes on the device + mock_device:set_field("__mode_select_supported_modes", {{0, "Normal"}, {1, "Eco"}, {2, "Turbo"}}, { persist = true }) + + test.socket.matter:__queue_receive({ + mock_device.id, + ModeSelect.attributes.CurrentMode:build_test_report_data( + mock_device, MOCK_MODE_SELECT_EP, 1 + ) + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.mode.mode("Eco") + ) + ) + end +) + + +test.register_message_test( + "setMode command should send ChangeToMode with correct mode index", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "mode", component = "main", command = "setMode", args = { "Turbo" } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + ModeSelect.commands.ChangeToMode(mock_device, MOCK_MODE_SELECT_EP, 2) + } + } + }, + { + test_init = function() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + mock_device:set_field("__mode_select_supported_modes", {{0, "Normal"}, {1, "Eco"}, {2, "Turbo"}}, { persist = true }) + end + } +) + + +test.run_registered_tests()