diff --git a/README.md b/README.md index cdd2d7c..796ecf9 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,13 @@ a JSON-LD document that is both highly human- and machine-readable and contains Our long-term goal here is to provide the .NET Standard 2.0 stack that fully implements the [Scripting API](https://www.w3.org/TR/wot-scripting-api/), which would facilitate rapid development of WoT applications and also facilitate the integration of the WoT stack in Unity. Our short-term goal is to implement the functionalities of a WoT Consumer, i.e. the functionalities needed to fetch a TD and consume it to interact with the entity it describes. -We will focus first on HTTP Things but aim to implement functionality for HTTPS, CoAP, CoAPS, and MQTT in the future. +We will focus first on HTTP Things but have also implemented basic CoAP support, with plans to add full CoAPS and MQTT support in the future. ## How is it structured? WoT.Net is implemented as a core package [**WoT.Net.Core**](https://www.nuget.org/packages/WoT.Net.Core), which defines the core interfaces and classes used in the context of the Web of Things. The core package is protocol-agnostic and does not provide any protocol implementations. Protocol implementations are provided using protocol bindings. Currently available binding packages are: - [**WoT.Net.Binding.Http**](https://www.nuget.org/packages/WoT.Net.Binding.Http): a binding for HTTP/S +- [**WoT.Net.Binding.CoAP**]: a basic binding for CoAP (Constrained Application Protocol) ## Getting Started @@ -129,7 +130,7 @@ This can be done by implementing the `IClient` and `IClientFactory` interfaces, - [X] TD Deserializing and Parsing - [X] HTTP Consumer - [X] HTTPS Consumer -- [ ] CoAP Consumer +- [X] CoAP Consumer - [ ] CoAPS Consumer - [ ] MQTT Consumer diff --git a/Tester/Program.cs b/Tester/Tester.cs similarity index 57% rename from Tester/Program.cs rename to Tester/Tester.cs index 4428baa..4b4c348 100644 --- a/Tester/Program.cs +++ b/Tester/Tester.cs @@ -1,20 +1,42 @@ -using WoT.Core.Implementation; +using System; +using WoT.Core.Implementation; +using WoT.Binding.CoAP; using WoT.Binding.Http; using WoT.Core.Definitions.TD; using Newtonsoft.Json; -Consumer consumer = new(); -HttpClientConfig clientConfig = new() -{ - AllowSelfSigned = true -}; -consumer.AddClientFactory(new HttpClientFactory(new HttpClientConfig())); -consumer.AddClientFactory(new HttpsClientFactory(clientConfig)); +var consumer = new Consumer(); +ThingDescription td; +string protocol2Test = "http"; // change to "coap" to test CoAP only -consumer.Start(); -ThingDescription td = await consumer.RequestThingDescription("http://plugfest.thingweb.io:80/http-data-schema-thing/"); +if (protocol2Test == "coap") +{ + // CoAP + var tdUri = args.Length > 0 ? args[0] : "coap://localhost:5683/tester"; + // Basic CoAP client configuration (timeouts in ms) + var coapConfig = new CoapClientConfig + { + Timeout = 5000, + MaxRetransmit = 4, + AckTimeout = 2000 + }; + consumer.AddClientFactory(new CoapClientFactory(coapConfig)); + Console.WriteLine($"Requesting TD from: {tdUri}"); + td = await consumer.RequestThingDescription(tdUri); +} +else +{ + // HTTP + var httpTdUri = args.Length > 0 ? args[0] : "http://localhost:8085/tester"; + consumer.AddClientFactory(new HttpClientFactory(new HttpClientConfig())); + consumer.Start(); + + Console.WriteLine($"Requesting TD from: {httpTdUri}"); + td = await consumer.RequestThingDescription(httpTdUri); +} +// Consume the Thing ConsumedThing consumedThing = (ConsumedThing)consumer.Consume(td); // Read a boolean @@ -55,35 +77,5 @@ int output2 = await (await consumedThing.InvokeAction("int-int", 4)).Value(); Console.WriteLine("Output of 'void-int' action was: " + output2); -if (consumedThing != null) -{ - - // Subscribe to Event - var sub = await consumedThing.SubscribeEvent("on-int", async (output) => - { - Console.WriteLine("Event: Received on-int event"); - Console.WriteLine($"Value received: {await output.Value()}"); - Console.WriteLine("---------------------"); - }); - var task = Task.Run(async () => - { - while (sub.Active) - { - // Get random integer between 0 and 100 - Random random = new(); - int randomInt = random.Next(0, 100); - Console.WriteLine($"Writing int {randomInt}"); - Console.WriteLine("---------------------"); - // Write an int - await consumedThing.WriteProperty("int", randomInt); - - await Task.Delay(TimeSpan.FromSeconds(1)); - } - return; - }); - - Task stopTask = Task.Delay(TimeSpan.FromSeconds(10)).ContinueWith(async (task) => { await sub.Stop(); }); - await task; -} -Console.WriteLine("Done"); \ No newline at end of file +Console.WriteLine("Done."); \ No newline at end of file diff --git a/Tester/Tester.csproj b/Tester/Tester.csproj index 7d19b59..c3138bd 100644 --- a/Tester/Tester.csproj +++ b/Tester/Tester.csproj @@ -14,12 +14,8 @@ - - ..\WoT\Core\bin\Release\netstandard2.0\Core.dll - - - ..\WoT\Binding\Http\bin\Release\netstandard2.0\Http.dll - + + + - diff --git a/WoT/Binding/CoAP/CoAP.csproj b/WoT/Binding/CoAP/CoAP.csproj new file mode 100644 index 0000000..07bfb96 --- /dev/null +++ b/WoT/Binding/CoAP/CoAP.csproj @@ -0,0 +1,39 @@ + + + + + netstandard2.0 + WoT.Net.Binding.$(AssemblyName) + WoT.Net.Binding.CoAP + 0.0.1 + Fady Salama + Web of Things; WoT; CoAP + wot_dot_net_logo.png + True + LICENSE + True + + + + + \ + True + + + + + + + + + + \ + True + + + \ + True + + + + diff --git a/WoT/Binding/CoAP/CoapClientConfig.cs b/WoT/Binding/CoAP/CoapClientConfig.cs new file mode 100644 index 0000000..0b2a0ef --- /dev/null +++ b/WoT/Binding/CoAP/CoapClientConfig.cs @@ -0,0 +1,30 @@ +using System; + +namespace WoT.Binding.CoAP +{ + /// + /// Configuration options for the CoAP client + /// + public class CoapClientConfig + { + /// + /// Timeout for CoAP requests in milliseconds + /// + public int Timeout { get; set; } = 30000; + + /// + /// Maximum number of retransmissions for confirmable messages + /// + public int MaxRetransmit { get; set; } = 4; + + /// + /// Acknowledgement timeout in milliseconds + /// + public int AckTimeout { get; set; } = 2000; + + /// + /// Default block size for block-wise transfers (in bytes) + /// + public int DefaultBlockSize { get; set; } = 512; + } +} diff --git a/WoT/Binding/CoAP/CoapClientFactory.cs b/WoT/Binding/CoAP/CoapClientFactory.cs new file mode 100644 index 0000000..d62fedc --- /dev/null +++ b/WoT/Binding/CoAP/CoapClientFactory.cs @@ -0,0 +1,54 @@ +using WoT.Core.Definitions; + +namespace WoT.Binding.CoAP +{ + /// + /// Factory for creating CoapClient instances + /// + public class CoapClientFactory : IProtocolClientFactory + { + private readonly string _scheme = "coap"; + private readonly CoapClientConfig _config; + + /// + /// Constructor + /// + /// + public CoapClientFactory(CoapClientConfig config) + { + _config = config; + } + + /// + /// Scheme of the CoapClient instance + /// + public string Scheme => _scheme; + + /// + /// Get a new WotCoapClient instance + /// + /// + public IProtocolClient GetClient() + { + return new WotCoapClient(_config); + } + + /// + /// Initialize the CoapClientFactory + /// + /// if initialization was successful, otherwise + public bool Init() + { + return true; + } + + /// + /// Destroy the CoapClientFactory + /// + /// if factory was destroyed successfully, otherwise + public bool Destroy() + { + return true; + } + } +} diff --git a/WoT/Binding/CoAP/EXAMPLE.md b/WoT/Binding/CoAP/EXAMPLE.md new file mode 100644 index 0000000..09a7122 --- /dev/null +++ b/WoT/Binding/CoAP/EXAMPLE.md @@ -0,0 +1,62 @@ +# CoAP Binding Example + +This example demonstrates how to use the CoAP binding with WoT.Net: + +```csharp +using WoT.Core.Implementation; +using WoT.Binding.CoAP; +using WoT.Core.Definitions.TD; + +Consumer consumer = new(); +CoapClientConfig coapConfig = new() +{ + Timeout = 30000, + MaxRetransmit = 4, + AckTimeout = 2000 +}; + +consumer.AddClientFactory(new CoapClientFactory(coapConfig)); +consumer.Start(); + +// Request a Thing Description from a CoAP server +ThingDescription td = await consumer.RequestThingDescription("coap://example.com:5683/thing"); +ConsumedThing consumedThing = (ConsumedThing)consumer.Consume(td); + +// Read a property +int value = await (await consumedThing.ReadProperty("temperature")).Value(); +Console.WriteLine($"Temperature: {value}"); + +// Write a property +await consumedThing.WriteProperty("setpoint", 22); + +// Invoke an action +await consumedThing.InvokeAction("toggle"); + +// Invoke an action with Input and Output +var result = await thing.InvokeAction("incrementFor", 1); +double value = await result.Value(); + +``` + +## Implementation Notes + +The current CoAP binding provides a basic implementation of the CoAP protocol supporting GET, POST, PUT, and DELETE operations. It includes: + +- Basic CoAP message encoding/decoding (RFC 7252) +- URI-Path option handling +- Content-Format option support +- Response code handling + +### Not Yet Implemented + +- **CoAP Observe**: Event subscriptions via CoAP Observe are not yet implemented +- **DTLS Security**: CoAPS (secure CoAP) is not yet supported +- **Advanced options**: Many CoAP options are not yet implemented + +### For Production Use + +For production deployments requiring full CoAP support, consider integrating a complete CoAP library such as: +- Com.AugustCellars.CoAP (for .NET Framework) +- CoAPnet (may require .NET Core 3.1+) + +The current implementation can be replaced by modifying `WotCoapClient.cs` to use the chosen library while maintaining the `IProtocolClient` interface. diff --git a/WoT/Binding/CoAP/LICENSE b/WoT/Binding/CoAP/LICENSE new file mode 100644 index 0000000..4202acc --- /dev/null +++ b/WoT/Binding/CoAP/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Associate Professorship of Embedded Systems and Internet of Things + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/WoT/Binding/CoAP/README.md b/WoT/Binding/CoAP/README.md new file mode 100644 index 0000000..f4f8413 --- /dev/null +++ b/WoT/Binding/CoAP/README.md @@ -0,0 +1,32 @@ +# WoT.Net.Binding.CoAP + +CoAP (Constrained Application Protocol) binding for WoT.Net. + +This package provides CoAP protocol support for the WoT.Net library, enabling consumption of Things that use the CoAP protocol. + +## Features + +- CoAP client implementation for consuming CoAP Things +- Support for GET, POST, PUT, DELETE operations +- Basic CoAP message encoding/decoding (RFC 7252) +- Compatible with .NET Standard 2.0 + +**Not yet implemented:** +- CoAP Observe for event subscriptions +- DTLS/CoAPS security + +## Usage + +```csharp +using WoT.Core.Implementation; +using WoT.Binding.CoAP; + +Consumer consumer = new(); +CoapClientConfig clientConfig = new(); +consumer.AddClientFactory(new CoapClientFactory(clientConfig)); + +consumer.Start(); + +ThingDescription td = await consumer.RequestThingDescription("coap://example.com/thing"); +ConsumedThing thing = (ConsumedThing)consumer.Consume(td); +``` diff --git a/WoT/Binding/CoAP/WotCoapClient.cs b/WoT/Binding/CoAP/WotCoapClient.cs new file mode 100644 index 0000000..9e15118 --- /dev/null +++ b/WoT/Binding/CoAP/WotCoapClient.cs @@ -0,0 +1,723 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using WoT.Core.Definitions; +using WoT.Core.Definitions.TD; + +namespace WoT.Binding.CoAP +{ + /// + /// A basic CoAP client implementation of for the WoT Consumer + /// + /// + /// This is a minimal CoAP implementation supporting basic GET, PUT, and POST operations. + /// For production use with advanced features (blockwise transfer, observe, DTLS), + /// consider integrating a full-featured CoAP library. + /// + public class WotCoapClient : IProtocolClient, IDisposable + { + private readonly CoapClientConfig _config; + private readonly UdpClient _udpClient; + private ushort _messageId = 0; + private bool _disposed = false; + + /// + /// The protocol scheme of this client + /// + public string Scheme { get; internal set; } = "coap"; + + /// + /// Create a new + /// + /// CoAP client configuration + public WotCoapClient(CoapClientConfig config) + { + _config = config ?? new CoapClientConfig(); + _udpClient = new UdpClient(); + // Don't set ReceiveTimeout on socket - timeout is handled in SendCoapRequest + } + + #region ReadResource + + public async Task ReadResource(Form form) + { + return await ReadResource(form, CancellationToken.None); + } + + public async Task ReadResource(Form form, CancellationToken cancellationToken) + { + var response = await SendCoapRequest(form.Href.ToString(), CoapMethod.GET, null, null, cancellationToken); + return CreateContentFromResponse(response); + } + + #endregion + + #region WriteResource + + public async Task WriteResource(Form form, Content content) + { + await WriteResource(form, content, CancellationToken.None); + } + + public async Task WriteResource(Form form, Content content, CancellationToken cancellationToken) + { + byte[] payload = content != null ? ReadStreamToBytes(content.body) : null; + string contentType = content?.type; + await SendCoapRequest(form.Href.ToString(), CoapMethod.PUT, payload, contentType, cancellationToken); + } + + #endregion + + #region InvokeResource + + public async Task InvokeResource(Form form) + { + return await InvokeResource(form, null, CancellationToken.None); + } + + public async Task InvokeResource(Form form, CancellationToken cancellationToken) + { + return await InvokeResource(form, null, cancellationToken); + } + + public async Task InvokeResource(Form form, Content content) + { + return await InvokeResource(form, content, CancellationToken.None); + } + + public async Task InvokeResource(Form form, Content content, CancellationToken cancellationToken) + { + byte[] payload = content != null ? ReadStreamToBytes(content.body) : null; + string contentType = content?.type; + var response = await SendCoapRequest(form.Href.ToString(), CoapMethod.POST, payload, contentType, cancellationToken); + return CreateContentFromResponse(response); + } + + #endregion + + #region SubscribeResource + + public async Task SubscribeResource(Form form, Action nextHandler, Action errorHandler = null, Action complete = null) + { + await Task.CompletedTask; + // CoAP Observe would be implemented here + throw new NotImplementedException("CoAP Observe is not yet implemented in this basic CoAP client."); + } + + public Task UnlinkResource(Form form) + { + return Task.CompletedTask; + } + + #endregion + + public Task Start() + { + return Task.CompletedTask; + } + + public Task Stop() + { + Dispose(); + return Task.CompletedTask; + } + + /// + /// Dispose of resources + /// + public void Dispose() + { + if (!_disposed) + { + _udpClient?.Dispose(); + _disposed = true; + } + } + + public bool SetSecurity(SecurityScheme[] metadata, Dictionary credentials) + { + // DTLS security would be implemented here + return false; + } + + public async Task RequestThingDescription(string url) + { + return await RequestThingDescription(new Uri(url)); + } + + public async Task RequestThingDescription(Uri tdUrl) + { + var response = await SendCoapRequest(tdUrl.ToString(), CoapMethod.GET, null, null, CancellationToken.None); + return CreateContentFromResponse(response, "application/td+json"); + } + + #region CoAP Protocol Implementation + + private async Task SendCoapRequest(string uri, CoapMethod method, byte[] payload, string contentType, CancellationToken cancellationToken) + { + var parsedUri = new Uri(uri); + var host = parsedUri.Host; + var port = parsedUri.Port > 0 ? parsedUri.Port : 5683; // Default CoAP port + var path = parsedUri.AbsolutePath.TrimStart('/'); + + // Build CoAP message + var message = BuildCoapMessage(method, path, payload, contentType, null); + + // Send request + await _udpClient.SendAsync(message, message.Length, host, port); + + // Receive response with timeout and cancellation support + // Note: UdpClient.ReceiveAsync() doesn't support CancellationToken in .NET Standard 2.0 + // Using Task.WhenAny as a workaround + using (var timeoutCts = new CancellationTokenSource(_config.Timeout)) + using (var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token)) + { + var receiveTask = _udpClient.ReceiveAsync(); + var cancelTask = Task.Delay(-1, linkedCts.Token); + + var completedTask = await Task.WhenAny(receiveTask, cancelTask); + + if (completedTask == receiveTask) + { + var result = await receiveTask; + var response = ParseCoapResponse(result.Buffer); + + // Handle Block2 (blockwise transfer for responses) + if (response.Block2 != null && response.Block2.HasMore) + { + // Accumulate the complete payload across all blocks + var completePayload = new List(); + if (response.Payload != null) + { + completePayload.AddRange(response.Payload); + } + + var blockNum = response.Block2.BlockNumber; + var blockSize = response.Block2.BlockSize; + + // Fetch remaining blocks + while (response.Block2.HasMore) + { + blockNum++; + + // Build request with Block2 option for next block + var block2Value = new Block2Option + { + BlockNumber = blockNum, + HasMore = false, + BlockSize = blockSize + }; + + var blockMessage = BuildCoapMessage(method, path, null, null, block2Value); + await _udpClient.SendAsync(blockMessage, blockMessage.Length, host, port); + + // Receive next block + var blockReceiveTask = _udpClient.ReceiveAsync(); + var blockCancelTask = Task.Delay(-1, linkedCts.Token); + var blockCompletedTask = await Task.WhenAny(blockReceiveTask, blockCancelTask); + + if (blockCompletedTask == blockReceiveTask) + { + var blockResult = await blockReceiveTask; + response = ParseCoapResponse(blockResult.Buffer); + + if (response.Payload != null) + { + completePayload.AddRange(response.Payload); + } + } + else if (timeoutCts.IsCancellationRequested) + { + throw new TimeoutException($"CoAP block request to {uri} timed out after {_config.Timeout}ms"); + } + else + { + throw new OperationCanceledException("CoAP block request was cancelled", cancellationToken); + } + } + + // Return response with complete payload + response.Payload = completePayload.ToArray(); + } + + return response; + } + else if (timeoutCts.IsCancellationRequested) + { + throw new TimeoutException($"CoAP request to {uri} timed out after {_config.Timeout}ms"); + } + else + { + throw new OperationCanceledException("CoAP request was cancelled", cancellationToken); + } + } + } + + private byte[] BuildCoapMessage(CoapMethod method, string path, byte[] payload, string contentType, Block2Option block2Option) + { + using (var ms = new MemoryStream()) + { + // CoAP header (4 bytes): Ver(2)|T(2)|TKL(4), Code(8), Message ID(16) + byte ver = 1; // CoAP version 1 + byte type = 0; // CON (Confirmable) + byte tkl = 0; // Token length (0 for simplicity) + + ms.WriteByte((byte)((ver << 6) | (type << 4) | tkl)); + ms.WriteByte(GetMethodCode(method)); + + // Message ID + ushort msgId = ++_messageId; + ms.WriteByte((byte)(msgId >> 8)); + ms.WriteByte((byte)(msgId & 0xFF)); + + // Options - must be in order by option number + int lastOptionNumber = 0; + + // Uri-Path options (option 11) + if (!string.IsNullOrEmpty(path)) + { + var pathSegments = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var segment in pathSegments) + { + int optionNumber = 11; + lastOptionNumber = WriteOption(ms, optionNumber, Encoding.UTF8.GetBytes(segment), lastOptionNumber); + } + } + + // Content-Format option (option 12) - must come after Uri-Path + if (payload != null && payload.Length > 0 && !string.IsNullOrEmpty(contentType)) + { + int contentFormat = GetContentFormatCode(contentType); + int optionNumber = 12; + byte[] formatBytes; + if (contentFormat <= 255) + { + formatBytes = new byte[] { (byte)contentFormat }; + } + else + { + formatBytes = new byte[] { (byte)(contentFormat >> 8), (byte)(contentFormat & 0xFF) }; + } + lastOptionNumber = WriteOption(ms, optionNumber, formatBytes, lastOptionNumber); + } + + // Block2 option (option 23) for blockwise transfer + if (block2Option != null) + { + int optionNumber = 23; + byte[] block2Bytes = EncodeBlock2Option(block2Option); + lastOptionNumber = WriteOption(ms, optionNumber, block2Bytes, lastOptionNumber); + } + + // Payload marker and payload + if (payload != null && payload.Length > 0) + { + ms.WriteByte(0xFF); + ms.Write(payload, 0, payload.Length); + } + + return ms.ToArray(); + } + } + + private int WriteOption(MemoryStream ms, int optionNumber, byte[] value, int previousOptionNumber) + { + int delta = optionNumber - previousOptionNumber; + int length = value.Length; + + // Encode delta and length according to RFC 7252 + // Note: This implementation supports delta/length values up to 268 (single extended byte) + // Values 269-65804 would require 2 extended bytes, which is not implemented for simplicity + int deltaEncoded = delta; + int lengthEncoded = length; + byte deltaExtra = 0; + byte lengthExtra = 0; + + // Handle extended delta (13-268: use 1 extra byte) + if (delta >= 13 && delta < 269) + { + deltaEncoded = 13; + deltaExtra = (byte)(delta - 13); + } + else if (delta >= 269) + { + // 2-byte extended delta not implemented - this would require special handling + throw new NotImplementedException($"CoAP option delta {delta} requires 2-byte extended encoding which is not implemented"); + } + + // Handle extended length (13-268: use 1 extra byte) + if (length >= 13 && length < 269) + { + lengthEncoded = 13; + lengthExtra = (byte)(length - 13); + } + else if (length >= 269) + { + // 2-byte extended length not implemented + throw new NotImplementedException($"CoAP option length {length} requires 2-byte extended encoding which is not implemented"); + } + + // Write option header + byte optionHeader = (byte)((deltaEncoded << 4) | lengthEncoded); + ms.WriteByte(optionHeader); + + // Write extended delta if needed + if (deltaEncoded == 13) + { + ms.WriteByte(deltaExtra); + } + + // Write extended length if needed + if (lengthEncoded == 13) + { + ms.WriteByte(lengthExtra); + } + + // Write option value + if (value.Length > 0) + { + ms.Write(value, 0, value.Length); + } + + return optionNumber; + } + + private CoapResponse ParseCoapResponse(byte[] data) + { + if (data.Length < 4) + throw new Exception("Invalid CoAP response: too short"); + + byte header = data[0]; + byte code = data[1]; + + // Extract response code + int codeClass = (code >> 5) & 0x07; + int codeDetail = code & 0x1F; + + // Parse options and find payload + byte[] payload = null; + Block2Option block2 = null; + int pos = 4; // Start after header + + // Parse options until we hit payload marker (0xFF) or end of data + int previousOptionNumber = 0; + while (pos < data.Length) + { + byte b = data[pos]; + + // Check for payload marker + if (b == 0xFF) + { + pos++; // Skip payload marker + break; + } + + // Parse option header + int delta = (b >> 4) & 0x0F; + int length = b & 0x0F; + pos++; + + // Handle extended delta + if (delta == 13) + { + if (pos >= data.Length) break; + delta = data[pos] + 13; + pos++; + } + else if (delta == 14) + { + if (pos + 1 >= data.Length) break; + delta = ((data[pos] << 8) | data[pos + 1]) + 269; + pos += 2; + } + + // Handle extended length + if (length == 13) + { + if (pos >= data.Length) break; + length = data[pos] + 13; + pos++; + } + else if (length == 14) + { + if (pos + 1 >= data.Length) break; + length = ((data[pos] << 8) | data[pos + 1]) + 269; + pos += 2; + } + + int optionNumber = previousOptionNumber + delta; + previousOptionNumber = optionNumber; + + // Extract option value + byte[] optionValue = new byte[length]; + if (length > 0 && pos + length <= data.Length) + { + Array.Copy(data, pos, optionValue, 0, length); + pos += length; + } + + // Parse Block2 option (option 23) + if (optionNumber == 23 && optionValue.Length > 0) + { + block2 = DecodeBlock2Option(optionValue); + } + } + + // Extract payload if present + if (pos < data.Length) + { + payload = new byte[data.Length - pos]; + Array.Copy(data, pos, payload, 0, payload.Length); + } + + return new CoapResponse + { + Code = code, + CodeClass = codeClass, + CodeDetail = codeDetail, + Payload = payload, + IsSuccess = codeClass == 2, // 2.xx codes are success + Block2 = block2 + }; + } + + private byte GetMethodCode(CoapMethod method) + { + switch (method) + { + case CoapMethod.GET: + return 0x01; // 0.01 + case CoapMethod.POST: + return 0x02; // 0.02 + case CoapMethod.PUT: + return 0x03; // 0.03 + case CoapMethod.DELETE: + return 0x04; // 0.04 + default: + return 0x01; + } + } + + private int GetContentFormatCode(string contentType) + { + if (string.IsNullOrEmpty(contentType)) + return 50; // application/json + + contentType = contentType.ToLowerInvariant().Split(';')[0].Trim(); + + switch (contentType) + { + case "text/plain": + return 0; + case "application/link-format": + return 40; + case "application/xml": + return 41; + case "application/octet-stream": + return 42; + case "application/exi": + return 47; + case "application/json": + case "application/td+json": + return 50; + case "application/cbor": + return 60; + default: + return 50; // default to JSON + } + } + + private Content CreateContentFromResponse(CoapResponse response, string defaultContentType = null) + { + if (!response.IsSuccess) + { + string errorMessage = GetCoapErrorMessage(response); + throw new Exception(errorMessage); + } + + string contentType = defaultContentType ?? "application/json"; + byte[] payload = response.Payload ?? new byte[0]; + MemoryStream memStream = new MemoryStream(payload); + + return new Content(contentType, memStream); + } + + private string GetCoapErrorMessage(CoapResponse response) + { + string codeDescription = GetCoapCodeDescription(response.CodeClass, response.CodeDetail); + string code = $"{response.CodeClass}.{response.CodeDetail:D2}"; + + // Try to extract error details from payload if present + string payloadInfo = ""; + if (response.Payload != null && response.Payload.Length > 0) + { + try + { + // Try to decode payload as UTF-8 string for additional context + string payloadText = Encoding.UTF8.GetString(response.Payload); + if (!string.IsNullOrWhiteSpace(payloadText) && payloadText.Length < 200) + { + payloadInfo = $" - {payloadText}"; + } + } + catch + { + // If payload is not text, ignore + } + } + + return $"CoAP request failed with code {code} ({codeDescription}){payloadInfo}"; + } + + private string GetCoapCodeDescription(int codeClass, int codeDetail) + { + // Based on RFC 7252 Section 5.9 - Response Codes + switch (codeClass) + { + case 4: // Client Error + switch (codeDetail) + { + case 0: return "Bad Request - Invalid request syntax or parameters"; + case 1: return "Unauthorized - Authentication required"; + case 2: return "Bad Option - Unrecognized or malformed option"; + case 3: return "Forbidden - Access denied"; + case 4: return "Not Found - Resource does not exist"; + case 5: return "Method Not Allowed - Method not supported for this resource"; + case 6: return "Not Acceptable - No acceptable representation available"; + case 12: return "Precondition Failed - Precondition in request failed"; + case 13: return "Request Entity Too Large - Payload too large"; + case 15: return "Unsupported Content-Format - Content format not supported"; + default: return $"Client Error {codeDetail}"; + } + case 5: // Server Error + switch (codeDetail) + { + case 0: return "Internal Server Error - Server encountered an error (check request parameters and payload)"; + case 1: return "Not Implemented - Method not implemented"; + case 2: return "Bad Gateway - Invalid response from upstream"; + case 3: return "Service Unavailable - Server temporarily unavailable"; + case 4: return "Gateway Timeout - Upstream timeout"; + case 5: return "Proxying Not Supported - Proxy functionality not supported"; + default: return $"Server Error {codeDetail}"; + } + default: + return $"Error {codeClass}.{codeDetail:D2}"; + } + } + + private byte[] ReadStreamToBytes(Stream stream) + { + if (stream == null) + return new byte[0]; + + stream.Position = 0; + using (MemoryStream ms = new MemoryStream()) + { + stream.CopyTo(ms); + return ms.ToArray(); + } + } + + private byte[] EncodeBlock2Option(Block2Option block2) + { + // Block2 encoding: NUM(variable)|M(1)|SZX(3) + // NUM = block number, M = more flag, SZX = size exponent (0-6) + int szx = GetSizeExponent(block2.BlockSize); + int value = (block2.BlockNumber << 4) | ((block2.HasMore ? 1 : 0) << 3) | szx; + + // Encode as variable-length integer (1-3 bytes) + if (value < 256) + { + return new byte[] { (byte)value }; + } + else if (value < 65536) + { + return new byte[] { (byte)(value >> 8), (byte)(value & 0xFF) }; + } + else + { + return new byte[] { (byte)(value >> 16), (byte)((value >> 8) & 0xFF), (byte)(value & 0xFF) }; + } + } + + private Block2Option DecodeBlock2Option(byte[] data) + { + if (data == null || data.Length == 0) + return null; + + // Decode variable-length integer + int value = 0; + for (int i = 0; i < data.Length; i++) + { + value = (value << 8) | data[i]; + } + + // Extract fields: NUM(variable)|M(1)|SZX(3) + int szx = value & 0x07; + bool hasMore = ((value >> 3) & 0x01) == 1; + int blockNumber = value >> 4; + int blockSize = 1 << (szx + 4); // 2^(SZX + 4) + + return new Block2Option + { + BlockNumber = blockNumber, + HasMore = hasMore, + BlockSize = blockSize + }; + } + + private int GetSizeExponent(int blockSize) + { + // Convert block size to SZX (size exponent) + // Block sizes: 16(0), 32(1), 64(2), 128(3), 256(4), 512(5), 1024(6) + switch (blockSize) + { + case 16: return 0; + case 32: return 1; + case 64: return 2; + case 128: return 3; + case 256: return 4; + case 512: return 5; + case 1024: return 6; + default: return 6; // Default to 1024 + } + } + + #endregion + + #region Helper Classes + + private enum CoapMethod + { + GET, + POST, + PUT, + DELETE + } + + private class Block2Option + { + public int BlockNumber { get; set; } + public bool HasMore { get; set; } + public int BlockSize { get; set; } + } + + private class CoapResponse + { + public byte Code { get; set; } + public int CodeClass { get; set; } + public int CodeDetail { get; set; } + public byte[] Payload { get; set; } + public bool IsSuccess { get; set; } + public Block2Option Block2 { get; set; } + } + + #endregion + } +} diff --git a/WoT/WoT.sln b/WoT/WoT.sln index 27cadcf..a7c3469 100644 --- a/WoT/WoT.sln +++ b/WoT/WoT.sln @@ -7,22 +7,61 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "Core\Core.csproj", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Http", "Binding\Http\Http.csproj", "{B8828C1A-4304-4E77-9AA6-66564BE0B712}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Binding", "Binding", "{076D0C98-A7DF-823F-36FD-2542E60937C7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoAP", "Binding\CoAP\CoAP.csproj", "{469387BB-74B5-44E2-AB0F-13A5C3ED62CD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {7D848794-BB05-449B-8B03-9B3017D58A69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7D848794-BB05-449B-8B03-9B3017D58A69}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D848794-BB05-449B-8B03-9B3017D58A69}.Debug|x64.ActiveCfg = Debug|Any CPU + {7D848794-BB05-449B-8B03-9B3017D58A69}.Debug|x64.Build.0 = Debug|Any CPU + {7D848794-BB05-449B-8B03-9B3017D58A69}.Debug|x86.ActiveCfg = Debug|Any CPU + {7D848794-BB05-449B-8B03-9B3017D58A69}.Debug|x86.Build.0 = Debug|Any CPU {7D848794-BB05-449B-8B03-9B3017D58A69}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D848794-BB05-449B-8B03-9B3017D58A69}.Release|Any CPU.Build.0 = Release|Any CPU + {7D848794-BB05-449B-8B03-9B3017D58A69}.Release|x64.ActiveCfg = Release|Any CPU + {7D848794-BB05-449B-8B03-9B3017D58A69}.Release|x64.Build.0 = Release|Any CPU + {7D848794-BB05-449B-8B03-9B3017D58A69}.Release|x86.ActiveCfg = Release|Any CPU + {7D848794-BB05-449B-8B03-9B3017D58A69}.Release|x86.Build.0 = Release|Any CPU {B8828C1A-4304-4E77-9AA6-66564BE0B712}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B8828C1A-4304-4E77-9AA6-66564BE0B712}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8828C1A-4304-4E77-9AA6-66564BE0B712}.Debug|x64.ActiveCfg = Debug|Any CPU + {B8828C1A-4304-4E77-9AA6-66564BE0B712}.Debug|x64.Build.0 = Debug|Any CPU + {B8828C1A-4304-4E77-9AA6-66564BE0B712}.Debug|x86.ActiveCfg = Debug|Any CPU + {B8828C1A-4304-4E77-9AA6-66564BE0B712}.Debug|x86.Build.0 = Debug|Any CPU {B8828C1A-4304-4E77-9AA6-66564BE0B712}.Release|Any CPU.ActiveCfg = Release|Any CPU {B8828C1A-4304-4E77-9AA6-66564BE0B712}.Release|Any CPU.Build.0 = Release|Any CPU + {B8828C1A-4304-4E77-9AA6-66564BE0B712}.Release|x64.ActiveCfg = Release|Any CPU + {B8828C1A-4304-4E77-9AA6-66564BE0B712}.Release|x64.Build.0 = Release|Any CPU + {B8828C1A-4304-4E77-9AA6-66564BE0B712}.Release|x86.ActiveCfg = Release|Any CPU + {B8828C1A-4304-4E77-9AA6-66564BE0B712}.Release|x86.Build.0 = Release|Any CPU + {469387BB-74B5-44E2-AB0F-13A5C3ED62CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {469387BB-74B5-44E2-AB0F-13A5C3ED62CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {469387BB-74B5-44E2-AB0F-13A5C3ED62CD}.Debug|x64.ActiveCfg = Debug|Any CPU + {469387BB-74B5-44E2-AB0F-13A5C3ED62CD}.Debug|x64.Build.0 = Debug|Any CPU + {469387BB-74B5-44E2-AB0F-13A5C3ED62CD}.Debug|x86.ActiveCfg = Debug|Any CPU + {469387BB-74B5-44E2-AB0F-13A5C3ED62CD}.Debug|x86.Build.0 = Debug|Any CPU + {469387BB-74B5-44E2-AB0F-13A5C3ED62CD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {469387BB-74B5-44E2-AB0F-13A5C3ED62CD}.Release|Any CPU.Build.0 = Release|Any CPU + {469387BB-74B5-44E2-AB0F-13A5C3ED62CD}.Release|x64.ActiveCfg = Release|Any CPU + {469387BB-74B5-44E2-AB0F-13A5C3ED62CD}.Release|x64.Build.0 = Release|Any CPU + {469387BB-74B5-44E2-AB0F-13A5C3ED62CD}.Release|x86.ActiveCfg = Release|Any CPU + {469387BB-74B5-44E2-AB0F-13A5C3ED62CD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {469387BB-74B5-44E2-AB0F-13A5C3ED62CD} = {076D0C98-A7DF-823F-36FD-2542E60937C7} + EndGlobalSection EndGlobal