From cd8e8fafcead4713c4a60a75d26a979b9930f329 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 21 Jul 2025 19:26:41 -0500 Subject: [PATCH] add support for matter hrap device types --- drivers/SmartThings/matter-hrap/config.yml | 6 + .../SmartThings/matter-hrap/fingerprints.yml | 11 + .../network-infrastructure-manager.yml | 16 + .../profiles/thread-border-router.yml | 14 + .../client/commands/DatasetResponse.lua | 103 ++++++ .../client/commands/init.lua | 23 ++ .../src/ThreadBorderRouterManagement/init.lua | 148 ++++++++ .../attributes/ActiveDatasetTimestamp.lua | 68 ++++ .../server/attributes/BorderRouterName.lua | 69 ++++ .../server/attributes/InterfaceEnabled.lua | 69 ++++ .../server/attributes/ThreadVersion.lua | 68 ++++ .../server/attributes/init.lua | 24 ++ .../commands/GetActiveDatasetRequest.lua | 79 ++++ .../server/commands/init.lua | 28 ++ .../types/Feature.lua | 54 +++ .../types/init.lua | 15 + .../src/WiFiNetworkManagement/init.lua | 58 +++ .../server/attributes/Ssid.lua | 68 ++++ .../server/attributes/init.lua | 24 ++ .../src/embedded-cluster-utils.lua | 62 ++++ drivers/SmartThings/matter-hrap/src/init.lua | 217 +++++++++++ .../test_thread_border_router_network.lua | 342 ++++++++++++++++++ 22 files changed, 1566 insertions(+) create mode 100644 drivers/SmartThings/matter-hrap/config.yml create mode 100644 drivers/SmartThings/matter-hrap/fingerprints.yml create mode 100644 drivers/SmartThings/matter-hrap/profiles/network-infrastructure-manager.yml create mode 100644 drivers/SmartThings/matter-hrap/profiles/thread-border-router.yml create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/DatasetResponse.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ActiveDatasetTimestamp.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/BorderRouterName.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/InterfaceEnabled.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ThreadVersion.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/GetActiveDatasetRequest.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/Feature.lua create mode 100644 drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/Ssid.lua create mode 100644 drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/embedded-cluster-utils.lua create mode 100644 drivers/SmartThings/matter-hrap/src/init.lua create mode 100644 drivers/SmartThings/matter-hrap/src/test/test_thread_border_router_network.lua diff --git a/drivers/SmartThings/matter-hrap/config.yml b/drivers/SmartThings/matter-hrap/config.yml new file mode 100644 index 0000000000..21d79bc8ec --- /dev/null +++ b/drivers/SmartThings/matter-hrap/config.yml @@ -0,0 +1,6 @@ +name: 'Matter HRAP' +packageKey: 'matter-hrap' +permissions: + matter: {} +description: "SmartThings driver for Matter HRAP devices" +vendorSupportInformation: "https://support.smartthings.com" diff --git a/drivers/SmartThings/matter-hrap/fingerprints.yml b/drivers/SmartThings/matter-hrap/fingerprints.yml new file mode 100644 index 0000000000..7b768709c2 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/fingerprints.yml @@ -0,0 +1,11 @@ +matterGeneric: + - id: "matter/network-manager" + deviceLabel: Matter Network Infrastructure Manager + deviceTypes: + - id: 0x0090 # NIM + deviceProfileName: network-infrastructure-manager + - id: "matter/thread-border-router" + deviceLabel: Matter Thread Border Router + deviceTypes: + - id: 0x0091 # TBR + deviceProfileName: thread-border-router diff --git a/drivers/SmartThings/matter-hrap/profiles/network-infrastructure-manager.yml b/drivers/SmartThings/matter-hrap/profiles/network-infrastructure-manager.yml new file mode 100644 index 0000000000..725676c502 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/profiles/network-infrastructure-manager.yml @@ -0,0 +1,16 @@ +name: network-infrastructure-manager +components: +- id: main + capabilities: + - id: threadBorderRouter + version: 1 + - id: threadNetwork + version: 1 + - id: wifiInformation + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Networking diff --git a/drivers/SmartThings/matter-hrap/profiles/thread-border-router.yml b/drivers/SmartThings/matter-hrap/profiles/thread-border-router.yml new file mode 100644 index 0000000000..b879dd2e76 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/profiles/thread-border-router.yml @@ -0,0 +1,14 @@ +name: thread-border-router +components: +- id: main + capabilities: + - id: threadBorderRouter + version: 1 + - id: threadNetwork + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Networking diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/DatasetResponse.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/DatasetResponse.lua new file mode 100644 index 0000000000..0cf8facc53 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/DatasetResponse.lua @@ -0,0 +1,103 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local DatasetResponse = {} + +DatasetResponse.NAME = "DatasetResponse" +DatasetResponse.ID = 0x0002 +DatasetResponse.field_defs = { + { + name = "dataset", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.OctetString1", + }, +} + +function DatasetResponse: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 DatasetResponse:build_test_command_response(device, endpoint_id, dataset, interaction_status) + local function init(self, device, endpoint_id, dataset) + local out = {} + local args = {dataset} + 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 = DatasetResponse, + __tostring = DatasetResponse.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + false, + true + ) + end + local self_request = init(self, device, endpoint_id, dataset) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + self_request.info_blocks[1].tlv, + interaction_status + ) +end + +function DatasetResponse:init() + return nil +end + +function DatasetResponse:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function DatasetResponse:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(DatasetResponse, {__call = DatasetResponse.init}) + +return DatasetResponse diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/init.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/init.lua new file mode 100644 index 0000000000..3c8bee49fa --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/client/commands/init.lua @@ -0,0 +1,23 @@ +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("ThreadBorderRouterManagement.client.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 ThreadBorderRouterManagementClientCommands = {} + +function ThreadBorderRouterManagementClientCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ThreadBorderRouterManagementClientCommands, command_mt) + +return ThreadBorderRouterManagementClientCommands + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/init.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/init.lua new file mode 100644 index 0000000000..3b3485d03d --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/init.lua @@ -0,0 +1,148 @@ +local cluster_base = require "st.matter.cluster_base" +local ThreadBorderRouterManagementServerAttributes = require "ThreadBorderRouterManagement.server.attributes" +local ThreadBorderRouterManagementServerCommands = require "ThreadBorderRouterManagement.server.commands" +local ThreadBorderRouterManagementClientCommands = require "ThreadBorderRouterManagement.client.commands" +local ThreadBorderRouterManagementTypes = require "ThreadBorderRouterManagement.types" + +local ThreadBorderRouterManagement = {} + +ThreadBorderRouterManagement.ID = 0x0452 +ThreadBorderRouterManagement.NAME = "ThreadBorderRouterManagement" +ThreadBorderRouterManagement.server = {} +ThreadBorderRouterManagement.client = {} +ThreadBorderRouterManagement.server.attributes = ThreadBorderRouterManagementServerAttributes:set_parent_cluster(ThreadBorderRouterManagement) +ThreadBorderRouterManagement.server.commands = ThreadBorderRouterManagementServerCommands:set_parent_cluster(ThreadBorderRouterManagement) +ThreadBorderRouterManagement.client.commands = ThreadBorderRouterManagementClientCommands:set_parent_cluster(ThreadBorderRouterManagement) +ThreadBorderRouterManagement.types = ThreadBorderRouterManagementTypes + +function ThreadBorderRouterManagement:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "BorderRouterName", + [0x0001] = "BorderAgentID", + [0x0002] = "ThreadVersion", + [0x0003] = "InterfaceEnabled", + [0x0004] = "ActiveDatasetTimestamp", + [0x0005] = "PendingDatasetTimestamp", + [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 ThreadBorderRouterManagement:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "GetActiveDatasetRequest", + [0x0001] = "GetPendingDatasetRequest", + [0x0003] = "SetActiveDatasetRequest", + [0x0004] = "SetPendingDatasetRequest", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +function ThreadBorderRouterManagement:get_client_command_by_id(command_id) + local client_id_map = { + [0x0002] = "DatasetResponse", + } + if client_id_map[command_id] ~= nil then + return self.client.commands[client_id_map[command_id]] + end + return nil +end + +ThreadBorderRouterManagement.attribute_direction_map = { + ["BorderRouterName"] = "server", + ["BorderAgentID"] = "server", + ["ThreadVersion"] = "server", + ["InterfaceEnabled"] = "server", + ["ActiveDatasetTimestamp"] = "server", + ["PendingDatasetTimestamp"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +do + local has_aliases, aliases = pcall(require, "st.matter.clusters.aliases.ThreadBorderRouterManagement.server.attributes") + if has_aliases then + for alias, _ in pairs(aliases) do + ThreadBorderRouterManagement.attribute_direction_map[alias] = "server" + end + end +end + +ThreadBorderRouterManagement.command_direction_map = { + ["GetActiveDatasetRequest"] = "server", + ["GetPendingDatasetRequest"] = "server", + ["SetActiveDatasetRequest"] = "server", + ["SetPendingDatasetRequest"] = "server", + ["DatasetResponse"] = "client", +} + +do + local has_aliases, aliases = pcall(require, "st.matter.clusters.aliases.ThreadBorderRouterManagement.server.commands") + if has_aliases then + for alias, _ in pairs(aliases) do + ThreadBorderRouterManagement.command_direction_map[alias] = "server" + end + end +end + +do + local has_aliases, aliases = pcall(require, "st.matter.clusters.aliases.ThreadBorderRouterManagement.client.commands") + if has_aliases then + for alias, _ in pairs(aliases) do + ThreadBorderRouterManagement.command_direction_map[alias] = "client" + end + end +end + +ThreadBorderRouterManagement.FeatureMap = ThreadBorderRouterManagement.types.Feature + +function ThreadBorderRouterManagement.are_features_supported(feature, feature_map) + if (ThreadBorderRouterManagement.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 = ThreadBorderRouterManagement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ThreadBorderRouterManagement.NAME)) + end + return ThreadBorderRouterManagement[direction].attributes[key] +end +ThreadBorderRouterManagement.attributes = {} +setmetatable(ThreadBorderRouterManagement.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = ThreadBorderRouterManagement.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, ThreadBorderRouterManagement.NAME)) + end + return ThreadBorderRouterManagement[direction].commands[key] +end +ThreadBorderRouterManagement.commands = {} +setmetatable(ThreadBorderRouterManagement.commands, command_helper_mt) + +local event_helper_mt = {} +event_helper_mt.__index = function(self, key) + return ThreadBorderRouterManagement.server.events[key] +end +ThreadBorderRouterManagement.events = {} +setmetatable(ThreadBorderRouterManagement.events, event_helper_mt) + +setmetatable(ThreadBorderRouterManagement, {__index = cluster_base}) + +return ThreadBorderRouterManagement + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ActiveDatasetTimestamp.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ActiveDatasetTimestamp.lua new file mode 100644 index 0000000000..b0dc67b590 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ActiveDatasetTimestamp.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ActiveDatasetTimestamp = { + ID = 0x0004, + NAME = "ActiveDatasetTimestamp", + base_type = require "st.matter.data_types.Uint64", +} + +function ActiveDatasetTimestamp:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function ActiveDatasetTimestamp:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ActiveDatasetTimestamp:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ActiveDatasetTimestamp:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ActiveDatasetTimestamp: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 ActiveDatasetTimestamp:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(ActiveDatasetTimestamp, {__call = ActiveDatasetTimestamp.new_value, __index = ActiveDatasetTimestamp.base_type}) +return ActiveDatasetTimestamp + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/BorderRouterName.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/BorderRouterName.lua new file mode 100644 index 0000000000..33fd11c111 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/BorderRouterName.lua @@ -0,0 +1,69 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local BorderRouterName = { + ID = 0x0000, + NAME = "BorderRouterName", + base_type = require "st.matter.data_types.UTF8String1", +} + +function BorderRouterName:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function BorderRouterName:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + + +function BorderRouterName:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function BorderRouterName:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function BorderRouterName: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 BorderRouterName:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(BorderRouterName, {__call = BorderRouterName.new_value, __index = BorderRouterName.base_type}) +return BorderRouterName + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/InterfaceEnabled.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/InterfaceEnabled.lua new file mode 100644 index 0000000000..c2676bfaf0 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/InterfaceEnabled.lua @@ -0,0 +1,69 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local InterfaceEnabled = { + ID = 0x0003, + NAME = "InterfaceEnabled", + base_type = require "st.matter.data_types.Boolean", +} + +function InterfaceEnabled:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function InterfaceEnabled:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + + +function InterfaceEnabled:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function InterfaceEnabled:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function InterfaceEnabled: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 InterfaceEnabled:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(InterfaceEnabled, {__call = InterfaceEnabled.new_value, __index = InterfaceEnabled.base_type}) +return InterfaceEnabled + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ThreadVersion.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ThreadVersion.lua new file mode 100644 index 0000000000..6630c863e9 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/ThreadVersion.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local ThreadVersion = { + ID = 0x0002, + NAME = "ThreadVersion", + base_type = require "st.matter.data_types.Uint16", +} + +function ThreadVersion:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function ThreadVersion:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ThreadVersion:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function ThreadVersion:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function ThreadVersion: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 ThreadVersion:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(ThreadVersion, {__call = ThreadVersion.new_value, __index = ThreadVersion.base_type}) +return ThreadVersion + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/init.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/init.lua new file mode 100644 index 0000000000..96cad47fdd --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/attributes/init.lua @@ -0,0 +1,24 @@ +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("ThreadBorderRouterManagement.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 ThreadBorderRouterManagementServerAttributes = {} + +function ThreadBorderRouterManagementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ThreadBorderRouterManagementServerAttributes, attr_mt) + +return ThreadBorderRouterManagementServerAttributes + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/GetActiveDatasetRequest.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/GetActiveDatasetRequest.lua new file mode 100644 index 0000000000..b63982575e --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/GetActiveDatasetRequest.lua @@ -0,0 +1,79 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local GetActiveDatasetRequest = {} + +GetActiveDatasetRequest.NAME = "GetActiveDatasetRequest" +GetActiveDatasetRequest.ID = 0x0000 +GetActiveDatasetRequest.field_defs = { +} + +function GetActiveDatasetRequest:init(device, endpoint_id) + local out = {} + local args = {} + 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 = GetActiveDatasetRequest, + __tostring = GetActiveDatasetRequest.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function GetActiveDatasetRequest:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function GetActiveDatasetRequest: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 GetActiveDatasetRequest:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(GetActiveDatasetRequest, {__call = GetActiveDatasetRequest.init}) + +return GetActiveDatasetRequest diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/init.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/init.lua new file mode 100644 index 0000000000..ec75068d8e --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/server/commands/init.lua @@ -0,0 +1,28 @@ +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("ThreadBorderRouterManagement.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 ThreadBorderRouterManagementServerCommands = {} + +function ThreadBorderRouterManagementServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ThreadBorderRouterManagementServerCommands, command_mt) + +local status, aliases = pcall(require, "st.matter.clusters.aliases.ThreadBorderRouterManagement.server.commands") +if status then + aliases:add_to_class(ThreadBorderRouterManagementServerCommands) +end + +return ThreadBorderRouterManagementServerCommands + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/Feature.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/Feature.lua new file mode 100644 index 0000000000..c8116cd481 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/Feature.lua @@ -0,0 +1,54 @@ +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.PAN_CHANGE = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + PAN_CHANGE = 0x0001, +} + +Feature.is_pan_change_set = function(self) + return (self.value & self.PAN_CHANGE) ~= 0 +end + +Feature.set_pan_change = function(self) + if self.value ~= nil then + self.value = self.value | self.PAN_CHANGE + else + self.value = self.PAN_CHANGE + end +end + +Feature.unset_pan_change = function(self) + self.value = self.value & (~self.PAN_CHANGE & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.PAN_CHANGE + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_pan_change_set = Feature.is_pan_change_set, + set_pan_change = Feature.set_pan_change, + unset_pan_change = Feature.unset_pan_change, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature + diff --git a/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/init.lua b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/init.lua new file mode 100644 index 0000000000..3aece4eef2 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/ThreadBorderRouterManagement/types/init.lua @@ -0,0 +1,15 @@ +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("ThreadBorderRouterManagement.types." .. key) + end + return types_mt.__types_cache[key] +end + +local ThreadBorderRouterManagementTypes = {} + +setmetatable(ThreadBorderRouterManagementTypes, types_mt) + +return ThreadBorderRouterManagementTypes + diff --git a/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/init.lua b/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/init.lua new file mode 100644 index 0000000000..061ee5854f --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/init.lua @@ -0,0 +1,58 @@ +local cluster_base = require "st.matter.cluster_base" +local WiFiNetworkManagementServerAttributes = require "WiFiNetworkManagement.server.attributes" + +local WiFiNetworkManagement = {} + +WiFiNetworkManagement.ID = 0x0451 +WiFiNetworkManagement.NAME = "WiFiNetworkManagement" +WiFiNetworkManagement.server = {} +WiFiNetworkManagement.client = {} +WiFiNetworkManagement.server.attributes = WiFiNetworkManagementServerAttributes:set_parent_cluster(WiFiNetworkManagement) + +function WiFiNetworkManagement:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "Ssid", + [0x0001] = "PassphraseSurrogate", + [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 + +WiFiNetworkManagement.attribute_direction_map = { + ["Ssid"] = "server", + ["PassphraseSurrogate"] = "server", + ["AcceptedCommandList"] = "server", + ["EventList"] = "server", + ["AttributeList"] = "server", +} + +do + local has_aliases, aliases = pcall(require, "st.matter.clusters.aliases.WiFiNetworkManagement.server.attributes") + if has_aliases then + for alias, _ in pairs(aliases) do + WiFiNetworkManagement.attribute_direction_map[alias] = "server" + end + end +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = WiFiNetworkManagement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, WiFiNetworkManagement.NAME)) + end + return WiFiNetworkManagement[direction].attributes[key] +end +WiFiNetworkManagement.attributes = {} +setmetatable(WiFiNetworkManagement.attributes, attribute_helper_mt) + +setmetatable(WiFiNetworkManagement, {__index = cluster_base}) + +return WiFiNetworkManagement + diff --git a/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/Ssid.lua b/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/Ssid.lua new file mode 100644 index 0000000000..e7a2a43fb5 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/Ssid.lua @@ -0,0 +1,68 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Ssid = { + ID = 0x0000, + NAME = "Ssid", + base_type = require "st.matter.data_types.OctetString1", +} + +function Ssid:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function Ssid:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Ssid:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function Ssid:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Ssid: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 Ssid:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(Ssid, {__call = Ssid.new_value, __index = Ssid.base_type}) +return Ssid + diff --git a/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/init.lua b/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/init.lua new file mode 100644 index 0000000000..de0b55c8b1 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/WiFiNetworkManagement/server/attributes/init.lua @@ -0,0 +1,24 @@ +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("WiFiNetworkManagement.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 WiFiNetworkManagementServerAttributes = {} + +function WiFiNetworkManagementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(WiFiNetworkManagementServerAttributes, attr_mt) + +return WiFiNetworkManagementServerAttributes + diff --git a/drivers/SmartThings/matter-hrap/src/embedded-cluster-utils.lua b/drivers/SmartThings/matter-hrap/src/embedded-cluster-utils.lua new file mode 100644 index 0000000000..ca36cd7562 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/embedded-cluster-utils.lua @@ -0,0 +1,62 @@ +-- 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" +local version = require "version" + +if version.api < 13 then + clusters.ThreadBorderRouterManagement = require "ThreadBorderRouterManagement" +end + +local embedded_cluster_utils = {} + +local embedded_clusters_api_13 = { + [clusters.ThreadBorderRouterManagement.ID] = clusters.ThreadBorderRouterManagement, +} + +function embedded_cluster_utils.get_endpoints(device, cluster_id, opts) + -- If using older lua libs and need to check for an embedded cluster feature, + -- we must use the embedded cluster definitions here + if version.api < 13 and embedded_clusters_api_13[cluster_id] ~= nil then + local embedded_cluster = embedded_clusters_api_13[cluster_id] + if not opts then opts = {} end + if utils.table_size(opts) > 1 then + device.log.warn_with({hub_logs = true}, "Invalid options for get_endpoints") + return + end + local clus_has_features = function(clus, feature_bitmap) + if not feature_bitmap or not clus then return false end + return embedded_cluster.are_features_supported(feature_bitmap, clus.feature_map) + end + local eps = {} + for _, ep in ipairs(device.endpoints) do + for _, clus in ipairs(ep.clusters) do + if ((clus.cluster_id == cluster_id) + and (opts.feature_bitmap == nil or clus_has_features(clus, opts.feature_bitmap)) + and ((opts.cluster_type == nil and clus.cluster_type == "SERVER" or clus.cluster_type == "BOTH") + or (opts.cluster_type == clus.cluster_type)) + or (cluster_id == nil)) then + table.insert(eps, ep.endpoint_id) + if cluster_id == nil then break end + end + end + end + return eps + else + return device:get_endpoints(cluster_id, opts) + end +end + +return embedded_cluster_utils diff --git a/drivers/SmartThings/matter-hrap/src/init.lua b/drivers/SmartThings/matter-hrap/src/init.lua new file mode 100644 index 0000000000..df05e7ce83 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/init.lua @@ -0,0 +1,217 @@ +-- 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 MatterDriver = require "st.matter.driver" +local data_types = require "st.matter.data_types" +local im = require "st.matter.interaction_model" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local lustre_utils = require "lustre.utils" +local st_utils = require "st.utils" +local version = require "version" +local log = require "log" + +local CURRENT_ACTIVE_DATASET_TIMESTAMP = "__CURRENT_ACTIVE_DATASET_TIMESTAMP" +local GET_ACTIVE_DATASET_RETRY_ATTEMPTS = "__GET_ACTIVE_DATASET_RETRY_ATTEMPTS" + +-- Include driver-side definitions when lua libs api version is <13 +if version.api < 13 then + clusters.ThreadBorderRouterManagement = require "ThreadBorderRouterManagement" + clusters.WiFiNetworkManagement = require "WiFiNetworkManagement" +end + + +--[[ ATTRIBUTE HANDLERS ]]-- + +local function border_router_name_attribute_handler(driver, device, ib) + -- per the spec, the recommended attribute format is ._meshcop._udp. This logic removes the MeshCoP suffix IFF it is present + local meshCop_name = ib.data.value + local terminal_display_char = (string.find(meshCop_name, "._meshcop._udp") or 64) - 1 -- where 64-1=63, the maximum allowed length for BorderRouterName + local display_name = string.sub(meshCop_name, 1, terminal_display_char) + device:emit_event_for_endpoint(ib.endpoint, capabilities.threadBorderRouter.borderRouterName({ value = display_name })) +end + +local function ssid_attribute_handler(driver, device, ib) + if (ib.data.value == string.char(data_types.Null.ID) or ib.data.value == nil) then -- Matter TLV-encoded NULL or Lua-encoded NULL + device.log.info("No primary Wi-Fi network is available") + return + end + local valid_utf8, utf8_err = lustre_utils.validate_utf8(ib.data.value) + if valid_utf8 then + device:emit_event_for_endpoint(ib.endpoint, capabilities.wifiInformation.ssid({ value = ib.data.value })) + else + device.log.info("UTF-8 validation of SSID failed: Error: '"..utf8_err.."'") + end +end + +local function thread_interface_enabled_attribute_handler(driver, device, ib) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint, capabilities.threadBorderRouter.threadInterfaceState("enabled")) + else + device:emit_event_for_endpoint(ib.endpoint, capabilities.threadBorderRouter.threadInterfaceState("disabled")) + end +end + +-- Spec uses TLV encoding of Thread Version, which should be mapped to a more human-readable name +local VERSION_TLV_MAP = { + [1] = "1.0.0", + [2] = "1.1.0", + [3] = "1.2.0", + [4] = "1.3.0", + [5] = "1.4.0", +} + +local function thread_version_attribute_handler(driver, device, ib) + local version_name = VERSION_TLV_MAP[ib.data.value] + if version_name then + device:emit_event_for_endpoint(ib.endpoint, capabilities.threadBorderRouter.threadVersion({ value = version_name })) + else + device.log.warn("The received TLV-encoded Thread version does not have a provided mapping to a human-readable version format") + end +end + +local function active_dataset_timestamp_handler(driver, device, ib) + if not ib.data.value then + device.log.info("No Thread operational dataset configured") + elseif ib.data.value ~= device:get_field(CURRENT_ACTIVE_DATASET_TIMESTAMP) then + device:send(clusters.ThreadBorderRouterManagement.server.commands.GetActiveDatasetRequest(device, ib.endpoint_id)) + device:set_field(CURRENT_ACTIVE_DATASET_TIMESTAMP, ib.data.value, { persist = true }) + end +end + + +--[[ COMMAND HANDLERS ]]-- + +local threadNetwork = capabilities.threadNetwork +local TLV_TYPE_ATTR_MAP = { + [0] = threadNetwork.channel, + [1] = threadNetwork.panId, + [2] = threadNetwork.extendedPanId, + [3] = threadNetwork.networkName, + -- [4] intentionally omitted, refers to the MeshCoP PSKc + [5] = threadNetwork.networkKey, +} + +local function dataset_response_handler(driver, device, ib) + if not ib.info_block.data.elements.dataset then + device.log.debug_with({ hub_logs = true }, "Received empty Thread operational dataset") + return + end + + local operational_dataset_length = ib.info_block.data.elements.dataset.byte_length + local spec_defined_max_dataset_length = 254 + if operational_dataset_length > spec_defined_max_dataset_length then + device.log.error_with({ hub_logs = true }, "Received Thread operational dataset is too long") + return + end + + -- parse dataset + local operational_dataset = ib.info_block.data.elements.dataset.value + local cur_byte = 1 + while cur_byte + 1 <= operational_dataset_length do + local tlv_type = string.byte(operational_dataset, cur_byte) + local tlv_length = string.byte(operational_dataset, cur_byte + 1) + if (cur_byte + 1 + tlv_length) > operational_dataset_length then + device.log.error_with({ hub_logs = true }, "Received Thread operational dataset has a malformed TLV encoding") + return + end + local tlv_mapped_attr = TLV_TYPE_ATTR_MAP[tlv_type] + if tlv_mapped_attr then + -- extract the value from a TLV-encoded message. Message format: byte tag + byte length + length byte value + local tlv_value = operational_dataset:sub(cur_byte + 2, cur_byte + 1 + tlv_length) + -- format data as required by threadNetwork attribute properties + if tlv_mapped_attr == threadNetwork.channel or tlv_mapped_attr == threadNetwork.panId then + tlv_value = st_utils.deserialize_int(tlv_value, tlv_length) + elseif tlv_mapped_attr ~= threadNetwork.networkName then + tlv_value = st_utils.bytes_to_hex_string(tlv_value) + end + device:emit_event(tlv_mapped_attr({ value = tlv_value })) + end + cur_byte = cur_byte + 2 + tlv_length + end +end + +local function get_active_dataset_response_handler(driver, device, ib) + if ib.status == im.InteractionResponse.Status.FAILURE then + -- per spec, on a GetActiveDatasetRequest failure, a failure response is sent over the same command. + -- on failure, retry the read up to 3 times before failing out. + local retries_attempted = device:get_field(GET_ACTIVE_DATASET_RETRY_ATTEMPTS) or 0 + if retries_attempted < 3 then + device.log.error_with({ hub_logs = true }, "Failed to retrieve Thread operational dataset. Retrying " .. retries_attempted + 1 .. "/3") + device:set_field(GET_ACTIVE_DATASET_RETRY_ATTEMPTS, retries_attempted + 1) + else + -- do not retry again, but reset the count to 0. + device:set_field(GET_ACTIVE_DATASET_RETRY_ATTEMPTS, 0) + end + elseif ib.status == im.InteractionResponse.Status.UNSUPPORTED_ACCESS then + device.log.error_with({ hub_logs = true }, + "Failed to retrieve Thread operational dataset, since the GetActiveDatasetRequest command was not executed over CASE" + ) + end +end + + +--[[ LIFECYCLE HANDLERS ]]-- + +local function device_init(driver, device) + device:subscribe() +end + + +--[[ MATTER DRIVER TEMPLATE ]]-- + +local matter_driver_template = { + lifecycle_handlers = { + init = device_init, + }, + matter_handlers = { + attr = { + [clusters.WiFiNetworkManagement.ID] = { + [clusters.WiFiNetworkManagement.attributes.Ssid.ID] = ssid_attribute_handler, + }, + [clusters.ThreadBorderRouterManagement.ID] = { + [clusters.ThreadBorderRouterManagement.attributes.BorderRouterName.ID] = border_router_name_attribute_handler, + [clusters.ThreadBorderRouterManagement.attributes.ThreadVersion.ID] = thread_version_attribute_handler, + [clusters.ThreadBorderRouterManagement.attributes.InterfaceEnabled.ID] = thread_interface_enabled_attribute_handler, + [clusters.ThreadBorderRouterManagement.attributes.ActiveDatasetTimestamp.ID] = active_dataset_timestamp_handler, + } + }, + cmd_response = { + [clusters.ThreadBorderRouterManagement.ID] = { + [clusters.ThreadBorderRouterManagement.client.commands.DatasetResponse.ID] = dataset_response_handler, + [clusters.ThreadBorderRouterManagement.server.commands.GetActiveDatasetRequest.ID] = get_active_dataset_response_handler, + } + } + }, + subscribed_attributes = { + [capabilities.threadBorderRouter.ID] = { + clusters.ThreadBorderRouterManagement.attributes.ActiveDatasetTimestamp, + clusters.ThreadBorderRouterManagement.attributes.BorderRouterName, + clusters.ThreadBorderRouterManagement.attributes.InterfaceEnabled, + clusters.ThreadBorderRouterManagement.attributes.ThreadVersion, + }, + [capabilities.wifiInformation.ID] = { + clusters.WiFiNetworkManagement.attributes.Ssid, + } + }, + supported_capabilities = { + capabilities.threadBorderRouter, + capabilities.threadNetwork, + capabilities.wifiInformation, + } +} + +local matter_driver = MatterDriver("matter-hrap", 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-hrap/src/test/test_thread_border_router_network.lua b/drivers/SmartThings/matter-hrap/src/test/test_thread_border_router_network.lua new file mode 100644 index 0000000000..0467e19de3 --- /dev/null +++ b/drivers/SmartThings/matter-hrap/src/test/test_thread_border_router_network.lua @@ -0,0 +1,342 @@ +-- 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 test = require "integration_test" +local t_utils = require "integration_test.utils" +local data_types = require "st.matter.data_types" +local capabilities = require "st.capabilities" + +local clusters = require "st.matter.clusters" +clusters.ThreadBorderRouterManagement = require "ThreadBorderRouterManagement" +clusters.WifiNetworkMangement = require "WiFiNetworkManagement" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("network-infrastructure-manager.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1,} -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.ThreadBorderRouterManagement.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.WifiNetworkMangement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0090, device_type_revision = 1,} -- Network Infrastructure Manager + } + } + } +}) + +local cluster_subscribe_list = { + clusters.ThreadBorderRouterManagement.attributes.ActiveDatasetTimestamp, + clusters.ThreadBorderRouterManagement.attributes.BorderRouterName, + clusters.ThreadBorderRouterManagement.attributes.InterfaceEnabled, + clusters.ThreadBorderRouterManagement.attributes.ThreadVersion, + clusters.WifiNetworkMangement.attributes.Ssid, +} + +local function test_init() + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "ThreadVersion should display the correct stringified version", + function() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.ThreadVersion:build_test_report_data( + mock_device, 1, 3 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadBorderRouter.threadVersion({ value = "1.2.0" })) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.ThreadVersion:build_test_report_data( + mock_device, 1, 4 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadBorderRouter.threadVersion({ value = "1.3.0" })) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.ThreadVersion:build_test_report_data( + mock_device, 1, 5 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadBorderRouter.threadVersion({ value = "1.4.0" })) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.ThreadVersion:build_test_report_data( + mock_device, 1, 6 + ) + }) + end +) + +test.register_message_test( + "InterfaceEnabled should correctly display enabled or disabled", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.InterfaceEnabled:build_test_report_data(mock_device, 1, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.threadBorderRouter.threadInterfaceState("enabled")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.InterfaceEnabled:build_test_report_data(mock_device, 1, false) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.threadBorderRouter.threadInterfaceState("disabled")) + } + } +) + +test.register_message_test( + "BorderRouterName should correctly display the given name", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.BorderRouterName:build_test_report_data(mock_device, 1, "john foo._meshcop._udp") + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.threadBorderRouter.borderRouterName({ value = "john foo"})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.BorderRouterName:build_test_report_data(mock_device, 1, "jane bar._meshcop._udp") + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.threadBorderRouter.borderRouterName({ value = "jane bar"})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ThreadBorderRouterManagement.attributes.BorderRouterName:build_test_report_data(mock_device, 1, "john foo no suffix") + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.threadBorderRouter.borderRouterName({ value = "john foo no suffix"})) + }, + } +) + +test.register_message_test( + "wifiInformation capability should correctly display the Ssid", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.WifiNetworkMangement.attributes.Ssid:build_test_report_data(mock_device, 1, "test name for ssid!") + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.wifiInformation.ssid({ value = "test name for ssid!" })) + } + } +) + +test.register_message_test( + "Null-valued ssid (TLV 0x14) should correctly fail", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.WifiNetworkMangement.attributes.Ssid:build_test_report_data(mock_device, 1, string.char(data_types.Null.ID)) + } + } + } +) + +test.register_message_test( + "Ssid inputs using non-UTF8 encoding should not display an Ssid", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.WifiNetworkMangement.attributes.Ssid:build_test_report_data(mock_device, 1, string.char(0xC0)) -- 0xC0 never appears in utf8 + } + } + } +) + +local hex_dataset = [[ +0E 08 00 00 68 87 D0 B2 00 00 00 03 00 00 18 35 +06 00 04 00 1F FF C0 02 08 25 31 25 A9 B2 16 7F +35 07 08 FD 6E D1 57 02 B4 CD BF 05 10 33 AF 36 +F8 13 8E 8F F9 50 6D 67 22 9B FD F2 40 03 0D 53 +54 2D 35 30 33 32 30 30 31 31 39 36 01 02 D9 78 +04 10 E2 29 D8 2A 84 B2 7D A1 AC 8D D8 71 64 AC +66 7F 0C 04 02 A0 FF F8 +]] + +local serializable_hex_dataset = hex_dataset:gsub("%s+", ""):gsub("..", function(cc) + return string.char(tonumber(cc, 16)) +end) + +test.register_coroutine_test( + "Thread DatasetResponse parsing should emit the correct capability events on an ActiveDatasetTimestamp update. Else, nothing should happen", + function() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.server.attributes.ActiveDatasetTimestamp:build_test_report_data( + mock_device, + 1, + 1 + ) + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ThreadBorderRouterManagement.server.commands.GetActiveDatasetRequest(mock_device, 1), + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.client.commands.DatasetResponse:build_test_command_response( + mock_device, + 1, + serializable_hex_dataset + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.channel({ value = 24 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.extendedPanId({ value = "253125a9b2167f35" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.networkKey({ value = "33af36f8138e8ff9506d67229bfdf240" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.networkName({ value = "ST-5032001196" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.panId({ value = 55672 })) + ) + test.wait_for_events() + + -- after some amount of time, a device init occurs or we re-subscribe for other reasons. + -- Since no change to the ActiveDatasetTimestamp has occurred, no re-read should occur + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.server.attributes.ActiveDatasetTimestamp:build_test_report_data( + mock_device, + 1, + 1 + ) + }) + test.wait_for_events() + + -- after some more amount of time, a device init occurs or we re-subscribe for other reasons. + -- This time, their ActiveDatasetTimestamp has updated, so we should re-read the operational dataset. + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.server.attributes.ActiveDatasetTimestamp:build_test_report_data( + mock_device, + 1, + 2 + ) + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ThreadBorderRouterManagement.server.commands.GetActiveDatasetRequest(mock_device, 1), + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ThreadBorderRouterManagement.client.commands.DatasetResponse:build_test_command_response( + mock_device, + 1, + serializable_hex_dataset + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.channel({ value = 24 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.extendedPanId({ value = "253125a9b2167f35" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.networkKey({ value = "33af36f8138e8ff9506d67229bfdf240" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.networkName({ value = "ST-5032001196" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threadNetwork.panId({ value = 55672 })) + ) + end +) + +test.run_registered_tests()