From b78fdd1d3e38777339a91d9c8283cece63bd17ac Mon Sep 17 00:00:00 2001
From: jdomnitz <380352+jdomnitz@users.noreply.github.com>
Date: Sun, 12 Jan 2025 01:39:20 -0500
Subject: [PATCH] Support for bluetooth le
---
ExampleConsole/ExampleConsole.csproj | 2 +-
MatterDotNet/Entities/Node.cs | 32 ++-
MatterDotNet/MatterDotNet.csproj | 39 +--
.../BTDiscoveryService.cs | 116 +++++++++
.../OperationalDiscovery/FabricInterface.cs | 46 ++++
...coveryService.cs => IPDiscoveryService.cs} | 55 ++--
.../Protocol/Connection/BLEEndPoint.cs | 38 +++
.../Protocol/Connection/BTPConnection.cs | 245 ++++++++++++++++++
MatterDotNet/Protocol/Payloads/BTPFrame.cs | 136 ++++++++++
.../Protocol/Payloads/Flags/BTPFlags.cs | 26 ++
.../Payloads/OpCodes/BTPManagementOpcode.cs | 26 ++
MatterDotNet/Protocol/Payloads/TLVPayload.cs | 2 +
.../Protocol/Sessions/SessionManager.cs | 24 +-
.../Subprotocols/InteractionManager.cs | 2 +-
README.md | 17 +-
15 files changed, 730 insertions(+), 76 deletions(-)
create mode 100644 MatterDotNet/OperationalDiscovery/BTDiscoveryService.cs
create mode 100644 MatterDotNet/OperationalDiscovery/FabricInterface.cs
rename MatterDotNet/OperationalDiscovery/{DiscoveryService.cs => IPDiscoveryService.cs} (80%)
create mode 100644 MatterDotNet/Protocol/Connection/BLEEndPoint.cs
create mode 100644 MatterDotNet/Protocol/Connection/BTPConnection.cs
create mode 100644 MatterDotNet/Protocol/Payloads/BTPFrame.cs
create mode 100644 MatterDotNet/Protocol/Payloads/Flags/BTPFlags.cs
create mode 100644 MatterDotNet/Protocol/Payloads/OpCodes/BTPManagementOpcode.cs
diff --git a/ExampleConsole/ExampleConsole.csproj b/ExampleConsole/ExampleConsole.csproj
index 676d59b..45212e7 100644
--- a/ExampleConsole/ExampleConsole.csproj
+++ b/ExampleConsole/ExampleConsole.csproj
@@ -2,7 +2,7 @@
Exe
- net8.0
+ net8.0-windows10.0.19041.0; net8.0
enable
enable
diff --git a/MatterDotNet/Entities/Node.cs b/MatterDotNet/Entities/Node.cs
index 2c3c011..6635167 100644
--- a/MatterDotNet/Entities/Node.cs
+++ b/MatterDotNet/Entities/Node.cs
@@ -13,6 +13,7 @@
using MatterDotNet.Clusters.Utility;
using MatterDotNet.OperationalDiscovery;
using MatterDotNet.PKI;
+using MatterDotNet.Protocol.Connection;
using MatterDotNet.Protocol.Sessions;
using MatterDotNet.Protocol.Subprotocols;
using System.Net;
@@ -56,15 +57,23 @@ private Node(ODNode connection, Fabric fabric, OperationalCertificate noc)
///
public async Task GetCASESession()
{
- using (SessionContext session = SessionManager.GetUnsecureSession(new IPEndPoint(connection.IPAddress!, connection.Port), true))
- return await GetCASESession(session);
+ if (connection.IPAddress != null)
+ {
+ using (SessionContext session = SessionManager.GetUnsecureSession(new IPEndPoint(connection.IPAddress!, connection.Port), true))
+ return await GetCASESession(session);
+ }
+ else
+ {
+ using (SessionContext session = SessionManager.GetUnsecureSession(new BLEEndPoint(connection.BTAddress), true))
+ return await GetCASESession(session);
+ }
}
///
/// Get a secure session for the node
///
///
///
- public async Task GetCASESession(SessionContext session)
+ internal async Task GetCASESession(SessionContext session)
{
CASE caseProtocol = new CASE(session);
//TODO - Use OD session params
@@ -74,15 +83,18 @@ public async Task GetCASESession(SessionContext session)
return caseSession;
}
- internal static Node CreateTemp(OperationalCertificate noc, Fabric fabric, ODNode opInfo)
+ internal static Node CreateTemp(OperationalCertificate noc, Fabric fabric, ODNode opInfo, EndPoint rootNode)
{
- return new Node(opInfo, fabric, noc);
+ Node node = new Node(opInfo, fabric, noc);
+ rootNode.SetNode(node);
+ node.root = rootNode;
+ return node;
}
internal static async Task Enumerate(OperationalCertificate noc, Fabric fabric)
{
string operationalInstanceName = $"{Convert.ToHexString(fabric.CompressedFabricID)}-{noc.NodeID:X16}";
- ODNode? opInfo = await DiscoveryService.Shared.Find(operationalInstanceName);
+ ODNode? opInfo = await IPDiscoveryService.Shared.Find(operationalInstanceName);
if (opInfo == null)
return null;
return await Enumerate(noc, fabric, opInfo);
@@ -112,6 +124,14 @@ internal static async Task Populate(SecureSession session, Node node)
await node.Root.EnumerateClusters(session);
}
+ internal string OperationalInstanceName
+ {
+ get
+ {
+ return $"{Convert.ToHexString(fabric.CompressedFabricID)}-{noc.NodeID:X16}";
+ }
+ }
+
///
public override string ToString()
{
diff --git a/MatterDotNet/MatterDotNet.csproj b/MatterDotNet/MatterDotNet.csproj
index 9e56041..56dc384 100644
--- a/MatterDotNet/MatterDotNet.csproj
+++ b/MatterDotNet/MatterDotNet.csproj
@@ -1,24 +1,30 @@
+ enable
+ enable
+ 0.1.0
+ jdomnitz
+ SmartHomeOS and Contributors
+ AGPL-3.0-or-later
+ MatterDotNet
+ A C# implementation of the Matter 1.3 Standard (Formally known as project chip)
+ Copyright MatterDotNet Contributors
+ README.md
+ https://github.com/SmartHomeOS/MatterDotNet/
+ matter; matter-controller; smarthome; project-chip; dotnet;
+ Library is not yet functional. See README for details.
+ logo.png
+ True
+
+
+
+ net8.0-windows10.0.19041.0; net9.0-windows10.0.19041.0; net8.0
+
+
net8.0; net9.0
- enable
- enable
- 0.1.0
- jdomnitz
- SmartHomeOS and Contributors
- AGPL-3.0-or-later
- MatterDotNet
- A C# implementation of the Matter 1.3 Standard (Formally known as project chip)
- Copyright MatterDotNet Contributors
- README.md
- https://github.com/SmartHomeOS/MatterDotNet/
- matter; matter-controller; smarthome; project-chip; dotnet;
- Library is not yet functional. See README for details.
- logo.png
- True
-
+
True
True
@@ -40,6 +46,7 @@
+
diff --git a/MatterDotNet/OperationalDiscovery/BTDiscoveryService.cs b/MatterDotNet/OperationalDiscovery/BTDiscoveryService.cs
new file mode 100644
index 0000000..ad2be8f
--- /dev/null
+++ b/MatterDotNet/OperationalDiscovery/BTDiscoveryService.cs
@@ -0,0 +1,116 @@
+// MatterDotNet Copyright (C) 2025
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or any later version.
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY, without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+// See the GNU Affero General Public License for more details.
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+using InTheHand.Bluetooth;
+using MatterDotNet.Protocol.Connection;
+using System.Buffers.Binary;
+
+namespace MatterDotNet.OperationalDiscovery
+{
+ ///
+ /// Bluetooth LE Operational Discovery Service
+ ///
+ public static class BTDiscoveryService
+ {
+ ///
+ /// Generate commissionable node info from BLE advertisement
+ ///
+ ///
+ ///
+ ///
+ ///
+ private static ODNode FromAdvertisement(string id, string name, ReadOnlySpan bleAdvertisement)
+ {
+ ODNode ret = new ODNode();
+ if (bleAdvertisement[0] == 0x0)
+ ret.CommissioningMode = CommissioningMode.Basic;
+ ret.Discriminator = (ushort)(BinaryPrimitives.ReadUInt16LittleEndian(bleAdvertisement.Slice(1, 2)) & 0xFFF);
+ ret.Vendor = BinaryPrimitives.ReadUInt16LittleEndian(bleAdvertisement.Slice(3, 2));
+ ret.Product = BinaryPrimitives.ReadUInt16LittleEndian(bleAdvertisement.Slice(5, 2));
+ ret.DeviceName = name;
+ ret.BTAddress = id;
+ return ret;
+ }
+
+ ///
+ /// Discover all matter devices commissionable using Bluetooth LE
+ ///
+ ///
+ ///
+ public static async Task ScanAll()
+ {
+ Dictionary discoveredDevices = new Dictionary();
+ void Bluetooth_AdvertisementReceived(object? sender, BluetoothAdvertisingEvent e)
+ {
+ if (e.ServiceData.ContainsKey(BTPConnection.MATTER_UUID) && e.Device != null && !discoveredDevices.ContainsKey(e.Device.Id))
+ discoveredDevices.Add(e.Device.Id, FromAdvertisement(e.Device.Id, e.Device?.Name ?? e.Name, e.ServiceData[BTPConnection.MATTER_UUID]));
+ }
+ if (await Bluetooth.GetAvailabilityAsync() == false)
+ throw new PlatformNotSupportedException("No Bluetooth Adapter Found");
+ BluetoothLEScanOptions opts = new BluetoothLEScanOptions();
+ BluetoothLEScanFilter filter = new BluetoothLEScanFilter();
+ filter.Services.Add(BTPConnection.MATTER_UUID);
+ opts.Filters.Add(filter);
+ opts.AcceptAllAdvertisements = false;
+ opts.KeepRepeatedDevices = false;
+ Bluetooth.AdvertisementReceived += Bluetooth_AdvertisementReceived;
+ BluetoothLEScan scan = await Bluetooth.RequestLEScanAsync(opts);
+ await Task.Delay(3000);
+ scan.Stop();
+ Bluetooth.AdvertisementReceived -= Bluetooth_AdvertisementReceived;
+ return discoveredDevices.Values.ToArray();
+ }
+
+ ///
+ /// Find a Bluetooth device that matches the provided payload
+ ///
+ ///
+ ///
+ ///
+ public static async Task Find(CommissioningPayload payload)
+ {
+ SemaphoreSlim findLock = new SemaphoreSlim(0, 1);
+ ODNode? result = null;
+ void Bluetooth_AdvertisementReceived(object? sender, BluetoothAdvertisingEvent e)
+ {
+ if (e.ServiceData.ContainsKey(BTPConnection.MATTER_UUID))
+ {
+ ODNode found = FromAdvertisement(e.Device.Id, e.Device?.Name ?? e.Name, e.ServiceData[BTPConnection.MATTER_UUID]);
+ if (found.Vendor != payload.VendorID && payload.VendorID != 0 && found.Vendor != 0)
+ return;
+ if (found.Product != payload.ProductID && payload.ProductID != 0 && found.Product != 0)
+ return;
+ if (payload.LongDiscriminator && found.Discriminator != payload.Discriminator)
+ return;
+ if (!payload.LongDiscriminator && (found.Discriminator >> 8) != payload.Discriminator)
+ return;
+ result = found;
+ findLock.Release();
+ }
+ }
+ if (await Bluetooth.GetAvailabilityAsync() == false)
+ throw new InvalidOperationException("No Bluetooth");
+ BluetoothLEScanOptions opts = new BluetoothLEScanOptions();
+ BluetoothLEScanFilter filter = new BluetoothLEScanFilter();
+ filter.Services.Add(BTPConnection.MATTER_UUID);
+ opts.Filters.Add(filter);
+ opts.AcceptAllAdvertisements = false;
+ opts.KeepRepeatedDevices = false;
+ Bluetooth.AdvertisementReceived += Bluetooth_AdvertisementReceived;
+ BluetoothLEScan scan = await Bluetooth.RequestLEScanAsync(opts);
+ await findLock.WaitAsync(10000);
+ scan.Stop();
+ Bluetooth.AdvertisementReceived -= Bluetooth_AdvertisementReceived;
+ return result;
+ }
+ }
+}
diff --git a/MatterDotNet/OperationalDiscovery/FabricInterface.cs b/MatterDotNet/OperationalDiscovery/FabricInterface.cs
new file mode 100644
index 0000000..546da4e
--- /dev/null
+++ b/MatterDotNet/OperationalDiscovery/FabricInterface.cs
@@ -0,0 +1,46 @@
+// MatterDotNet Copyright (C) 2025
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or any later version.
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY, without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+// See the GNU Affero General Public License for more details.
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+namespace MatterDotNet.OperationalDiscovery
+{
+ ///
+ /// Connectivity method between a node and a controller
+ ///
+ [Flags]
+ public enum FabricInterface
+ {
+ ///
+ /// No connectivity Type
+ ///
+ None = 0x0,
+ ///
+ /// WiFi Connection
+ ///
+ WiFi = 0x1,
+ ///
+ /// Thread 802.11.4
+ ///
+ Thread = 0x2,
+ ///
+ /// Ethernet
+ ///
+ Ethernet = 0x4,
+ ///
+ /// One or more IP protocols (aka anything but bluetooth)
+ ///
+ IP = 0x8,
+ ///
+ /// Bluetooth LE
+ ///
+ Bluetooth = 0x10
+ }
+}
diff --git a/MatterDotNet/OperationalDiscovery/DiscoveryService.cs b/MatterDotNet/OperationalDiscovery/IPDiscoveryService.cs
similarity index 80%
rename from MatterDotNet/OperationalDiscovery/DiscoveryService.cs
rename to MatterDotNet/OperationalDiscovery/IPDiscoveryService.cs
index 07a008f..b196830 100644
--- a/MatterDotNet/OperationalDiscovery/DiscoveryService.cs
+++ b/MatterDotNet/OperationalDiscovery/IPDiscoveryService.cs
@@ -10,7 +10,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-using System.Buffers.Binary;
using TinyDNS;
using TinyDNS.Enums;
using TinyDNS.Records;
@@ -18,18 +17,18 @@
namespace MatterDotNet.OperationalDiscovery
{
///
- /// Operational Discovery Service
+ /// IP Operational Discovery Service
///
- public class DiscoveryService
+ public class IPDiscoveryService
{
bool running = false;
MDNS mdns = new MDNS();
- private static DiscoveryService service = new DiscoveryService();
+ private static IPDiscoveryService service = new IPDiscoveryService();
///
/// Return a shared instance of the discovery service
///
- public static DiscoveryService Shared
+ public static IPDiscoveryService Shared
{
get
{
@@ -46,41 +45,18 @@ public static DiscoveryService Shared
}
///
- /// Generate commissionable node info from BLE advertisement
+ /// Find IP commissionable nodes matching the given params
///
- ///
- ///
- ///
+ ///
///
- public static ODNode FromAdvertisement(string id, string name, ReadOnlySpan bleAdvertisement)
+ public async Task Find(CommissioningPayload commissioningPayload)
{
- ODNode ret = new ODNode();
- if (bleAdvertisement[0] == 0x0)
- ret.CommissioningMode = CommissioningMode.Basic;
- ret.Discriminator = (ushort)(BinaryPrimitives.ReadUInt16LittleEndian(bleAdvertisement.Slice(1, 2)) & 0xFFF);
- ret.Vendor = BinaryPrimitives.ReadUInt16LittleEndian(bleAdvertisement.Slice(3, 2));
- ret.Product = BinaryPrimitives.ReadUInt16LittleEndian(bleAdvertisement.Slice(5, 2));
- ret.DeviceName = name;
- ret.BTAddress = id;
- return ret;
- }
-
- ///
- /// Find commissionable nodes matching the given params
- ///
- ///
- ///
- ///
- ///
- ///
- public async Task Find(uint vendor, uint product, uint discriminator, bool fullLen)
- {
- List results = await Find(discriminator, fullLen);
+ List results = await Find(commissioningPayload.Discriminator, commissioningPayload.LongDiscriminator);
foreach (ODNode result in results)
{
- if (result.Vendor != 0 && result.Vendor != vendor)
+ if (result.Vendor != 0 && commissioningPayload.VendorID != 0 && result.Vendor != commissioningPayload.VendorID)
continue;
- if (result.Product != 0 && result.Product != product)
+ if (result.Product != 0 && commissioningPayload.ProductID != 0 && result.Product != commissioningPayload.ProductID)
continue;
return result;
}
@@ -88,7 +64,7 @@ public static ODNode FromAdvertisement(string id, string name, ReadOnlySpan
- /// Find commissionable nodes matching the given discriminator
+ /// Find IP commissionable nodes matching the given discriminator
///
///
///
@@ -127,11 +103,14 @@ public async Task> Find(uint discriminator, bool fullLen)
/// Query operational information about the provided instance
///
///
+ ///
///
- public async Task Find(string operationalInstanceName)
+ public async Task Find(string operationalInstanceName, bool extendedSearch = false)
{
+ Console.WriteLine("Looking for " + operationalInstanceName);
List results;
- for (int i = 0; i < 10; i++)
+ int length = extendedSearch ? 20 : 10; // 60 / 30 seconds
+ for (int i = 0; i < length; i++)
{
results = Parse(await mdns.ResolveServiceInstance(operationalInstanceName, "_matter._tcp", "local"));
if (results.Count > 0)
@@ -141,7 +120,7 @@ public async Task> Find(uint discriminator, bool fullLen)
}
///
- /// Find all commissionable nodes
+ /// Find all commissionable nodes accessible by IP
///
///
public async Task FindAll()
diff --git a/MatterDotNet/Protocol/Connection/BLEEndPoint.cs b/MatterDotNet/Protocol/Connection/BLEEndPoint.cs
new file mode 100644
index 0000000..9830b9d
--- /dev/null
+++ b/MatterDotNet/Protocol/Connection/BLEEndPoint.cs
@@ -0,0 +1,38 @@
+// MatterDotNet Copyright (C) 2025
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or any later version.
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY, without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+// See the GNU Affero General Public License for more details.
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+using System.Net;
+
+namespace MatterDotNet.Protocol.Connection
+{
+ ///
+ /// Bluetooth End Point
+ ///
+ public class BLEEndPoint : EndPoint
+ {
+ private string address;
+
+ ///
+ /// Create a Bluetooth End Point
+ ///
+ ///
+ public BLEEndPoint(string address)
+ {
+ this.address = address;
+ }
+
+ ///
+ /// Get the BT Address associated with this endpoint
+ ///
+ public string Address { get { return address; } }
+ }
+}
diff --git a/MatterDotNet/Protocol/Connection/BTPConnection.cs b/MatterDotNet/Protocol/Connection/BTPConnection.cs
new file mode 100644
index 0000000..679e028
--- /dev/null
+++ b/MatterDotNet/Protocol/Connection/BTPConnection.cs
@@ -0,0 +1,245 @@
+// MatterDotNet Copyright (C) 2025
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or any later version.
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY, without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+// See the GNU Affero General Public License for more details.
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+using InTheHand.Bluetooth;
+using MatterDotNet.Protocol.Payloads;
+using MatterDotNet.Protocol.Payloads.Flags;
+using MatterDotNet.Protocol.Payloads.OpCodes;
+using MatterDotNet.Protocol.Sessions;
+using System.Threading.Channels;
+
+namespace MatterDotNet.Protocol.Connection
+{
+ internal class BTPConnection : IConnection
+ {
+ public static readonly BluetoothUuid MATTER_UUID = BluetoothUuid.FromShortId(0xFFF6);
+ private static readonly BluetoothUuid C1_UUID = BluetoothUuid.FromGuid(Guid.Parse("18EE2EF5-263D-4559-959F-4F9C429F9D11"));
+ private static readonly BluetoothUuid C2_UUID = BluetoothUuid.FromGuid(Guid.Parse("18EE2EF5-263D-4559-959F-4F9C429F9D12"));
+
+ private static readonly TimeSpan CONN_RSP_TIMEOUT = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan ACK_TIME = TimeSpan.FromSeconds(6);
+ private static readonly TimeSpan ACK_TIMEOUT = TimeSpan.FromSeconds(15);
+
+ CancellationTokenSource cts = new CancellationTokenSource();
+ private GattCharacteristic Read;
+ private GattCharacteristic Write;
+ Channel instream = Channel.CreateBounded(10);
+ ushort MTU = 0;
+ byte ServerWindow = 0;
+ byte txCounter = 0; // First is 0
+ byte rxCounter = 0;
+ byte rxAcknowledged = 255; //Ensures we acknowledge the handshake
+ byte txAcknowledged = 0;
+ Timer? AckTimer;
+ SemaphoreSlim WriteLock = new SemaphoreSlim(1, 1);
+ bool connected;
+ BluetoothDevice? device;
+
+ public BTPConnection(BLEEndPoint bleDevice)
+ {
+ Connect(bleDevice.Address).Wait();
+ AckTimer = new Timer(SendAck, null, ACK_TIME, ACK_TIME);
+ if (Read == null || Write == null)
+ throw new InvalidOperationException("Failed to initialize characteristics");
+ }
+
+ private async Task Connect(string deviceID)
+ {
+ device = await BluetoothDevice.FromIdAsync(deviceID);
+ device.GattServerDisconnected += Device_GattServerDisconnected;
+ await Connect();
+ }
+
+ private async Task Connect()
+ {
+ if (!device!.Gatt.IsConnected)
+ await device.Gatt.ConnectAsync();
+ MTU = (ushort)Math.Min(device.Gatt.Mtu, 244);
+ GattService service = await device.Gatt.GetPrimaryServiceAsync(MATTER_UUID);
+ Write = await service.GetCharacteristicAsync(C1_UUID);
+ Read = await service.GetCharacteristicAsync(C2_UUID);
+ await Read.StopNotificationsAsync();
+
+ await SendHandshake().WaitAsync(CONN_RSP_TIMEOUT);
+ await Task.Factory.StartNew(Run);
+ }
+
+ private void Device_GattServerDisconnected(object? sender, EventArgs e)
+ {
+ connected = false;
+ AckTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ rxAcknowledged = 255;
+ Console.WriteLine(DateTime.Now + "** Disconnected **");
+ }
+
+ private async Task SendHandshake()
+ {
+ try
+ {
+ Console.WriteLine("Send Handshake Request");
+ BTPFrame handshake = new BTPFrame(BTPFlags.Handshake | BTPFlags.Management | BTPFlags.Beginning | BTPFlags.Ending);
+ handshake.OpCode = BTPManagementOpcode.Handshake;
+ handshake.WindowSize = 8;
+ handshake.ATT_MTU = MTU;
+ await Write.WriteValueWithResponseAsync(handshake.Serialize(9));
+ Read.CharacteristicValueChanged += Read_CharacteristicValueChanged;
+ await Read.StartNotificationsAsync();
+
+ BTPFrame frame = await instream.Reader.ReadAsync();
+ MTU = frame.ATT_MTU;
+ ServerWindow = frame.WindowSize;
+ if (frame.Version != BTPFrame.MATTER_BT_VERSION1)
+ throw new NotSupportedException($"Version {frame.Version} not supported");
+ connected = true;
+ Console.WriteLine($"MTU: {MTU}, Window: {ServerWindow}");
+ }
+ catch(Exception ex)
+ {
+ Console.WriteLine(ex.Message);
+ }
+ }
+
+ private async void SendAck(object? state)
+ {
+ await WriteLock.WaitAsync();
+ if (!connected)
+ return;
+ try
+ {
+ BTPFrame segment = new BTPFrame(BTPFlags.Acknowledgement);
+ segment.Sequence = txCounter++;
+ if (rxCounter != rxAcknowledged)
+ {
+ segment.Acknowledge = rxCounter;
+ rxAcknowledged = rxCounter;
+ }
+ Console.WriteLine("[StandaloneAck] Wrote Segment: " + segment);
+ await Write.WriteValueWithResponseAsync(segment.Serialize(MTU));
+ }
+ finally
+ {
+ WriteLock.Release();
+ }
+ }
+
+ private void Read_CharacteristicValueChanged(object? sender, GattCharacteristicValueChangedEventArgs e)
+ {
+ if (e.Value != null)
+ {
+ BTPFrame frame = new BTPFrame(e.Value!);
+ Console.WriteLine("BTP Received: " + frame);
+ AckTimer?.Change(ACK_TIME, ACK_TIME);
+ if ((frame.Flags & BTPFlags.Acknowledgement) != 0)
+ txAcknowledged = frame.Acknowledge;
+ if ((frame.Flags & BTPFlags.Handshake) == 0)
+ rxCounter = frame.Sequence;
+ if ((frame.Flags & BTPFlags.Continuing) != 0 || (frame.Flags & BTPFlags.Beginning) != 0)
+ instream.Writer.TryWrite(frame);
+ }
+ }
+
+ public async Task SendFrame(Exchange exchange, Frame frame, bool reliable)
+ {
+ PayloadWriter writer = new PayloadWriter(Frame.MAX_SIZE);
+ frame.Serialize(writer, exchange.Session);
+ if (!connected)
+ await Connect();
+ await WaitForWindow();
+ await WriteLock.WaitAsync();
+ try
+ {
+ byte? ack = null;
+ if (rxCounter != rxAcknowledged)
+ {
+ ack = rxCounter;
+ rxAcknowledged = rxCounter;
+ AckTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ }
+ BTPFrame[] segments = BTPFrame.CreateSegments(frame, exchange.Session, MTU, ack);
+ foreach (BTPFrame segment in segments)
+ {
+ await WaitForWindow();
+ segment.Sequence = txCounter++;
+ Console.WriteLine("Wrote Segment: " + segment);
+ await Write.WriteValueWithResponseAsync(segment.Serialize(MTU));
+ }
+ }
+ finally
+ {
+ WriteLock.Release();
+ }
+ }
+
+ private async Task WaitForWindow()
+ {
+ while (txCounter - txAcknowledged > ServerWindow)
+ await instream.Reader.WaitToReadAsync();
+ }
+
+ public async Task Run()
+ {
+ try
+ {
+ List segments = new List();
+ while (!cts.IsCancellationRequested)
+ {
+ BTPFrame segment = await instream.Reader.ReadAsync();
+ Console.WriteLine("Segment Received: " + segment);
+ segments.Add(segment);
+ if ((segment.Flags & BTPFlags.Ending) == 0x0)
+ continue;
+ PayloadWriter buffer = new PayloadWriter(segments[0].Length);
+ foreach (BTPFrame part in segments)
+ buffer.Write(part.Payload);
+ segments.Clear();
+ Frame frame = new Frame(buffer.GetPayload().Span);
+ if (!frame.Valid)
+ {
+ Console.WriteLine("Invalid frame received");
+ continue;
+ }
+ SessionContext? session = SessionManager.GetSession(frame.SessionID);
+ Console.WriteLine(DateTime.Now.ToString("h:mm:ss") + " Received: " + frame.ToString());
+ if (session == null)
+ {
+ Console.WriteLine("Unknown Session: " + frame.SessionID);
+ continue;
+ }
+ session.ProcessFrame(frame);
+ session.Timestamp = DateTime.Now;
+ session.LastActive = DateTime.Now;
+ }
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine(e.ToString());
+ }
+ }
+
+ public Task CloseExchange(Exchange exchange)
+ {
+ // Nothing to do
+ return Task.CompletedTask;
+ }
+
+ public bool Connected { get { return connected; } }
+
+ ///
+ public void Dispose()
+ {
+ AckTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ Read.StopNotificationsAsync().Wait();
+ cts.Cancel();
+ cts.Dispose();
+ }
+ }
+}
diff --git a/MatterDotNet/Protocol/Payloads/BTPFrame.cs b/MatterDotNet/Protocol/Payloads/BTPFrame.cs
new file mode 100644
index 0000000..f8d1603
--- /dev/null
+++ b/MatterDotNet/Protocol/Payloads/BTPFrame.cs
@@ -0,0 +1,136 @@
+// MatterDotNet Copyright (C) 2025
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or any later version.
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY, without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+// See the GNU Affero General Public License for more details.
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+using MatterDotNet.Protocol.Payloads.Flags;
+using MatterDotNet.Protocol.Payloads.OpCodes;
+using MatterDotNet.Protocol.Sessions;
+using System.Buffers.Binary;
+
+namespace MatterDotNet.Protocol.Payloads
+{
+ internal class BTPFrame
+ {
+ public const byte MATTER_BT_VERSION1 = 0x04;
+
+ public BTPFlags Flags { get; set; }
+ public BTPManagementOpcode OpCode { get; set; }
+ public byte Acknowledge { get; set; }
+ public byte Sequence { get; set; }
+ public ushort Length { get; set; }
+ public Memory Payload { get; set; }
+ public ushort ATT_MTU { get; set; }
+ public byte WindowSize { get; set; }
+ public byte Version { get; private set; }
+
+ private BTPFrame() { }
+
+ public BTPFrame(BTPFlags flags)
+ {
+ Flags = flags;
+ }
+
+ public BTPFrame(Memory payload)
+ {
+ int pos = 0;
+ Span span = payload.Span;
+ Flags = (BTPFlags)span[pos++];
+ if ((Flags & BTPFlags.Management) != 0)
+ {
+ OpCode = (BTPManagementOpcode)span[pos++];
+ Version = (byte)(span[pos++] & 0xF);
+ }
+ if ((Flags & BTPFlags.Acknowledgement) != 0)
+ Acknowledge = span[pos++];
+ if ((Flags & BTPFlags.Handshake) == 0)
+ {
+ Sequence = span[pos++];
+ if ((Flags & BTPFlags.Beginning) != 0)
+ {
+ Length = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(pos, 2));
+ pos += 2;
+ }
+ Payload = payload.Slice(pos);
+ }
+ if ((Flags & BTPFlags.Handshake) != 0)
+ {
+ ATT_MTU = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(pos, 2));
+ WindowSize = span[pos + 2];
+ }
+ }
+
+ public byte[] Serialize(int mtu)
+ {
+ PayloadWriter stream = new PayloadWriter(mtu);
+ stream.Write((byte)Flags);
+ if ((Flags & BTPFlags.Management) != 0)
+ stream.Write((byte)OpCode);
+ if ((Flags & BTPFlags.Acknowledgement) != 0)
+ stream.Write(Acknowledge);
+ if ((Flags & BTPFlags.Handshake) == 0)
+ {
+ stream.Write(Sequence);
+ if ((Flags & BTPFlags.Beginning) != 0)
+ stream.Write(Length);
+ stream.Write(Payload.Span);
+ }
+ else
+ {
+ stream.Write([MATTER_BT_VERSION1, 0x0, 0x0, 0x0]);
+ stream.Write(ATT_MTU);
+ stream.Write(WindowSize);
+ }
+ return stream.GetPayload().ToArray();
+ }
+
+ public override string ToString()
+ {
+ return $"Flags: {Flags}, Seq: {Sequence}, Ack: {Acknowledge}, Len: {Length}, Payload: {Payload.Length}";
+ }
+
+ internal static BTPFrame[] CreateSegments(Frame frame, SessionContext session, ushort maxSegment, byte? ack = null)
+ {
+ PayloadWriter writer = new PayloadWriter(Frame.MAX_SIZE);
+ frame.Serialize(writer, session);
+ List segments = new List();
+ ushort bytesPacked = 0;
+ do
+ {
+ ushort header = 2; //Flags + sequence
+ BTPFrame segment = new BTPFrame();
+ if (segments.Count == 0)
+ {
+ segment.Flags = BTPFlags.Beginning;
+ segment.Length = (ushort)writer.Length;
+ header += 2; //Length field
+ if (ack.HasValue)
+ {
+ header += 1;
+ segment.Acknowledge = ack.Value;
+ segment.Flags |= BTPFlags.Acknowledgement;
+ }
+ }
+ else
+ segment.Flags = BTPFlags.Continuing;
+
+ ushort segmentSize = (ushort)Math.Min(writer.Length - bytesPacked, maxSegment - header);
+ if (segmentSize + bytesPacked == writer.Length)
+ segment.Flags |= BTPFlags.Ending;
+ segment.Payload = writer.GetPayload().Slice(bytesPacked, segmentSize);
+
+ segments.Add(segment);
+ bytesPacked += segmentSize;
+ }
+ while (bytesPacked < writer.Length);
+ return segments.ToArray();
+ }
+ }
+}
diff --git a/MatterDotNet/Protocol/Payloads/Flags/BTPFlags.cs b/MatterDotNet/Protocol/Payloads/Flags/BTPFlags.cs
new file mode 100644
index 0000000..76ce477
--- /dev/null
+++ b/MatterDotNet/Protocol/Payloads/Flags/BTPFlags.cs
@@ -0,0 +1,26 @@
+// MatterDotNet Copyright (C) 2025
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or any later version.
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY, without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+// See the GNU Affero General Public License for more details.
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+namespace MatterDotNet.Protocol.Payloads.Flags
+{
+ [Flags]
+ internal enum BTPFlags : byte
+ {
+ Beginning = 0x1,
+ Continuing = 0x2,
+ Ending = 0x4,
+ Acknowledgement = 0x8,
+ Reserved = 0x10,
+ Management = 0x20,
+ Handshake = 0x40,
+ }
+}
diff --git a/MatterDotNet/Protocol/Payloads/OpCodes/BTPManagementOpcode.cs b/MatterDotNet/Protocol/Payloads/OpCodes/BTPManagementOpcode.cs
new file mode 100644
index 0000000..d882c49
--- /dev/null
+++ b/MatterDotNet/Protocol/Payloads/OpCodes/BTPManagementOpcode.cs
@@ -0,0 +1,26 @@
+// MatterDotNet Copyright (C) 2025
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or any later version.
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY, without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+// See the GNU Affero General Public License for more details.
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+namespace MatterDotNet.Protocol.Payloads.OpCodes
+{
+ internal enum BTPManagementOpcode : byte
+ {
+ ///
+ /// No OpCode
+ ///
+ None = 0x0,
+ ///
+ /// Request and response for BTP session establishment
+ ///
+ Handshake = 0x6C
+ }
+}
diff --git a/MatterDotNet/Protocol/Payloads/TLVPayload.cs b/MatterDotNet/Protocol/Payloads/TLVPayload.cs
index 77b9afe..c9e972b 100644
--- a/MatterDotNet/Protocol/Payloads/TLVPayload.cs
+++ b/MatterDotNet/Protocol/Payloads/TLVPayload.cs
@@ -36,6 +36,8 @@ internal TLVPayload(Memory data) : this(new TLVReader(data)) {}
///
internal TLVPayload(TLVReader reader, long structureNumber = -1) { }
+ public TLVPayload(object[] fields) { }
+
///
/// Write the TLVs to an application payload
///
diff --git a/MatterDotNet/Protocol/Sessions/SessionManager.cs b/MatterDotNet/Protocol/Sessions/SessionManager.cs
index 6f42de4..93b82b3 100644
--- a/MatterDotNet/Protocol/Sessions/SessionManager.cs
+++ b/MatterDotNet/Protocol/Sessions/SessionManager.cs
@@ -23,10 +23,10 @@ namespace MatterDotNet.Protocol.Sessions
public static class SessionManager
{
private static uint globalCtr;
- private static ConcurrentDictionary connections = new ConcurrentDictionary();
+ private static ConcurrentDictionary connections = new ConcurrentDictionary();
private static ConcurrentDictionary sessions = new ConcurrentDictionary();
- public static SessionContext GetUnsecureSession(IPEndPoint ep, bool initiator)
+ public static SessionContext GetUnsecureSession(EndPoint ep, bool initiator)
{
return GetUnsecureSession(GetConnection(ep), initiator);
}
@@ -40,7 +40,7 @@ internal static SessionContext GetUnsecureSession(IConnection connection, bool i
return ctx;
}
- public static SecureSession? CreateSession(IPEndPoint ep, bool PASE, bool initiator, ushort initiatorSessionId, ushort responderSessionId, byte[] i2r, byte[] r2i, ulong localNodeId, ulong peerNodeId, byte[] sharedSecret, byte[] resumptionId, bool group, uint idleInterval, uint activeInterval, uint activeThreshold)
+ internal static SecureSession? CreateSession(EndPoint ep, bool PASE, bool initiator, ushort initiatorSessionId, ushort responderSessionId, byte[] i2r, byte[] r2i, ulong localNodeId, ulong peerNodeId, byte[] sharedSecret, byte[] resumptionId, bool group, uint idleInterval, uint activeInterval, uint activeThreshold)
{
return CreateSession(GetConnection(ep), PASE, initiator, initiatorSessionId, responderSessionId, i2r, r2i, localNodeId, peerNodeId, sharedSecret, resumptionId, group, idleInterval, activeInterval, activeThreshold);
}
@@ -86,13 +86,23 @@ public static uint GlobalUnencryptedCounter
}
}
- private static IConnection GetConnection(IPEndPoint endPoint)
+ private static IConnection GetConnection(EndPoint endPoint)
{
if (connections.TryGetValue(endPoint, out IConnection? connection))
return connection;
- IConnection con = new MRPConnection(endPoint);
- connections.TryAdd(endPoint, con);
- return con;
+ if (endPoint is IPEndPoint ipep)
+ {
+ IConnection con = new MRPConnection(ipep);
+ connections.TryAdd(endPoint, con);
+ return con;
+ }
+ else if (endPoint is BLEEndPoint ble)
+ {
+ IConnection con = new BTPConnection(ble);
+ connections.TryAdd(endPoint, con);
+ return con;
+ }
+ throw new ArgumentException("Invalid EndPoint");
}
public static SessionParameter GetDefaultSessionParams()
diff --git a/MatterDotNet/Protocol/Subprotocols/InteractionManager.cs b/MatterDotNet/Protocol/Subprotocols/InteractionManager.cs
index a1db59c..bd97411 100644
--- a/MatterDotNet/Protocol/Subprotocols/InteractionManager.cs
+++ b/MatterDotNet/Protocol/Subprotocols/InteractionManager.cs
@@ -201,7 +201,7 @@ public static async Task ExecCommand(SecureSession secSession,
Frame response = await exchange.Read();
if (response.Message.Payload is InvokeResponseMessage msg)
{
- if (!msg.InvokeResponses[0].Status!.CommandRef.HasValue || msg.InvokeResponses[0].Status!.CommandRef!.Value == refNum)
+ if (msg.InvokeResponses[0].Status == null || !msg.InvokeResponses[0].Status!.CommandRef.HasValue || msg.InvokeResponses[0].Status!.CommandRef!.Value == refNum)
return msg.InvokeResponses[0];
}
else if (response.Message.Payload is StatusResponseMessage status)
diff --git a/README.md b/README.md
index 5181625..135a507 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,25 @@
[![Build](https://github.com/SmartHomeOS/MatterDotNet/actions/workflows/dotnet.yml/badge.svg)](https://github.com/SmartHomeOS/MatterDotNet/actions/workflows/dotnet.yml)
# MatterDotNet
-**Coming Soon** - New implementation in progress
+##### A C# implementation of the Matter 1.4 (formerly Project CHIP) standard
#### What's Working
-* Automatic Code Generation (directly from the specification)
+* Automatic code generation (directly from the specification)
+* Operational discovery over IP or Bluetooth LE
* Commissioning by QR code or PIN
-* Operational Discovery over IP or BT LE
-* Reading/Writing Attributes
+* Reading/Writing attributes
* Executing cluster commands
-#### What's In Progress:
-* Cluster generation
+#### In-Progress:
+* Matter 1.4 cluster generation
#### Coming Soon
-* Matter 1.4 Standard Implementation
* Multicast/Group Control
* Subscriptions
* Events
+* Over the Air Software Updates
+
+#### Will Not Implement
+* Provisional specification items (including TermsAndConditions and Joint Fabric)
#### Other Projects:
* Check out my other projects for [HomeKit](https://github.com/SmartHomeOS/HomeKitDotNet) and [ZWave](https://github.com/SmartHomeOS/ZWaveDotNet)