From 04055220b784ae16b76dd79f4b187957b206b0aa Mon Sep 17 00:00:00 2001 From: Abhipsa Misra Date: Wed, 19 May 2021 13:31:44 -0700 Subject: [PATCH 01/14] feat(shared): Add common resources for convention-based operations --- .../devdoc/Convention-based operations.md | 211 ++++++++++++++++++ iothub/device/src/ClientOptions.cs | 15 +- iothub/device/src/PayloadCollection.cs | 160 +++++++++++++ shared/src/ConventionBasedConstants.cs | 41 ++++ shared/src/DefaultPayloadConvention.cs | 25 +++ shared/src/IWritablePropertyResponse.cs | 35 +++ .../src/Microsoft.Azure.Devices.Shared.csproj | 5 + shared/src/NewtonsoftJsonPayloadSerializer.cs | 71 ++++++ .../NewtonsoftJsonWritablePropertyResponse.cs | 56 +++++ shared/src/PayloadConvention.cs | 38 ++++ shared/src/PayloadEncoder.cs | 32 +++ shared/src/PayloadSerializer.cs | 75 +++++++ shared/src/SystemTextJsonPayloadSerializer.cs | 71 ++++++ .../SystemTextJsonWritablePropertyResponse.cs | 60 +++++ shared/src/Utf8PayloadEncoder.cs | 27 +++ 15 files changed, 921 insertions(+), 1 deletion(-) create mode 100644 iothub/device/devdoc/Convention-based operations.md create mode 100644 iothub/device/src/PayloadCollection.cs create mode 100644 shared/src/ConventionBasedConstants.cs create mode 100644 shared/src/DefaultPayloadConvention.cs create mode 100644 shared/src/IWritablePropertyResponse.cs create mode 100644 shared/src/NewtonsoftJsonPayloadSerializer.cs create mode 100644 shared/src/NewtonsoftJsonWritablePropertyResponse.cs create mode 100644 shared/src/PayloadConvention.cs create mode 100644 shared/src/PayloadEncoder.cs create mode 100644 shared/src/PayloadSerializer.cs create mode 100644 shared/src/SystemTextJsonPayloadSerializer.cs create mode 100644 shared/src/SystemTextJsonWritablePropertyResponse.cs create mode 100644 shared/src/Utf8PayloadEncoder.cs diff --git a/iothub/device/devdoc/Convention-based operations.md b/iothub/device/devdoc/Convention-based operations.md new file mode 100644 index 0000000000..1af4489ab9 --- /dev/null +++ b/iothub/device/devdoc/Convention-based operations.md @@ -0,0 +1,211 @@ +## Plug and Play convention compatible APIs + +#### Common + +```csharp + +public abstract class PayloadConvention { + protected PayloadConvention(); + public abstract PayloadEncoder PayloadEncoder { get; } + public abstract PayloadSerializer PayloadSerializer { get; } + public virtual byte[] GetObjectBytes(object objectToSendWithConvention); +} + +public abstract class PayloadEncoder { + protected PayloadEncoder(); + public abstract Encoding ContentEncoding { get; } + public abstract byte[] EncodeStringToByteArray(string contentPayload); +} + +public abstract class PayloadSerializer { + protected PayloadSerializer(); + public abstract string ContentType { get; } + public abstract T ConvertFromObject(object objectToConvert); + public abstract IWritablePropertyResponse CreateWritablePropertyResponse(object value, int statusCode, long version, string description = null); + public abstract T DeserializeToType(string stringToDeserialize); + public abstract string SerializeToString(object objectToSerialize); + public abstract bool TryGetNestedObjectValue(object objectToConvert, string propertyName, out T outValue); +} + +public sealed class DefaultPayloadConvention : PayloadConvention { + public static readonly DefaultPayloadConvention Instance; + public DefaultPayloadConvention(); + public override PayloadEncoder PayloadEncoder { get; } + public override PayloadSerializer PayloadSerializer { get; } +} + +public class Utf8PayloadEncoder : PayloadEncoder { + public static readonly Utf8PayloadEncoder Instance; + public Utf8PayloadEncoder(); + public override Encoding ContentEncoding { get; } + public override byte[] EncodeStringToByteArray(string contentPayload); +} + +public class NewtonsoftJsonPayloadSerializer : PayloadSerializer { + public static readonly NewtonsoftJsonPayloadSerializer Instance; + public NewtonsoftJsonPayloadSerializer(); + public override string ContentType { get; } + public override T ConvertFromObject(object objectToConvert); + public override IWritablePropertyResponse CreateWritablePropertyResponse(object value, int statusCode, long version, string description = null); + public override T DeserializeToType(string stringToDeserialize); + public override string SerializeToString(object objectToSerialize); + public override bool TryGetNestedObjectValue(object objectToConvert, string propertyName, out T outValue); +} + +public abstract class PayloadCollection : IEnumerable, IEnumerable { + protected PayloadCollection(); + public IDictionary Collection { get; private set; } + public PayloadConvention Convention { get; internal set; } + public virtual object this[string key] { get; set; } + public virtual void Add(string key, object value); + public virtual void AddOrUpdate(string key, object value); + public bool Contains(string key); + public IEnumerator GetEnumerator(); + public virtual byte[] GetPayloadObjectBytes(); + public virtual string GetSerializedString(); + protected void SetCollection(PayloadCollection payloadCollection); + IEnumerator System.Collections.IEnumerable.GetEnumerator(); + public bool TryGetValue(string key, out T value); +} + +public static class ConventionBasedConstants { + public const string AckCodePropertyName = "ac"; + public const string AckDescriptionPropertyName = "ad"; + public const string AckVersionPropertyName = "av"; + public const string ComponentIdentifierKey = "__t"; + public const string ComponentIdentifierValue = "c"; + public const string ValuePropertyName = "value"; +} +``` + +### Properties + +```csharp +/// +/// Retrieve the device properties. +/// +/// A cancellation token to cancel the operation. +/// The device properties. +public Task GetClientPropertiesAsync(CancellationToken cancellationToken = default); + +/// +/// Update properties. +/// +/// Reported properties to push. +/// A cancellation token to cancel the operation. +/// The response containing the operation request Id and updated version no. +public Task UpdateClientPropertiesAsync(ClientPropertyCollection propertyCollection, CancellationToken cancellationToken = default); + +/// +/// Sets the global listener for Writable properties +/// +/// The global call back to handle all writable property updates. +/// Generic parameter to be interpreted by the client code. +/// A cancellation token to cancel the operation. +public Task SubscribeToWritablePropertiesEventAsync(Func callback, object userContext, CancellationToken cancellationToken = default); +``` + +#### All related types + +```csharp +public class ClientProperties : ClientPropertyCollection { + public ClientPropertyCollection Writable { get; private set; } +} + +public class ClientPropertyCollection : PayloadCollection { + public ClientPropertyCollection(); + public long Version { get; protected set; } + public void Add(IDictionary properties, string componentName = null); + public override void Add(string propertyName, object propertyValue); + public void Add(string propertyName, object propertyValue, int statusCode, long version, string description = null, string componentName = null); + public void Add(string propertyName, object propertyValue, string componentName); + public void AddOrUpdate(IDictionary properties, string componentNam = null); + public override void AddOrUpdate(string propertyName, object propertyValue); + public void AddOrUpdate(string propertyName, object propertyValue, int statusCode, long version, string description = null, string componentName = null); + public void AddOrUpdate(string propertyName, object propertyValue, string componentName); + public bool Contains(string componentName, string propertyName); + public virtual bool TryGetValue(string componentName, string propertyName, out T propertyValue); +} + +public interface IWritablePropertyResponse { + int AckCode { get; set; } + string AckDescription { get; set; } + long AckVersion { get; set; } + object Value { get; set; } +} + +public sealed class NewtonsoftJsonWritablePropertyResponse : IWritablePropertyResponse { + public NewtonsoftJsonWritablePropertyResponse(object propertyValue, int ackCode, long ackVersion, string ackDescription = null); + public int AckCode { get; set; } + public string AckDescription { get; set; } + public long AckVersion { get; set; } + public object Value { get; set; } +} + +public class ClientPropertiesUpdateResponse { + public ClientPropertiesUpdateResponse(); + public string RequestId { get; internal set; } + public long Version { get; internal set; } +} +``` + +### Telemetry + +```csharp +/// +/// Send telemetry using the specified message. +/// +/// +/// Use the constructor to pass in the optional +/// that specifies your payload and serialization and encoding rules. +/// +/// The telemetry message. +/// A cancellation token to cancel the operation. +public Task SendTelemetryAsync(TelemetryMessage telemetryMessage, CancellationToken cancellationToken = default); +``` +#### All related types + +```csharp +public class TelemetryCollection : PayloadCollection { + public TelemetryCollection(); + public override void Add(string telemetryName, object telemetryValue); + public override void AddOrUpdate(string telemetryName, object telemetryValue); +} + +public class TelemetryMessage : Message { + public TelemetryMessage(string componentName = null); + public new string ContentEncoding { get; internal set; } + public new string ContentType { get; internal set; } + public TelemetryCollection Telemetry { get; set; } + public override Stream GetBodyStream(); +} +``` + +### Commands + +```csharp +/// +/// Set the global command callback handler. +/// +/// A method implementation that will handle the incoming command. +/// Generic parameter to be interpreted by the client code. +/// A cancellation token to cancel the operation. +public Task SubscribeToCommandsAsync(Func> callback, object userContext, CancellationToken cancellationToken = default); +``` +#### All related types + +```csharp +public sealed class CommandRequest { + public string CommandName { get; private set; } + public string ComponentName { get; private set; } + public string DataAsJson { get; } + public T GetData(); +} + +public sealed class CommandResponse { + public CommandResponse(int status); + public CommandResponse(object result, int status); + public string ResultAsJson { get; } + public int Status { get; private set; } +} +``` diff --git a/iothub/device/src/ClientOptions.cs b/iothub/device/src/ClientOptions.cs index 8f6fd9cd0e..47d43a2ddf 100644 --- a/iothub/device/src/ClientOptions.cs +++ b/iothub/device/src/ClientOptions.cs @@ -19,7 +19,7 @@ public class ClientOptions /// /// The transport settings to use for all file upload operations, regardless of what protocol the device - /// client is configured with. All file upload operations take place over https. + /// client is configured with. All file upload operations take place over https. /// If FileUploadTransportSettings is not provided, then file upload operations will use the client certificates configured /// in the transport settings set for the non-file upload operations. /// @@ -54,5 +54,18 @@ public class ClientOptions /// or the flow. /// public int SasTokenRenewalBuffer { get; set; } + + /// + /// The payload convention to be used to serialize and encode the messages for convention based methods. + /// + /// + /// The defines both the serializer and encoding to be used for convention based messages. + /// You will only need to set this if you have objects that have special serialization rules or require a specific byte encoding. + /// + /// The default value is set to which uses the serializer + /// and encoder. + /// + /// + public PayloadConvention PayloadConvention { get; set; } = DefaultPayloadConvention.Instance; } } diff --git a/iothub/device/src/PayloadCollection.cs b/iothub/device/src/PayloadCollection.cs new file mode 100644 index 0000000000..b417db2ee6 --- /dev/null +++ b/iothub/device/src/PayloadCollection.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using Microsoft.Azure.Devices.Shared; + +namespace Microsoft.Azure.Devices.Client +{ + /// + /// The base class for all payloads that accept a . + /// + /// + /// This classes uses the and + /// based by default. + /// + public abstract class PayloadCollection : IEnumerable + { + /// + /// The underlying collection for the payload. + /// + public IDictionary Collection { get; private set; } = new Dictionary(); + + /// + /// The convention to use with this payload. + /// + public PayloadConvention Convention { get; internal set; } + + /// + /// Get the value at the specified key. + /// + /// + /// This accessor is best used to access and cast to simple types. + /// It is recommended to use to deserialize to a complex type. + /// + /// Key of value. + /// The specified property. + public virtual object this[string key] + { + get => Collection[key]; + set => AddOrUpdate(key, value); + } + + /// + /// Adds the key-value pair to the collection. + /// + /// + /// + /// + /// An element with the same key already exists in the collection. + public virtual void Add(string key, object value) + { + Collection.Add(key, value); + } + + /// + /// Adds or updates the key-value pair to the collection. + /// + /// The name of the telemetry. + /// The value of the telemetry. + /// is null. + public virtual void AddOrUpdate(string key, object value) + { + Collection[key] = value; + } + + /// + /// Gets the collection as a byte array. + /// + /// + /// This will get the fully encoded serialized string using both . + /// and methods implemented in the . + /// + /// A fully encoded serialized string. + public virtual byte[] GetPayloadObjectBytes() + { + return Convention.GetObjectBytes(Collection); + } + + /// + /// Determines whether the specified property is present. + /// + /// The key in the collection to locate. + /// true if the specified property is present; otherwise, false. + public bool Contains(string key) + { + return Collection.ContainsKey(key); + } + + /// + /// Gets the value of the object from the collection. + /// + /// + /// This class is used for both sending and receiving properties for the device. + /// + /// The type to cast the object to. + /// The key of the property to get. + /// The value of the object from the collection. + /// True if the collection contains an element with the specified key; otherwise, it returns false. + public bool TryGetValue(string key, out T value) + { + if (Collection.ContainsKey(key)) + { + // If the object is of type T go ahead and return it. + if (Collection[key] is T valueRef) + { + value = valueRef; + return true; + } + // If it's not we need to try to convert it using the serializer. + // JObject or JsonElement + value = Convention.PayloadSerializer.ConvertFromObject(Collection[key]); + return true; + } + + value = default; + return false; + } + + /// + /// Returns a serialized string of this collection from the method. + /// + /// A serialized string of this collection. + public virtual string GetSerializedString() + { + return Convention.PayloadSerializer.SerializeToString(Collection); + } + + /// + public IEnumerator GetEnumerator() + { + foreach (object property in Collection) + { + yield return property; + } + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Will set the underlying of the payload collection. + /// + /// The collection to get the underlying dictionary from. + protected void SetCollection(PayloadCollection payloadCollection) + { + if (payloadCollection == null) + { + throw new ArgumentNullException(); + } + + Collection = payloadCollection.Collection; + Convention = payloadCollection.Convention; + } + } +} diff --git a/shared/src/ConventionBasedConstants.cs b/shared/src/ConventionBasedConstants.cs new file mode 100644 index 0000000000..1d4649fc5e --- /dev/null +++ b/shared/src/ConventionBasedConstants.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Azure.Devices.Shared +{ + /// + /// Container for common convention based constants. + /// + public static class ConventionBasedConstants + { + /// + /// Marker key to indicate a component-level property. + /// + public const string ComponentIdentifierKey = "__t"; + + /// + /// Marker value to indicate a component-level property. + /// + public const string ComponentIdentifierValue = "c"; + + /// + /// Represents the JSON document property name for the value of a writable property response. + /// + public const string ValuePropertyName = "value"; + + /// + /// Represents the JSON document property name for the Ack Code of a writable property response. + /// + public const string AckCodePropertyName = "ac"; + + /// + /// Represents the JSON document property name for the Ack Version of a writable property response. + /// + public const string AckVersionPropertyName = "av"; + + /// + /// Represents the JSON document property name for the Ack Description of a writable property response. + /// + public const string AckDescriptionPropertyName = "ad"; + } +} diff --git a/shared/src/DefaultPayloadConvention.cs b/shared/src/DefaultPayloadConvention.cs new file mode 100644 index 0000000000..749fb31a13 --- /dev/null +++ b/shared/src/DefaultPayloadConvention.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Azure.Devices.Shared +{ + /// + /// The default implementation of the class. + /// + /// + /// This class makes use of the serializer and the . + /// + public sealed class DefaultPayloadConvention : PayloadConvention + { + /// + /// A static instance of this class. + /// + public static readonly DefaultPayloadConvention Instance = new DefaultPayloadConvention(); + + /// + public override PayloadSerializer PayloadSerializer { get; } = NewtonsoftJsonPayloadSerializer.Instance; + + /// + public override PayloadEncoder PayloadEncoder { get; } = Utf8PayloadEncoder.Instance; + } +} diff --git a/shared/src/IWritablePropertyResponse.cs b/shared/src/IWritablePropertyResponse.cs new file mode 100644 index 0000000000..aea4986e4d --- /dev/null +++ b/shared/src/IWritablePropertyResponse.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Azure.Devices.Shared +{ + /// + /// The interface that defines the structure of a writable property response. + /// + /// + /// This interface is used to allow extension to use a different set of attributes for serialization. + /// For example our default implementation found in is based on serializer attributes. + /// + public interface IWritablePropertyResponse + { + /// + /// The unserialized property value. + /// + public object Value { get; set; } + + /// + /// The acknowledgment code, usually an HTTP Status Code e.g. 200, 400. + /// + public int AckCode { get; set; } + + /// + /// The acknowledgment version, as supplied in the property update request. + /// + public long AckVersion { get; set; } + + /// + /// The acknowledgment description, an optional, human-readable message about the result of the property update. + /// + public string AckDescription { get; set; } + } +} diff --git a/shared/src/Microsoft.Azure.Devices.Shared.csproj b/shared/src/Microsoft.Azure.Devices.Shared.csproj index c62a779b83..28b2d2afa3 100644 --- a/shared/src/Microsoft.Azure.Devices.Shared.csproj +++ b/shared/src/Microsoft.Azure.Devices.Shared.csproj @@ -43,6 +43,11 @@ + + + + + diff --git a/shared/src/NewtonsoftJsonPayloadSerializer.cs b/shared/src/NewtonsoftJsonPayloadSerializer.cs new file mode 100644 index 0000000000..ec0b10cef0 --- /dev/null +++ b/shared/src/NewtonsoftJsonPayloadSerializer.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.Devices.Shared +{ + /// + /// A implementation. + /// + public class NewtonsoftJsonPayloadSerializer : PayloadSerializer + { + /// + /// The content type string. + /// + internal const string ApplicationJson = "application/json"; + + /// + /// The default instance of this class. + /// + public static readonly NewtonsoftJsonPayloadSerializer Instance = new NewtonsoftJsonPayloadSerializer(); + + /// + public override string ContentType => ApplicationJson; + + /// + public override string SerializeToString(object objectToSerialize) + { + return JsonConvert.SerializeObject(objectToSerialize); + } + + /// + public override T DeserializeToType(string stringToDeserialize) + { + return JsonConvert.DeserializeObject(stringToDeserialize); + } + + /// + public override T ConvertFromObject(object objectToConvert) + { + if (objectToConvert == null) + { + return default; + } + return ((JObject)objectToConvert).ToObject(); + } + + /// + public override bool TryGetNestedObjectValue(object nestedObject, string propertyName, out T outValue) + { + outValue = default; + if (nestedObject == null || string.IsNullOrEmpty(propertyName)) + { + return false; + } + if (((JObject)nestedObject).TryGetValue(propertyName, out JToken element)) + { + outValue = element.ToObject(); + return true; + } + return false; + } + + /// + public override IWritablePropertyResponse CreateWritablePropertyResponse(object value, int statusCode, long version, string description = null) + { + return new NewtonsoftJsonWritablePropertyResponse(value, statusCode, version, description); + } + } +} diff --git a/shared/src/NewtonsoftJsonWritablePropertyResponse.cs b/shared/src/NewtonsoftJsonWritablePropertyResponse.cs new file mode 100644 index 0000000000..348d725edd --- /dev/null +++ b/shared/src/NewtonsoftJsonWritablePropertyResponse.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Newtonsoft.Json; + +namespace Microsoft.Azure.Devices.Shared +{ + /// + /// An optional, helper class for constructing a writable property response. + /// + /// + /// This helper class will only work with . + /// It uses based to define the JSON property names. + /// + public sealed class NewtonsoftJsonWritablePropertyResponse : IWritablePropertyResponse + { + /// + /// Convenience constructor for specifying the properties. + /// + /// The unserialized property value. + /// The acknowledgment code, usually an HTTP Status Code e.g. 200, 400. + /// The acknowledgment version, as supplied in the property update request. + /// The acknowledgment description, an optional, human-readable message about the result of the property update. + public NewtonsoftJsonWritablePropertyResponse(object propertyValue, int ackCode, long ackVersion, string ackDescription = default) + { + Value = propertyValue; + AckCode = ackCode; + AckVersion = ackVersion; + AckDescription = ackDescription; + } + + /// + /// The unserialized property value. + /// + [JsonProperty(ConventionBasedConstants.ValuePropertyName)] + public object Value { get; set; } + + /// + /// The acknowledgment code, usually an HTTP Status Code e.g. 200, 400. + /// + [JsonProperty(ConventionBasedConstants.AckCodePropertyName)] + public int AckCode { get; set; } + + /// + /// The acknowledgment version, as supplied in the property update request. + /// + [JsonProperty(ConventionBasedConstants.AckVersionPropertyName)] + public long AckVersion { get; set; } + + /// + /// The acknowledgment description, an optional, human-readable message about the result of the property update. + /// + [JsonProperty(ConventionBasedConstants.AckDescriptionPropertyName, DefaultValueHandling = DefaultValueHandling.Ignore)] + public string AckDescription { get; set; } + } +} diff --git a/shared/src/PayloadConvention.cs b/shared/src/PayloadConvention.cs new file mode 100644 index 0000000000..99fd943723 --- /dev/null +++ b/shared/src/PayloadConvention.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Azure.Devices.Shared +{ + /// + /// The payload convention class. + /// + /// The payload convention is used to define a specific serializer as well as a specific content encoding. + /// For example, IoT has a convention that is designed + /// to make it easier to get started with products that use specific conventions by default. + public abstract class PayloadConvention + { + /// + /// Gets the serializer used for the payload. + /// + /// A serializer that will be used to convert the payload object to a string. + public abstract PayloadSerializer PayloadSerializer { get; } + + /// + /// Gets the encoder used for the payload to be serialized. + /// + /// An encoder that will be used to convert the serialized string to a byte array. + public abstract PayloadEncoder PayloadEncoder { get; } + + /// + /// Returns the byte array for the convention-based message. + /// + /// This base class will use the and to create this byte array. + /// The convention-based message that is to be sent. + /// The correctly encoded object for this convention. + public virtual byte[] GetObjectBytes(object objectToSendWithConvention) + { + string serializedString = PayloadSerializer.SerializeToString(objectToSendWithConvention); + return PayloadEncoder.EncodeStringToByteArray(serializedString); + } + } +} diff --git a/shared/src/PayloadEncoder.cs b/shared/src/PayloadEncoder.cs new file mode 100644 index 0000000000..03b5c8bb72 --- /dev/null +++ b/shared/src/PayloadEncoder.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Microsoft.Azure.Devices.Shared +{ + /// + /// This class specifies the byte encoding for the payload. + /// + /// + /// The encoder is responsible for encoding all of your objects into the correct bytes for the that uses it. + /// + /// By default we have implemented the class that uses + /// to handle the encoding for the class. + /// + /// + public abstract class PayloadEncoder + { + /// + /// The used for the payload. + /// + public abstract Encoding ContentEncoding { get; } + + /// + /// Outputs an encoded byte array for the specified payload string. + /// + /// The contents of the message payload. + /// An encoded byte array. + public abstract byte[] EncodeStringToByteArray(string contentPayload); + } +} diff --git a/shared/src/PayloadSerializer.cs b/shared/src/PayloadSerializer.cs new file mode 100644 index 0000000000..9fced3233e --- /dev/null +++ b/shared/src/PayloadSerializer.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Azure.Devices.Shared +{ + /// + /// Provides the serialization for a specified convention. + /// + /// + /// The serializer is responsible for converting all of your objects into the correct format for the that uses it. + /// + /// By default we have implemented the class that uses + /// to handle the serialization for the class. + /// + /// + public abstract class PayloadSerializer + { + /// + /// Used to specify what type of content to expect. + /// + /// This can be free-form but should adhere to standard MIME types. For example, "application/json" is what we implement by default. + /// A string representing the content type to use when sending a payload. + public abstract string ContentType { get; } + + /// + /// Serialize the specified object to a string. + /// + /// Object to serialize. + /// A serialized string of the object. + public abstract string SerializeToString(object objectToSerialize); + + /// + /// Convert the serialized string to an object. + /// + /// The type you want to return. + /// String to deserialize. + /// A fully deserialized type. + public abstract T DeserializeToType(string stringToDeserialize); + + /// + /// Converts the object using the serializer. + /// + /// This class is used by the PayloadCollection-based classes to attempt to convert from the native serializer type + /// (for example, JObject or JsonElement) to the desired type. + /// When you implement this you need to be aware of what type your serializer will use for anonymous types. + /// The type to convert to. + /// The object to convert. + /// A converted object + public abstract T ConvertFromObject(object objectToConvert); + + /// + /// Gets a nested property from the serialized data. + /// + /// + /// This is used internally by our PayloadCollection-based classes to attempt to get a property of the underlying object. + /// An example of this would be a property under the component. + /// + /// The type to convert the retrieved property to. + /// The object that might contain the nested property. + /// The name of the property to be retrieved. + /// True if the nested object contains an element with the specified key; otherwise, it returns false. + /// + public abstract bool TryGetNestedObjectValue(object nestedObject, string propertyName, out T outValue); + + /// + /// Creates the correct to be used with this serializer. + /// + /// The value of the property. + /// The status code of the write operation. + /// The version the property is responding to. + /// An optional description of the writable property response. + /// The writable property response to be used with this serializer. + public abstract IWritablePropertyResponse CreateWritablePropertyResponse(object value, int statusCode, long version, string description = default); + } +} diff --git a/shared/src/SystemTextJsonPayloadSerializer.cs b/shared/src/SystemTextJsonPayloadSerializer.cs new file mode 100644 index 0000000000..8a5ab3f235 --- /dev/null +++ b/shared/src/SystemTextJsonPayloadSerializer.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !NET451 + +using System.Text.Json; +using Microsoft.Azure.Devices.Shared; + +namespace Microsoft.Azure.Devices.Client.Samples +{ + /// + /// A implementation. + /// + public class SystemTextJsonPayloadSerializer : PayloadSerializer + { + /// + /// The Content Type string. + /// + internal const string ApplicationJson = "application/json"; + + /// + /// The default instance of this class. + /// + public static readonly SystemTextJsonPayloadSerializer Instance = new SystemTextJsonPayloadSerializer(); + + /// + public override string ContentType => ApplicationJson; + + /// + public override string SerializeToString(object objectToSerialize) + { + return JsonSerializer.Serialize(objectToSerialize); + } + + /// + public override T DeserializeToType(string stringToDeserialize) + { + return JsonSerializer.Deserialize(stringToDeserialize); + } + + /// + public override T ConvertFromObject(object objectToConvert) + { + return DeserializeToType(((JsonElement)objectToConvert).ToString()); + } + + /// + public override bool TryGetNestedObjectValue(object nestedObject, string propertyName, out T outValue) + { + outValue = default; + if (nestedObject == null || string.IsNullOrEmpty(propertyName)) + { + return false; + } + if (((JsonElement)nestedObject).TryGetProperty(propertyName, out JsonElement element)) + { + outValue = DeserializeToType(element.GetRawText()); + return true; + } + return false; + } + + /// + public override IWritablePropertyResponse CreateWritablePropertyResponse(object value, int statusCode, long version, string description = null) + { + return new SystemTextJsonWritablePropertyResponse(value, statusCode, version, description); + } + } +} + +#endif diff --git a/shared/src/SystemTextJsonWritablePropertyResponse.cs b/shared/src/SystemTextJsonWritablePropertyResponse.cs new file mode 100644 index 0000000000..8873736908 --- /dev/null +++ b/shared/src/SystemTextJsonWritablePropertyResponse.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !NET451 + +using System.Text.Json.Serialization; + +namespace Microsoft.Azure.Devices.Shared +{ + /// + /// An optional, helper class for constructing a writable property response. + /// + /// + /// This helper class will only work with . + /// It uses based to define the JSON property names. + /// + public sealed class SystemTextJsonWritablePropertyResponse : IWritablePropertyResponse + { + /// + /// Convenience constructor for specifying the properties. + /// + /// The unserialized property value. + /// The acknowledgment code, usually an HTTP Status Code e.g. 200, 400. + /// The acknowledgment version, as supplied in the property update request. + /// The acknowledgment description, an optional, human-readable message about the result of the property update. + public SystemTextJsonWritablePropertyResponse(object propertyValue, int ackCode, long ackVersion, string ackDescription = default) + { + Value = propertyValue; + AckCode = ackCode; + AckVersion = ackVersion; + AckDescription = ackDescription; + } + + /// + /// The unserialized property value. + /// + [JsonPropertyName(ConventionBasedConstants.ValuePropertyName)] + public object Value { get; set; } + + /// + /// The acknowledgment code, usually an HTTP Status Code e.g. 200, 400. + /// + [JsonPropertyName(ConventionBasedConstants.AckCodePropertyName)] + public int AckCode { get; set; } + + /// + /// The acknowledgment version, as supplied in the property update request. + /// + [JsonPropertyName(ConventionBasedConstants.AckVersionPropertyName)] + public long AckVersion { get; set; } + + /// + /// The acknowledgment description, an optional, human-readable message about the result of the property update. + /// + [JsonPropertyName(ConventionBasedConstants.AckDescriptionPropertyName)] + public string AckDescription { get; set; } + } +} + +#endif diff --git a/shared/src/Utf8PayloadEncoder.cs b/shared/src/Utf8PayloadEncoder.cs new file mode 100644 index 0000000000..0330c3fcf2 --- /dev/null +++ b/shared/src/Utf8PayloadEncoder.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Microsoft.Azure.Devices.Shared +{ + /// + /// A UTF-8 implementation. + /// + public class Utf8PayloadEncoder : PayloadEncoder + { + /// + /// The default instance of this class. + /// + public static readonly Utf8PayloadEncoder Instance = new Utf8PayloadEncoder(); + + /// + public override Encoding ContentEncoding => Encoding.UTF8; + + /// + public override byte[] EncodeStringToByteArray(string contentPayload) + { + return ContentEncoding.GetBytes(contentPayload); + } + } +} From 3bdd81440cf4c839859612ac345ab7aadcd6c24a Mon Sep 17 00:00:00 2001 From: Abhipsa Misra Date: Tue, 18 May 2021 14:16:16 -0700 Subject: [PATCH 02/14] feat(iot-device): Add support for convention-based telemetry operation --- iothub/device/src/ClientOptions.cs | 4 +- .../DeviceClient.ConventionBasedOperations.cs | 30 ++ iothub/device/src/DeviceClient.cs | 2 +- iothub/device/src/IDelegatingHandler.cs | 4 +- ...nternalClient.ConventionBasedOperations.cs | 36 ++ iothub/device/src/InternalClient.cs | 6 +- iothub/device/src/IotHubClientDiagnostic.cs | 4 +- iothub/device/src/Message.cs | 379 +-------------- iothub/device/src/MessageBase.cs | 447 ++++++++++++++++++ .../ModuleClient.ConventionBasedOperations.cs | 30 ++ iothub/device/src/ModuleClient.cs | 2 +- iothub/device/src/TelemetryCollection.cs | 37 ++ iothub/device/src/TelemetryMessage.cs | 45 ++ .../Transport/Amqp/AmqpTransportHandler.cs | 4 +- iothub/device/src/Transport/Amqp/AmqpUnit.cs | 8 +- .../AmqpIot/AmqpIotMessageConverter.cs | 6 +- .../Transport/AmqpIot/AmqpIotSendingLink.cs | 4 +- .../src/Transport/DefaultDelegatingHandler.cs | 4 +- .../src/Transport/ErrorDelegatingHandler.cs | 4 +- .../src/Transport/HttpTransportHandler.cs | 8 +- .../Transport/Mqtt/MqttTransportHandler.cs | 4 +- .../src/Transport/RetryDelegatingHandler.cs | 4 +- .../tests/Mqtt/MqttIotHubAdapterTest.cs | 10 +- 23 files changed, 675 insertions(+), 407 deletions(-) create mode 100644 iothub/device/src/DeviceClient.ConventionBasedOperations.cs create mode 100644 iothub/device/src/InternalClient.ConventionBasedOperations.cs create mode 100644 iothub/device/src/MessageBase.cs create mode 100644 iothub/device/src/ModuleClient.ConventionBasedOperations.cs create mode 100644 iothub/device/src/TelemetryCollection.cs create mode 100644 iothub/device/src/TelemetryMessage.cs diff --git a/iothub/device/src/ClientOptions.cs b/iothub/device/src/ClientOptions.cs index 47d43a2ddf..d1ea0c7546 100644 --- a/iothub/device/src/ClientOptions.cs +++ b/iothub/device/src/ClientOptions.cs @@ -26,8 +26,8 @@ public class ClientOptions public Http1TransportSettings FileUploadTransportSettings { get; set; } = new Http1TransportSettings(); /// - /// The configuration for setting for every message sent by the device or module client instance. - /// The default behavior is that is set only by the user. + /// The configuration for setting for every message sent by the device or module client instance. + /// The default behavior is that is set only by the user. /// public SdkAssignsMessageId SdkAssignsMessageId { get; set; } = SdkAssignsMessageId.Never; diff --git a/iothub/device/src/DeviceClient.ConventionBasedOperations.cs b/iothub/device/src/DeviceClient.ConventionBasedOperations.cs new file mode 100644 index 0000000000..70db59d4fd --- /dev/null +++ b/iothub/device/src/DeviceClient.ConventionBasedOperations.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.Devices.Client +{ + /// + /// Contains methods that a convention based device can use to send telemetry to the service, + /// respond to commands and perform operations on its properties. + /// + /// + public partial class DeviceClient : IDisposable + { + /// + /// Send telemetry using the specified message. + /// + /// + /// Use the constructor to pass in the optional component name + /// that the telemetry message is from. + /// + /// The telemetry message. + /// A cancellation token to cancel the operation. + /// + public Task SendTelemetryAsync(TelemetryMessage telemetryMessage, CancellationToken cancellationToken = default) + => InternalClient.SendTelemetryAsync(telemetryMessage, cancellationToken); + } +} diff --git a/iothub/device/src/DeviceClient.cs b/iothub/device/src/DeviceClient.cs index d6179291ce..0bc6d5d1fc 100644 --- a/iothub/device/src/DeviceClient.cs +++ b/iothub/device/src/DeviceClient.cs @@ -15,7 +15,7 @@ namespace Microsoft.Azure.Devices.Client /// Contains methods that a device can use to send messages to and receive from the service. /// /// - public class DeviceClient : IDisposable + public partial class DeviceClient : IDisposable { /// /// Default operation timeout. diff --git a/iothub/device/src/IDelegatingHandler.cs b/iothub/device/src/IDelegatingHandler.cs index 2df13cfa81..7b8cfa8918 100644 --- a/iothub/device/src/IDelegatingHandler.cs +++ b/iothub/device/src/IDelegatingHandler.cs @@ -23,9 +23,9 @@ internal interface IDelegatingHandler : IContinuationProvider messages, CancellationToken cancellationToken); + Task SendEventAsync(IEnumerable messages, CancellationToken cancellationToken); // Telemetry downlink for devices. Task ReceiveAsync(CancellationToken cancellationToken); diff --git a/iothub/device/src/InternalClient.ConventionBasedOperations.cs b/iothub/device/src/InternalClient.ConventionBasedOperations.cs new file mode 100644 index 0000000000..4cd5607bd1 --- /dev/null +++ b/iothub/device/src/InternalClient.ConventionBasedOperations.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.Devices.Shared; + +namespace Microsoft.Azure.Devices.Client +{ + /// + /// Contains methods that a convention based device/ module can use to send telemetry to the service, + /// respond to commands and perform operations on its properties. + /// + internal partial class InternalClient + { + internal PayloadConvention PayloadConvention => _clientOptions.PayloadConvention ?? DefaultPayloadConvention.Instance; + + internal Task SendTelemetryAsync(TelemetryMessage telemetryMessage, CancellationToken cancellationToken) + { + if (telemetryMessage == null) + { + throw new ArgumentNullException(nameof(telemetryMessage)); + } + + if (telemetryMessage.Telemetry != null) + { + telemetryMessage.Telemetry.Convention = PayloadConvention; + telemetryMessage.PayloadContentEncoding = PayloadConvention.PayloadEncoder.ContentEncoding.WebName; + telemetryMessage.PayloadContentType = PayloadConvention.PayloadSerializer.ContentType; + } + + return SendEventAsync(telemetryMessage, cancellationToken); + } + } +} diff --git a/iothub/device/src/InternalClient.cs b/iothub/device/src/InternalClient.cs index 5f4794c9c9..32d8df73c8 100644 --- a/iothub/device/src/InternalClient.cs +++ b/iothub/device/src/InternalClient.cs @@ -105,7 +105,7 @@ public enum MessageResponse /// Contains methods that a device can use to send messages to and receive messages from the service, /// respond to direct method invocations from the service, and send and receive twin property updates. /// - internal class InternalClient : IDisposable + internal partial class InternalClient : IDisposable { private readonly SemaphoreSlim _methodsSemaphore = new SemaphoreSlim(1, 1); private readonly SemaphoreSlim _deviceReceiveMessageSemaphore = new SemaphoreSlim(1, 1); @@ -643,7 +643,7 @@ public Task RejectAsync(Message message, CancellationToken cancellationToken) /// Sends an event to device hub /// /// The message containing the event - public async Task SendEventAsync(Message message) + public async Task SendEventAsync(MessageBase message) { try { @@ -662,7 +662,7 @@ public async Task SendEventAsync(Message message) /// Sends an event to device hub /// /// The message containing the event - public Task SendEventAsync(Message message, CancellationToken cancellationToken) + public Task SendEventAsync(MessageBase message, CancellationToken cancellationToken) { if (message == null) { diff --git a/iothub/device/src/IotHubClientDiagnostic.cs b/iothub/device/src/IotHubClientDiagnostic.cs index 4ed50de462..0917efc570 100644 --- a/iothub/device/src/IotHubClientDiagnostic.cs +++ b/iothub/device/src/IotHubClientDiagnostic.cs @@ -12,7 +12,7 @@ internal class IotHubClientDiagnostic private const string DiagnosticCreationTimeUtcKey = "creationtimeutc"; private static readonly DateTime Dt1970 = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - internal static bool AddDiagnosticInfoIfNecessary(Message message, int diagnosticSamplingPercentage, ref int currentMessageCount) + internal static bool AddDiagnosticInfoIfNecessary(MessageBase message, int diagnosticSamplingPercentage, ref int currentMessageCount) { bool result = false; @@ -27,7 +27,7 @@ internal static bool AddDiagnosticInfoIfNecessary(Message message, int diagnosti return result; } - internal static bool HasDiagnosticProperties(Message message) + internal static bool HasDiagnosticProperties(MessageBase message) { return message.SystemProperties.ContainsKey(MessageSystemPropertyNames.DiagId) && message.SystemProperties.ContainsKey(MessageSystemPropertyNames.DiagCorrelationContext); } diff --git a/iothub/device/src/Message.cs b/iothub/device/src/Message.cs index 2130074304..1c5fb54f81 100644 --- a/iothub/device/src/Message.cs +++ b/iothub/device/src/Message.cs @@ -1,12 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using System; using System.IO; -using System.Threading; -using Microsoft.Azure.Devices.Client.Extensions; -using Microsoft.Azure.Devices.Client.Common.Api; -using System.Collections.Generic; using Microsoft.Azure.Devices.Shared; namespace Microsoft.Azure.Devices.Client @@ -14,26 +9,14 @@ namespace Microsoft.Azure.Devices.Client /// /// The data structure represent the message that is used for interacting with IotHub. /// - public sealed class Message : IReadOnlyIndicator, IDisposable + public sealed class Message : MessageBase { - private volatile Stream _bodyStream; - private bool _disposed; - private StreamDisposalResponsibility _streamDisposalResponsibility; - - private const long StreamCannotSeek = -1; - private long _originalStreamPosition = StreamCannotSeek; - - private int _getBodyCalled; - private long _sizeInBytesCalled; - /// /// Default constructor with no body data /// public Message() + : base() { - Properties = new ReadOnlyDictionary45(new Dictionary(StringComparer.OrdinalIgnoreCase), this); - SystemProperties = new ReadOnlyDictionary45(new Dictionary(StringComparer.OrdinalIgnoreCase), this); - InitializeWithStream(Stream.Null, StreamDisposalResponsibility.Sdk); } /// @@ -43,12 +26,8 @@ public Message() /// A stream which will be used as body stream. // UWP cannot expose a method with System.IO.Stream in signature. TODO: consider adding an IRandomAccessStream overload public Message(Stream stream) - : this() + : base(stream) { - if (stream != null) - { - InitializeWithStream(stream, StreamDisposalResponsibility.App); - } } /// @@ -57,171 +36,27 @@ public Message(Stream stream) /// User should treat the input byte array as immutable when sending the message. /// A byte array which will be used to form the body stream. public Message(byte[] byteArray) - : this(new MemoryStream(byteArray)) + : base(byteArray) { - // Reset the owning of the stream - _streamDisposalResponsibility = StreamDisposalResponsibility.Sdk; } /// - /// This constructor is only used on the Gateway HTTP path so that we can clean up the stream. + /// This constructor is only used on the Gateway HTTP path and AMQP SendEventAsync() so that we can clean up the stream. /// /// A stream which will be used as body stream. /// Indicates if the stream passed in should be disposed by the client library, or by the calling application. internal Message(Stream stream, StreamDisposalResponsibility streamDisposalResponsibility) - : this(stream) - { - _streamDisposalResponsibility = streamDisposalResponsibility; - } - - /// - /// [Required for two way requests] Used to correlate two-way communication. - /// Format: A case-sensitive string ( up to 128 char long) of ASCII 7-bit alphanumeric chars - /// + {'-', ':', '/', '\', '.', '+', '%', '_', '#', '*', '?', '!', '(', ')', ',', '=', '@', ';', '$', '''}. - /// Non-alphanumeric characters are from URN RFC. - /// - public string MessageId - { - get => GetSystemProperty(MessageSystemPropertyNames.MessageId); - set => SystemProperties[MessageSystemPropertyNames.MessageId] = value; - } - - /// - /// [Required] Destination of the message - /// - public string To - { - get => GetSystemProperty(MessageSystemPropertyNames.To); - set => SystemProperties[MessageSystemPropertyNames.To] = value; - } - - /// - /// [Optional] The time when this message is considered expired - /// - public DateTime ExpiryTimeUtc - { - get => GetSystemProperty(MessageSystemPropertyNames.ExpiryTimeUtc); - internal set => SystemProperties[MessageSystemPropertyNames.ExpiryTimeUtc] = value; - } - - /// - /// Used in message responses and feedback - /// - public string CorrelationId - { - get => GetSystemProperty(MessageSystemPropertyNames.CorrelationId); - set => SystemProperties[MessageSystemPropertyNames.CorrelationId] = value; - } - - /// - /// [Required] SequenceNumber of the received message - /// - public ulong SequenceNumber - { - get => GetSystemProperty(MessageSystemPropertyNames.SequenceNumber); - internal set => SystemProperties[MessageSystemPropertyNames.SequenceNumber] = value; - } - - /// - /// [Required] LockToken of the received message - /// - public string LockToken - { - get => GetSystemProperty(MessageSystemPropertyNames.LockToken); - internal set => SystemProperties[MessageSystemPropertyNames.LockToken] = value; - } - - /// - /// Date and time when the device-to-cloud message was received by the server. - /// - public DateTime EnqueuedTimeUtc - { - get => GetSystemProperty(MessageSystemPropertyNames.EnqueuedTime); - internal set => SystemProperties[MessageSystemPropertyNames.EnqueuedTime] = value; - } - - /// - /// Number of times the message has been previously delivered - /// - public uint DeliveryCount - { - get => GetSystemProperty(MessageSystemPropertyNames.DeliveryCount); - internal set => SystemProperties[MessageSystemPropertyNames.DeliveryCount] = (byte)value; - } - - /// - /// [Required in feedback messages] Used to specify the origin of messages generated by device hub. - /// Possible value: “{hub name}/” - /// - public string UserId - { - get => GetSystemProperty(MessageSystemPropertyNames.UserId); - set => SystemProperties[MessageSystemPropertyNames.UserId] = value; - } - - /// - /// For outgoing messages, contains the Mqtt topic that the message is being sent to - /// For incoming messages, contains the Mqtt topic that the message arrived on - /// - internal string MqttTopicName { get; set; } - - /// - /// Used to specify the schema of the message content. - /// - public string MessageSchema + : base(stream, streamDisposalResponsibility) { - get => GetSystemProperty(MessageSystemPropertyNames.MessageSchema); - set => SystemProperties[MessageSystemPropertyNames.MessageSchema] = value; } - /// - /// Custom date property set by the originator of the message. - /// - public DateTime CreationTimeUtc - { - get => GetSystemProperty(MessageSystemPropertyNames.CreationTimeUtc); - set => SystemProperties[MessageSystemPropertyNames.CreationTimeUtc] = value; - } - - /// - /// True if the message is set as a security message - /// - public bool IsSecurityMessage => CommonConstants.SecurityMessageInterfaceId.Equals(GetSystemProperty(MessageSystemPropertyNames.InterfaceId), StringComparison.Ordinal); - /// /// Used to specify the content type of the message. /// public string ContentType { - get => GetSystemProperty(MessageSystemPropertyNames.ContentType); - set => SystemProperties[MessageSystemPropertyNames.ContentType] = value; - } - - /// - /// Specifies the input name on which the message was sent, if there was one. - /// - public string InputName - { - get => GetSystemProperty(MessageSystemPropertyNames.InputName); - internal set => SystemProperties[MessageSystemPropertyNames.InputName] = value; - } - - /// - /// Specifies the device Id from which this message was sent, if there is one. - /// - public string ConnectionDeviceId - { - get => GetSystemProperty(MessageSystemPropertyNames.ConnectionDeviceId); - internal set => SystemProperties[MessageSystemPropertyNames.ConnectionDeviceId] = value; - } - - /// - /// Specifies the module Id from which this message was sent, if there is one. - /// - public string ConnectionModuleId - { - get => GetSystemProperty(MessageSystemPropertyNames.ConnectionModuleId); - internal set => SystemProperties[MessageSystemPropertyNames.ConnectionModuleId] = value; + get => PayloadContentType; + set => PayloadContentType = value; } /// @@ -229,202 +64,8 @@ public string ConnectionModuleId /// public string ContentEncoding { - get => GetSystemProperty(MessageSystemPropertyNames.ContentEncoding); - set => SystemProperties[MessageSystemPropertyNames.ContentEncoding] = value; - } - - /// - /// The DTDL component name from where the telemetry message has originated. - /// This is relevant only for plug and play certified devices. - /// - public string ComponentName - { - get => GetSystemProperty(MessageSystemPropertyNames.ComponentName); - set => SystemProperties[MessageSystemPropertyNames.ComponentName] = value; - } - - /// - /// Gets the dictionary of user properties which are set when user send the data. - /// - public IDictionary Properties { get; private set; } - - /// - /// Gets the dictionary of system properties which are managed internally. - /// - internal IDictionary SystemProperties { get; private set; } - - bool IReadOnlyIndicator.IsReadOnly => Interlocked.Read(ref _sizeInBytesCalled) == 1; - - - /// - /// The body stream of the current event data instance - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Naming", - "CA1721:Property names should not match get methods", - Justification = "Cannot remove public property on a public facing type")] - public Stream BodyStream => _bodyStream; - - /// - /// Gets or sets the deliveryTag which is used for server side check-pointing. - /// - internal ArraySegment DeliveryTag { get; set; } - - /// - /// Dispose the current event data instance - /// - public void Dispose() - { - Dispose(true); - } - - internal bool HasBodyStream() - { - return _bodyStream != null; - } - - /// - /// Return the body stream of the current event data instance - /// - /// - /// throws if the method has been called. - /// throws if the event data has already been disposed. - /// This method can only be called once and afterwards method will throw . - public Stream GetBodyStream() - { - ThrowIfDisposed(); - SetGetBodyCalled(); - return _bodyStream ?? Stream.Null; - } - - /// - /// This methods return the body stream as a byte array - /// - /// - /// throws if the event data has already been disposed. - public byte[] GetBytes() - { - ThrowIfDisposed(); - SetGetBodyCalled(); - if (_bodyStream == null) - { -#if NET451 - return new byte[] { }; -#else - return Array.Empty(); -#endif - } - - return ReadFullStream(_bodyStream); - } - - /// - /// Clones an existing instance and sets content body defined by on it. - /// - /// - /// The cloned message has the message as the original message. - /// User should treat the input byte array as immutable when sending the message. - /// - /// Message content to be set after clone. - /// A new instance of with body content defined by , - /// and user/system properties of the cloned instance. - /// - public Message CloneWithBody(in byte[] byteArray) - { - var result = new Message(byteArray); - - foreach (string key in Properties.Keys) - { - result.Properties.Add(key, Properties[key]); - } - - foreach (string key in SystemProperties.Keys) - { - result.SystemProperties.Add(key, SystemProperties[key]); - } - - return result; - } - - internal void ResetBody() - { - if (_originalStreamPosition == StreamCannotSeek) - { - throw new IOException("Stream cannot seek."); - } - - _bodyStream.Seek(_originalStreamPosition, SeekOrigin.Begin); - Interlocked.Exchange(ref _getBodyCalled, 0); - } - - internal bool IsBodyCalled => Volatile.Read(ref _getBodyCalled) == 1; - - private void SetGetBodyCalled() - { - if (1 == Interlocked.Exchange(ref _getBodyCalled, 1)) - { - throw Fx.Exception.AsError(new InvalidOperationException(ApiResources.MessageBodyConsumed)); - } - } - - /// - /// Sets the message as an security message - /// - public void SetAsSecurityMessage() - { - SystemProperties[MessageSystemPropertyNames.InterfaceId] = CommonConstants.SecurityMessageInterfaceId; - } - - private void InitializeWithStream(Stream stream, StreamDisposalResponsibility streamDisposalResponsibility) - { - // This method should only be used in constructor because - // this has no locking on the bodyStream. - _bodyStream = stream; - _streamDisposalResponsibility = streamDisposalResponsibility; - - if (_bodyStream.CanSeek) - { - _originalStreamPosition = _bodyStream.Position; - } - } - - private static byte[] ReadFullStream(Stream inputStream) - { - using var ms = new MemoryStream(); - inputStream.CopyTo(ms); - return ms.ToArray(); - } - - private T GetSystemProperty(string key) - { - return SystemProperties.ContainsKey(key) - ? (T)SystemProperties[key] - : default; - } - - internal void ThrowIfDisposed() - { - if (_disposed) - { - throw Fx.Exception.ObjectDisposed(ApiResources.MessageDisposed); - } - } - - private void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { - if (_bodyStream != null && _streamDisposalResponsibility == StreamDisposalResponsibility.Sdk) - { - _bodyStream.Dispose(); - _bodyStream = null; - } - } - } - - _disposed = true; + get => PayloadContentEncoding; + set => PayloadContentEncoding = value; } } } diff --git a/iothub/device/src/MessageBase.cs b/iothub/device/src/MessageBase.cs new file mode 100644 index 0000000000..11c491cc27 --- /dev/null +++ b/iothub/device/src/MessageBase.cs @@ -0,0 +1,447 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.IO; +using System.Threading; +using Microsoft.Azure.Devices.Client.Common.Api; +using System.Collections.Generic; +using Microsoft.Azure.Devices.Shared; + +namespace Microsoft.Azure.Devices.Client +{ + /// + /// The base Message class that is used for interacting with IotHub. + /// + public abstract class MessageBase : IReadOnlyIndicator, IDisposable + { + private volatile Stream _bodyStream; + private bool _disposed; + private StreamDisposalResponsibility _streamDisposalResponsibility; + + private const long StreamCannotSeek = -1; + private long _originalStreamPosition = StreamCannotSeek; + + private int _getBodyCalled; + private long _sizeInBytesCalled; + + /// + /// Default constructor with no body data + /// + public MessageBase() + { + Properties = new ReadOnlyDictionary45(new Dictionary(StringComparer.OrdinalIgnoreCase), this); + SystemProperties = new ReadOnlyDictionary45(new Dictionary(StringComparer.OrdinalIgnoreCase), this); + InitializeWithStream(Stream.Null, StreamDisposalResponsibility.Sdk); + } + + /// + /// Constructor which uses the argument stream as the body stream. + /// + /// User is expected to own the disposing of the stream when using this constructor. + /// A stream which will be used as body stream. + // UWP cannot expose a method with System.IO.Stream in signature. TODO: consider adding an IRandomAccessStream overload + public MessageBase(Stream stream) + : this() + { + if (stream != null) + { + InitializeWithStream(stream, StreamDisposalResponsibility.App); + } + } + + /// + /// Constructor which uses the input byte array as the body. + /// + /// User should treat the input byte array as immutable when sending the message. + /// A byte array which will be used to form the body stream. + public MessageBase(byte[] byteArray) + : this(new MemoryStream(byteArray)) + { + // Reset the owning of the stream + _streamDisposalResponsibility = StreamDisposalResponsibility.Sdk; + } + + /// + /// This constructor is only used on the Gateway HTTP path and AMQP SendEventAsync()so that we can clean up the stream. + /// + /// A stream which will be used as body stream. + /// Indicates if the stream passed in should be disposed by the client library, or by the calling application. + internal MessageBase(Stream stream, StreamDisposalResponsibility streamDisposalResponsibility) + : this(stream) + { + _streamDisposalResponsibility = streamDisposalResponsibility; + } + + /// + /// [Required for two way requests] Used to correlate two-way communication. + /// Format: A case-sensitive string ( up to 128 char long) of ASCII 7-bit alphanumeric chars + /// + {'-', ':', '/', '\', '.', '+', '%', '_', '#', '*', '?', '!', '(', ')', ',', '=', '@', ';', '$', '''}. + /// Non-alphanumeric characters are from URN RFC. + /// + public string MessageId + { + get => GetSystemProperty(MessageSystemPropertyNames.MessageId); + set => SystemProperties[MessageSystemPropertyNames.MessageId] = value; + } + + /// + /// [Required] Destination of the message + /// + public string To + { + get => GetSystemProperty(MessageSystemPropertyNames.To); + set => SystemProperties[MessageSystemPropertyNames.To] = value; + } + + /// + /// [Optional] The time when this message is considered expired + /// + public DateTime ExpiryTimeUtc + { + get => GetSystemProperty(MessageSystemPropertyNames.ExpiryTimeUtc); + internal set => SystemProperties[MessageSystemPropertyNames.ExpiryTimeUtc] = value; + } + + /// + /// Used in message responses and feedback + /// + public string CorrelationId + { + get => GetSystemProperty(MessageSystemPropertyNames.CorrelationId); + set => SystemProperties[MessageSystemPropertyNames.CorrelationId] = value; + } + + /// + /// [Required] SequenceNumber of the received message + /// + public ulong SequenceNumber + { + get => GetSystemProperty(MessageSystemPropertyNames.SequenceNumber); + internal set => SystemProperties[MessageSystemPropertyNames.SequenceNumber] = value; + } + + /// + /// [Required] LockToken of the received message + /// + public string LockToken + { + get => GetSystemProperty(MessageSystemPropertyNames.LockToken); + internal set => SystemProperties[MessageSystemPropertyNames.LockToken] = value; + } + + /// + /// Date and time when the device-to-cloud message was received by the server. + /// + public DateTime EnqueuedTimeUtc + { + get => GetSystemProperty(MessageSystemPropertyNames.EnqueuedTime); + internal set => SystemProperties[MessageSystemPropertyNames.EnqueuedTime] = value; + } + + /// + /// Number of times the message has been previously delivered + /// + public uint DeliveryCount + { + get => GetSystemProperty(MessageSystemPropertyNames.DeliveryCount); + internal set => SystemProperties[MessageSystemPropertyNames.DeliveryCount] = (byte)value; + } + + /// + /// [Required in feedback messages] Used to specify the origin of messages generated by device hub. + /// Possible value: “{hub name}/” + /// + public string UserId + { + get => GetSystemProperty(MessageSystemPropertyNames.UserId); + set => SystemProperties[MessageSystemPropertyNames.UserId] = value; + } + + /// + /// Used to specify the schema of the message content. + /// + public string MessageSchema + { + get => GetSystemProperty(MessageSystemPropertyNames.MessageSchema); + set => SystemProperties[MessageSystemPropertyNames.MessageSchema] = value; + } + + /// + /// Custom date property set by the originator of the message. + /// + public DateTime CreationTimeUtc + { + get => GetSystemProperty(MessageSystemPropertyNames.CreationTimeUtc); + set => SystemProperties[MessageSystemPropertyNames.CreationTimeUtc] = value; + } + + /// + /// True if the message is set as a security message + /// + public bool IsSecurityMessage => CommonConstants.SecurityMessageInterfaceId.Equals(GetSystemProperty(MessageSystemPropertyNames.InterfaceId), StringComparison.Ordinal); + + /// + /// Used to specify the content type of the message. + /// + internal string PayloadContentType + { + get => GetSystemProperty(MessageSystemPropertyNames.ContentType); + set => SystemProperties[MessageSystemPropertyNames.ContentType] = value; + } + + /// + /// Specifies the input name on which the message was sent, if there was one. + /// + public string InputName + { + get => GetSystemProperty(MessageSystemPropertyNames.InputName); + internal set => SystemProperties[MessageSystemPropertyNames.InputName] = value; + } + + /// + /// Specifies the device Id from which this message was sent, if there is one. + /// + public string ConnectionDeviceId + { + get => GetSystemProperty(MessageSystemPropertyNames.ConnectionDeviceId); + internal set => SystemProperties[MessageSystemPropertyNames.ConnectionDeviceId] = value; + } + + /// + /// Specifies the module Id from which this message was sent, if there is one. + /// + public string ConnectionModuleId + { + get => GetSystemProperty(MessageSystemPropertyNames.ConnectionModuleId); + internal set => SystemProperties[MessageSystemPropertyNames.ConnectionModuleId] = value; + } + + /// + /// Used to specify the content encoding type of the message. + /// + internal string PayloadContentEncoding + { + get => GetSystemProperty(MessageSystemPropertyNames.ContentEncoding); + set => SystemProperties[MessageSystemPropertyNames.ContentEncoding] = value; + } + + /// + /// The DTDL component name from where the telemetry message has originated. + /// This is relevant only for plug and play certified devices. + /// + internal string ComponentName + { + get => GetSystemProperty(MessageSystemPropertyNames.ComponentName); + set => SystemProperties[MessageSystemPropertyNames.ComponentName] = value; + } + + /// + /// Gets the dictionary of user properties which are set when user send the data. + /// + public IDictionary Properties { get; internal set; } + + /// + /// The body stream of the current event data instance + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Naming", + "CA1721:Property names should not match get methods", + Justification = "Cannot remove public property on a public facing type")] + public Stream BodyStream { get { return _bodyStream; } protected set { _bodyStream = value; } } + + /// + /// For outgoing messages, contains the Mqtt topic that the message is being sent to + /// For incoming messages, contains the Mqtt topic that the message arrived on + /// + internal string MqttTopicName { get; set; } + + /// + /// Gets the dictionary of system properties which are managed internally. + /// + internal IDictionary SystemProperties { get; set; } + + /// + /// Gets or sets the deliveryTag which is used for server side check-pointing. + /// + internal ArraySegment DeliveryTag { get; set; } + + /// + /// Sets the message as an security message + /// + public void SetAsSecurityMessage() + { + SystemProperties[MessageSystemPropertyNames.InterfaceId] = CommonConstants.SecurityMessageInterfaceId; + } + + /// + /// Return the body stream of the current event data instance + /// + /// + /// throws if the method has been called. + /// throws if the event data has already been disposed. + /// This method can only be called once and afterwards method will throw . + public virtual Stream GetBodyStream() + { + ThrowIfDisposed(); + SetGetBodyCalled(); + return _bodyStream ?? Stream.Null; + } + + /// + /// This methods return the body stream as a byte array + /// + /// + /// throws if the event data has already been disposed. + public byte[] GetBytes() + { + ThrowIfDisposed(); + SetGetBodyCalled(); + if (_bodyStream == null) + { +#if NET451 + return new byte[] { }; +#else + return Array.Empty(); +#endif + } + + return ReadFullStream(_bodyStream); + } + + /// + /// Clones an existing instance and sets content body defined by on it. + /// + /// + /// The cloned message has the message as the original message. + /// User should treat the input byte array as immutable when sending the message. + /// + /// Message content to be set after clone. + /// A new instance of with body content defined by , + /// and user/system properties of the cloned instance. + /// + public Message CloneWithBody(in byte[] byteArray) + { + var result = new Message(byteArray); + + foreach (string key in Properties.Keys) + { + result.Properties.Add(key, Properties[key]); + } + + foreach (string key in SystemProperties.Keys) + { + result.SystemProperties.Add(key, SystemProperties[key]); + } + + return result; + } + + bool IReadOnlyIndicator.IsReadOnly => Interlocked.Read(ref _sizeInBytesCalled) == 1; + + /// + /// Dispose the current event data instance + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose the Message object. + /// + /// + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + DisposeBodyStream(); + } + } + + _disposed = true; + } + + /// + /// Disposes the body stream. + /// + protected void DisposeBodyStream() + { + if (_bodyStream != null && _streamDisposalResponsibility == StreamDisposalResponsibility.Sdk) + { + _bodyStream.Dispose(); + _bodyStream = null; + } + } + + internal void ThrowIfDisposed() + { + if (_disposed) + { + throw Fx.Exception.ObjectDisposed(ApiResources.MessageDisposed); + } + } + + internal bool HasBodyStream() + { + return _bodyStream != null; + } + + internal void ResetBody() + { + if (_originalStreamPosition == StreamCannotSeek) + { + throw new IOException("Stream cannot seek."); + } + + _bodyStream.Seek(_originalStreamPosition, SeekOrigin.Begin); + Interlocked.Exchange(ref _getBodyCalled, 0); + } + + internal bool IsBodyCalled => Volatile.Read(ref _getBodyCalled) == 1; + + private void InitializeWithStream(Stream stream, StreamDisposalResponsibility streamDisposalResponsibility) + { + // This method should only be used in constructor because + // this has no locking on the bodyStream. + _bodyStream = stream; + _streamDisposalResponsibility = streamDisposalResponsibility; + + if (_bodyStream.CanSeek) + { + _originalStreamPosition = _bodyStream.Position; + } + } + + private void SetGetBodyCalled() + { + if (1 == Interlocked.Exchange(ref _getBodyCalled, 1)) + { + throw Fx.Exception.AsError(new InvalidOperationException(ApiResources.MessageBodyConsumed)); + } + } + + private static byte[] ReadFullStream(Stream inputStream) + { + using var ms = new MemoryStream(); + inputStream.CopyTo(ms); + return ms.ToArray(); + } + + /// + /// Retrieves the value for the specified key from the SystemProperties dictionary. + /// + /// The type to cast the retrieved value to. + /// The key whose value is to be retrieved. + /// The value for the specified key from the SystemProperties dictionary + protected T GetSystemProperty(string key) + { + return SystemProperties.ContainsKey(key) + ? (T)SystemProperties[key] + : default; + } + } +} diff --git a/iothub/device/src/ModuleClient.ConventionBasedOperations.cs b/iothub/device/src/ModuleClient.ConventionBasedOperations.cs new file mode 100644 index 0000000000..4c15885a2a --- /dev/null +++ b/iothub/device/src/ModuleClient.ConventionBasedOperations.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.Devices.Client +{ + /// + /// Contains methods that a convention based module can use to send telemetry to the service, + /// respond to commands and perform operations on its properties. + /// + /// + public partial class ModuleClient : IDisposable + { + /// + /// Send telemetry using the specified message. + /// + /// + /// Use the constructor to pass in the optional component name + /// that the telemetry message is from. + /// + /// The telemetry message. + /// A cancellation token to cancel the operation. + /// + public Task SendTelemetryAsync(TelemetryMessage telemetryMessage, CancellationToken cancellationToken = default) + => InternalClient.SendTelemetryAsync(telemetryMessage, cancellationToken); + } +} diff --git a/iothub/device/src/ModuleClient.cs b/iothub/device/src/ModuleClient.cs index 8970dd1f0d..04a63de0ec 100644 --- a/iothub/device/src/ModuleClient.cs +++ b/iothub/device/src/ModuleClient.cs @@ -21,7 +21,7 @@ namespace Microsoft.Azure.Devices.Client /// /// Contains methods that a module can use to send messages to and receive from the service and interact with module twins. /// - public class ModuleClient : IDisposable + public partial class ModuleClient : IDisposable { private const string ModuleMethodUriFormat = "/twins/{0}/modules/{1}/methods?" + ClientApiVersionHelper.ApiVersionQueryStringLatest; private const string DeviceMethodUriFormat = "/twins/{0}/methods?" + ClientApiVersionHelper.ApiVersionQueryStringLatest; diff --git a/iothub/device/src/TelemetryCollection.cs b/iothub/device/src/TelemetryCollection.cs new file mode 100644 index 0000000000..677a6d16e0 --- /dev/null +++ b/iothub/device/src/TelemetryCollection.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using Microsoft.Azure.Devices.Shared; + +namespace Microsoft.Azure.Devices.Client +{ + /// + /// The telmetry collection used to populate a . + /// + public class TelemetryCollection : PayloadCollection + { + /// + /// Adds the telemetry to the telemetry collection. + /// + /// + /// + /// + /// An element with the same key already exists in the collection. + public override void Add(string telemetryName, object telemetryValue) + { + base.Add(telemetryName, telemetryValue); + } + + /// + /// Adds or updates the telemetry collection. + /// + /// The name of the telemetry. + /// The value of the telemetry. + /// is null + public override void AddOrUpdate(string telemetryName, object telemetryValue) + { + base.AddOrUpdate(telemetryName, telemetryValue); + } + } +} diff --git a/iothub/device/src/TelemetryMessage.cs b/iothub/device/src/TelemetryMessage.cs new file mode 100644 index 0000000000..78a5c8c552 --- /dev/null +++ b/iothub/device/src/TelemetryMessage.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.IO; +using Microsoft.Azure.Devices.Shared; + +namespace Microsoft.Azure.Devices.Client +{ + /// + /// A class used to send telemetry to IoT Hub. + /// + /// + /// This class is derived from and is designed to accept a + /// to utilize the to adhere to a well defined convention. + /// + public sealed class TelemetryMessage : MessageBase + { + /// + /// Gets or sets the for this + /// + /// A telemetry collection that will be set as the message payload. + public TelemetryCollection Telemetry { get; set; } = new TelemetryCollection(); + + /// + /// A convenience constructor that allows you to set the of this + /// + /// The name of the component. + public TelemetryMessage(string componentName = default) + : base() + { + if (!string.IsNullOrEmpty(componentName)) + { + ComponentName = componentName; + } + } + + /// + public override Stream GetBodyStream() + { + DisposeBodyStream(); + BodyStream = new MemoryStream(Telemetry.GetPayloadObjectBytes()); + return base.GetBodyStream(); + } + } +} diff --git a/iothub/device/src/Transport/Amqp/AmqpTransportHandler.cs b/iothub/device/src/Transport/Amqp/AmqpTransportHandler.cs index c1fc594c66..e4ec03001c 100644 --- a/iothub/device/src/Transport/Amqp/AmqpTransportHandler.cs +++ b/iothub/device/src/Transport/Amqp/AmqpTransportHandler.cs @@ -152,7 +152,7 @@ public override async Task CloseAsync(CancellationToken cancellationToken) #region Telemetry - public override async Task SendEventAsync(Message message, CancellationToken cancellationToken) + public override async Task SendEventAsync(MessageBase message, CancellationToken cancellationToken) { Logging.Enter(this, message, cancellationToken, nameof(SendEventAsync)); @@ -168,7 +168,7 @@ public override async Task SendEventAsync(Message message, CancellationToken can } } - public override async Task SendEventAsync(IEnumerable messages, CancellationToken cancellationToken) + public override async Task SendEventAsync(IEnumerable messages, CancellationToken cancellationToken) { Logging.Enter(this, messages, cancellationToken, nameof(SendEventAsync)); diff --git a/iothub/device/src/Transport/Amqp/AmqpUnit.cs b/iothub/device/src/Transport/Amqp/AmqpUnit.cs index 359424eb1a..f2f62d9fbb 100644 --- a/iothub/device/src/Transport/Amqp/AmqpUnit.cs +++ b/iothub/device/src/Transport/Amqp/AmqpUnit.cs @@ -246,7 +246,7 @@ private async Task EnsureMessageReceivingLinkIsOpenAsync(TimeSpan timeout, bool } } - public async Task SendMessagesAsync(IEnumerable messages, TimeSpan timeout) + public async Task SendMessagesAsync(IEnumerable messages, TimeSpan timeout) { Logging.Enter(this, messages, timeout, nameof(SendMessagesAsync)); @@ -262,7 +262,7 @@ public async Task SendMessagesAsync(IEnumerable message } } - public async Task SendMessageAsync(Message message, TimeSpan timeout) + public async Task SendMessageAsync(MessageBase message, TimeSpan timeout) { Logging.Enter(this, message, timeout, nameof(SendMessageAsync)); @@ -463,7 +463,7 @@ public async Task EnableEventReceiveAsync(TimeSpan timeout) } } - public async Task SendEventsAsync(IEnumerable messages, TimeSpan timeout) + public async Task SendEventsAsync(IEnumerable messages, TimeSpan timeout) { Logging.Enter(this, messages, timeout, nameof(SendEventsAsync)); @@ -477,7 +477,7 @@ public async Task SendEventsAsync(IEnumerable messages, } } - public async Task SendEventAsync(Message message, TimeSpan timeout) + public async Task SendEventAsync(MessageBase message, TimeSpan timeout) { Logging.Enter(this, message, timeout, nameof(SendEventAsync)); diff --git a/iothub/device/src/Transport/AmqpIot/AmqpIotMessageConverter.cs b/iothub/device/src/Transport/AmqpIot/AmqpIotMessageConverter.cs index e2a57e229f..5b3c72f3b9 100644 --- a/iothub/device/src/Transport/AmqpIot/AmqpIotMessageConverter.cs +++ b/iothub/device/src/Transport/AmqpIot/AmqpIotMessageConverter.cs @@ -46,11 +46,11 @@ public static Message AmqpMessageToMessage(AmqpMessage amqpMessage) return message; } - public static AmqpMessage MessageToAmqpMessage(Message message) + public static AmqpMessage MessageToAmqpMessage(MessageBase message) { if (message == null) { - throw Fx.Exception.ArgumentNull(nameof(Message)); + throw Fx.Exception.ArgumentNull(nameof(MessageBase)); } message.ThrowIfDisposed(); @@ -181,7 +181,7 @@ public static void UpdateMessageHeaderAndProperties(AmqpMessage amqpMessage, Mes /// /// Copies the Message instance's properties to the AmqpMessage instance. /// - public static void UpdateAmqpMessageHeadersAndProperties(AmqpMessage amqpMessage, Message data, bool copyUserProperties = true) + public static void UpdateAmqpMessageHeadersAndProperties(AmqpMessage amqpMessage, MessageBase data, bool copyUserProperties = true) { amqpMessage.Properties.MessageId = data.MessageId; diff --git a/iothub/device/src/Transport/AmqpIot/AmqpIotSendingLink.cs b/iothub/device/src/Transport/AmqpIot/AmqpIotSendingLink.cs index da3aadfe4f..ae932f68d5 100644 --- a/iothub/device/src/Transport/AmqpIot/AmqpIotSendingLink.cs +++ b/iothub/device/src/Transport/AmqpIot/AmqpIotSendingLink.cs @@ -73,7 +73,7 @@ internal bool IsClosing() #region Telemetry handling - internal async Task SendMessageAsync(Message message, TimeSpan timeout) + internal async Task SendMessageAsync(MessageBase message, TimeSpan timeout) { if (Logging.IsEnabled) { @@ -93,7 +93,7 @@ internal async Task SendMessageAsync(Message message, TimeSpan t return new AmqpIotOutcome(outcome); } - internal async Task SendMessagesAsync(IEnumerable messages, TimeSpan timeout) + internal async Task SendMessagesAsync(IEnumerable messages, TimeSpan timeout) { if (Logging.IsEnabled) { diff --git a/iothub/device/src/Transport/DefaultDelegatingHandler.cs b/iothub/device/src/Transport/DefaultDelegatingHandler.cs index 6db75b1e18..d239eaf125 100644 --- a/iothub/device/src/Transport/DefaultDelegatingHandler.cs +++ b/iothub/device/src/Transport/DefaultDelegatingHandler.cs @@ -136,13 +136,13 @@ public virtual Task RejectAsync(string lockToken, CancellationToken cancellation return InnerHandler?.RejectAsync(lockToken, cancellationToken) ?? TaskHelpers.CompletedTask; } - public virtual Task SendEventAsync(Message message, CancellationToken cancellationToken) + public virtual Task SendEventAsync(MessageBase message, CancellationToken cancellationToken) { ThrowIfDisposed(); return InnerHandler?.SendEventAsync(message, cancellationToken) ?? TaskHelpers.CompletedTask; } - public virtual Task SendEventAsync(IEnumerable messages, CancellationToken cancellationToken) + public virtual Task SendEventAsync(IEnumerable messages, CancellationToken cancellationToken) { ThrowIfDisposed(); return InnerHandler?.SendEventAsync(messages, cancellationToken) ?? TaskHelpers.CompletedTask; diff --git a/iothub/device/src/Transport/ErrorDelegatingHandler.cs b/iothub/device/src/Transport/ErrorDelegatingHandler.cs index 4d4b169a8e..ee6c9e2d5b 100644 --- a/iothub/device/src/Transport/ErrorDelegatingHandler.cs +++ b/iothub/device/src/Transport/ErrorDelegatingHandler.cs @@ -130,12 +130,12 @@ public override Task RejectAsync(string lockToken, CancellationToken cancellatio return ExecuteWithErrorHandlingAsync(() => base.RejectAsync(lockToken, cancellationToken)); } - public override Task SendEventAsync(IEnumerable messages, CancellationToken cancellationToken) + public override Task SendEventAsync(IEnumerable messages, CancellationToken cancellationToken) { return ExecuteWithErrorHandlingAsync(() => base.SendEventAsync(messages, cancellationToken)); } - public override Task SendEventAsync(Message message, CancellationToken cancellationToken) + public override Task SendEventAsync(MessageBase message, CancellationToken cancellationToken) { return ExecuteWithErrorHandlingAsync(() => base.SendEventAsync(message, cancellationToken)); } diff --git a/iothub/device/src/Transport/HttpTransportHandler.cs b/iothub/device/src/Transport/HttpTransportHandler.cs index b57150cdc9..7917452398 100644 --- a/iothub/device/src/Transport/HttpTransportHandler.cs +++ b/iothub/device/src/Transport/HttpTransportHandler.cs @@ -93,7 +93,7 @@ public override Task CloseAsync(CancellationToken cancellationToken) return TaskHelpers.CompletedTask; } - public override Task SendEventAsync(Message message, CancellationToken cancellationToken) + public override Task SendEventAsync(MessageBase message, CancellationToken cancellationToken) { Debug.Assert(message != null); cancellationToken.ThrowIfCancellationRequested(); @@ -120,7 +120,7 @@ public override Task SendEventAsync(Message message, CancellationToken cancellat cancellationToken); } - public override Task SendEventAsync(IEnumerable messages, CancellationToken cancellationToken) + public override Task SendEventAsync(IEnumerable messages, CancellationToken cancellationToken) { if (messages == null) { @@ -494,7 +494,7 @@ private static Uri GetRequestUri(string deviceId, string path, IDictionary messages) + private static string ToJson(IEnumerable messages) { using var sw = new StringWriter(); using var writer = new JsonTextWriter(sw); @@ -502,7 +502,7 @@ private static string ToJson(IEnumerable messages) // [ writer.WriteStartArray(); - foreach (Message message in messages) + foreach (MessageBase message in messages) { // { writer.WriteStartObject(); diff --git a/iothub/device/src/Transport/Mqtt/MqttTransportHandler.cs b/iothub/device/src/Transport/Mqtt/MqttTransportHandler.cs index caaf8959c4..43ae6551e0 100644 --- a/iothub/device/src/Transport/Mqtt/MqttTransportHandler.cs +++ b/iothub/device/src/Transport/Mqtt/MqttTransportHandler.cs @@ -230,7 +230,7 @@ public override async Task OpenAsync(CancellationToken cancellationToken) } } - public override async Task SendEventAsync(Message message, CancellationToken cancellationToken) + public override async Task SendEventAsync(MessageBase message, CancellationToken cancellationToken) { try { @@ -251,7 +251,7 @@ public override async Task SendEventAsync(Message message, CancellationToken can } } - public override async Task SendEventAsync(IEnumerable messages, CancellationToken cancellationToken) + public override async Task SendEventAsync(IEnumerable messages, CancellationToken cancellationToken) { foreach (Message message in messages) { diff --git a/iothub/device/src/Transport/RetryDelegatingHandler.cs b/iothub/device/src/Transport/RetryDelegatingHandler.cs index 716770fe7e..c3d0c2285b 100644 --- a/iothub/device/src/Transport/RetryDelegatingHandler.cs +++ b/iothub/device/src/Transport/RetryDelegatingHandler.cs @@ -67,7 +67,7 @@ public void SetRetryPolicy(IRetryPolicy retryPolicy) Logging.Associate(this, _internalRetryPolicy, nameof(SetRetryPolicy)); } - public override async Task SendEventAsync(Message message, CancellationToken cancellationToken) + public override async Task SendEventAsync(MessageBase message, CancellationToken cancellationToken) { try { @@ -95,7 +95,7 @@ await _internalRetryPolicy } } - public override async Task SendEventAsync(IEnumerable messages, CancellationToken cancellationToken) + public override async Task SendEventAsync(IEnumerable messages, CancellationToken cancellationToken) { try { diff --git a/iothub/device/tests/Mqtt/MqttIotHubAdapterTest.cs b/iothub/device/tests/Mqtt/MqttIotHubAdapterTest.cs index 446420e58b..eb64d3c90c 100644 --- a/iothub/device/tests/Mqtt/MqttIotHubAdapterTest.cs +++ b/iothub/device/tests/Mqtt/MqttIotHubAdapterTest.cs @@ -36,8 +36,9 @@ public void TestPopulateMessagePropertiesFromPacket_NormalMessage() Assert.AreEqual("Value3", message.Properties["Prop3"]); Assert.AreEqual(3, message.SystemProperties.Count); - Assert.AreEqual("Corrid1", message.SystemProperties["correlation-id"]); - Assert.AreEqual("MessageId1", message.SystemProperties["message-id"]); + Assert.AreEqual("Corrid1", message.SystemProperties[MessageSystemPropertyNames.CorrelationId]); + Assert.AreEqual("MessageId1", message.SystemProperties[MessageSystemPropertyNames.MessageId]); + Assert.AreEqual(null, message.SystemProperties[MessageSystemPropertyNames.LockToken]); } [TestMethod] @@ -57,8 +58,9 @@ public void TestPopulateMessagePropertiesFromPacket_ModuleEndpointMessage() Assert.AreEqual("Value3", message.Properties["Prop3"]); Assert.AreEqual(3, message.SystemProperties.Count); - Assert.AreEqual("Corrid1", message.SystemProperties["correlation-id"]); - Assert.AreEqual("MessageId1", message.SystemProperties["message-id"]); + Assert.AreEqual("Corrid1", message.SystemProperties[MessageSystemPropertyNames.CorrelationId]); + Assert.AreEqual("MessageId1", message.SystemProperties[MessageSystemPropertyNames.MessageId]); + Assert.AreEqual(null, message.SystemProperties[MessageSystemPropertyNames.LockToken]); } [TestMethod] From 5285c79da6fd632331dff83ddb1d0e9466665306 Mon Sep 17 00:00:00 2001 From: Abhipsa Misra Date: Thu, 20 May 2021 16:16:35 -0700 Subject: [PATCH 03/14] feat(iot-device): Add support for convention-based command operations --- iothub/device/src/CommandRequest.cs | 67 +++++++++++++++++++ iothub/device/src/CommandResponse.cs | 50 ++++++++++++++ iothub/device/src/Common/Utils.cs | 9 ++- .../DeviceClient.ConventionBasedOperations.cs | 11 +++ ...nternalClient.ConventionBasedOperations.cs | 30 +++++++++ .../ModuleClient.ConventionBasedOperations.cs | 11 +++ shared/src/ConventionBasedConstants.cs | 5 ++ 7 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 iothub/device/src/CommandRequest.cs create mode 100644 iothub/device/src/CommandResponse.cs diff --git a/iothub/device/src/CommandRequest.cs b/iothub/device/src/CommandRequest.cs new file mode 100644 index 0000000000..9b434f34a6 --- /dev/null +++ b/iothub/device/src/CommandRequest.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; +using Microsoft.Azure.Devices.Shared; + +namespace Microsoft.Azure.Devices.Client +{ + /// + /// The data structure that represents a convention based command request. + /// + public sealed class CommandRequest + { + private readonly byte[] _data; + private readonly PayloadConvention _payloadConvention; + + internal CommandRequest(PayloadConvention payloadConvention, string commandName, string componentName = default, byte[] data = default) + { + CommandName = commandName; + ComponentName = componentName; + _data = data; + _payloadConvention = payloadConvention; + } + + /// + /// The name of the component that is command is invoked on. + /// + public string ComponentName { get; private set; } + + /// + /// The command name. + /// + public string CommandName { get; private set; } + + /// + /// The command request data. + /// + /// The type to cast the command request data to. + /// The command request data. + public T GetData() + { + string dataAsJson = DataAsJson; + + return dataAsJson == null + ? default + : _payloadConvention.PayloadSerializer.DeserializeToType(dataAsJson); + } + + /// + /// The command request data bytes. + /// + /// + /// The command request data bytes. + /// + public byte[] GetDataAsBytes() + { + // Need to return a clone of the array so that consumers + // of this library cannot change its contents. + return (byte[])_data.Clone(); + } + + /// + /// The command data in Json format. + /// + public string DataAsJson => (_data == null || _data.Length == 0) ? null : Encoding.UTF8.GetString(_data); + } +} diff --git a/iothub/device/src/CommandResponse.cs b/iothub/device/src/CommandResponse.cs new file mode 100644 index 0000000000..465cecc78e --- /dev/null +++ b/iothub/device/src/CommandResponse.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; +using Microsoft.Azure.Devices.Shared; + +namespace Microsoft.Azure.Devices.Client +{ + /// + /// The command response that the client responds with. + /// + public sealed class CommandResponse + { + private readonly object _result; + + internal PayloadConvention PayloadConvention { get; set; } + + /// + /// Creates a new instance of the class with the associated command response data and a status code. + /// + /// The command response data. + /// A status code indicating success or failure. + public CommandResponse(object result, int status) + { + _result = result; + Status = status; + } + + /// + /// Creates a new instance of the class with the associated status code. + /// + /// A status code indicating success or failure. + public CommandResponse(int status) + { + Status = status; + } + + /// + /// The command response status code indicating success or failure. + /// + public int Status { get; private set; } + + /// + /// The serialized command response data. + /// + public string ResultAsJson => _result == null ? null : PayloadConvention.PayloadSerializer.SerializeToString(_result); + + internal byte[] ResultAsBytes => _result == null ? null : Encoding.UTF8.GetBytes(ResultAsJson); + } +} diff --git a/iothub/device/src/Common/Utils.cs b/iothub/device/src/Common/Utils.cs index a858b3ed2a..723bdfc762 100644 --- a/iothub/device/src/Common/Utils.cs +++ b/iothub/device/src/Common/Utils.cs @@ -99,11 +99,14 @@ public static void ValidateDataIsEmptyOrJson(byte[] data) if (data != null && data.Length != 0) { - var stream = new MemoryStream(data); - var streamReader = new StreamReader(stream, Encoding.UTF8, false, Math.Min(1024, data.Length)); + using var stream = new MemoryStream(data); + using var streamReader = new StreamReader(stream, Encoding.UTF8, false, Math.Min(1024, data.Length)); using var reader = new JsonTextReader(streamReader); - while (reader.Read()) { } + + while (reader.Read()) + { + } } } diff --git a/iothub/device/src/DeviceClient.ConventionBasedOperations.cs b/iothub/device/src/DeviceClient.ConventionBasedOperations.cs index 70db59d4fd..7afb17c35d 100644 --- a/iothub/device/src/DeviceClient.ConventionBasedOperations.cs +++ b/iothub/device/src/DeviceClient.ConventionBasedOperations.cs @@ -26,5 +26,16 @@ public partial class DeviceClient : IDisposable /// public Task SendTelemetryAsync(TelemetryMessage telemetryMessage, CancellationToken cancellationToken = default) => InternalClient.SendTelemetryAsync(telemetryMessage, cancellationToken); + + /// + /// Set the global command callback handler. + /// + /// A method implementation that will handle the incoming command. + /// Generic parameter to be interpreted by the client code. + /// A cancellation token to cancel the operation. + public Task SubscribeToCommandsAsync( + Func> callback, object userContext, + CancellationToken cancellationToken = default) + => InternalClient.SubscribeToCommandsAsync(callback, userContext, cancellationToken); } } diff --git a/iothub/device/src/InternalClient.ConventionBasedOperations.cs b/iothub/device/src/InternalClient.ConventionBasedOperations.cs index 4cd5607bd1..e92f0d7beb 100644 --- a/iothub/device/src/InternalClient.ConventionBasedOperations.cs +++ b/iothub/device/src/InternalClient.ConventionBasedOperations.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Azure.Devices.Shared; @@ -32,5 +33,34 @@ internal Task SendTelemetryAsync(TelemetryMessage telemetryMessage, Cancellation return SendEventAsync(telemetryMessage, cancellationToken); } + + internal Task SubscribeToCommandsAsync(Func> callback, object userContext, CancellationToken cancellationToken) + { + // Subscribe to methods default handler internally and use the callback received internally to invoke the user supplied command callback. + var methodDefaultCallback = new MethodCallback(async (methodRequest, userContext) => + { + CommandRequest commandRequest; + if (methodRequest.Name != null + && methodRequest.Name.Contains(ConventionBasedConstants.ComponentLevelCommandSeparator)) + { + string[] split = methodRequest.Name.Split(ConventionBasedConstants.ComponentLevelCommandSeparator); + string componentName = split[0]; + string commandName = split[1]; + commandRequest = new CommandRequest(PayloadConvention, commandName, componentName, methodRequest.Data); + } + else + { + commandRequest = new CommandRequest(payloadConvention: PayloadConvention, commandName: methodRequest.Name, data: methodRequest.Data); + } + + CommandResponse commandResponse = await callback.Invoke(commandRequest, userContext).ConfigureAwait(false); + commandResponse.PayloadConvention = PayloadConvention; + return commandResponse.ResultAsBytes != null + ? new MethodResponse(commandResponse.ResultAsBytes, commandResponse.Status) + : new MethodResponse(commandResponse.Status); + }); + + return SetMethodDefaultHandlerAsync(methodDefaultCallback, userContext, cancellationToken); + } } } diff --git a/iothub/device/src/ModuleClient.ConventionBasedOperations.cs b/iothub/device/src/ModuleClient.ConventionBasedOperations.cs index 4c15885a2a..d8e19cf128 100644 --- a/iothub/device/src/ModuleClient.ConventionBasedOperations.cs +++ b/iothub/device/src/ModuleClient.ConventionBasedOperations.cs @@ -26,5 +26,16 @@ public partial class ModuleClient : IDisposable /// public Task SendTelemetryAsync(TelemetryMessage telemetryMessage, CancellationToken cancellationToken = default) => InternalClient.SendTelemetryAsync(telemetryMessage, cancellationToken); + + /// + /// Set the global command callback handler. + /// + /// A method implementation that will handle the incoming command. + /// Generic parameter to be interpreted by the client code. + /// A cancellation token to cancel the operation. + public Task SubscribeToCommandsAsync( + Func> callback, object userContext, + CancellationToken cancellationToken = default) + => InternalClient.SubscribeToCommandsAsync(callback, userContext, cancellationToken); } } diff --git a/shared/src/ConventionBasedConstants.cs b/shared/src/ConventionBasedConstants.cs index 1d4649fc5e..2313acf92e 100644 --- a/shared/src/ConventionBasedConstants.cs +++ b/shared/src/ConventionBasedConstants.cs @@ -8,6 +8,11 @@ namespace Microsoft.Azure.Devices.Shared /// public static class ConventionBasedConstants { + /// + /// Separator for a component-level command name. + /// + public const char ComponentLevelCommandSeparator = '*'; + /// /// Marker key to indicate a component-level property. /// From 88c2155d641da327981ffe1b715eb70739cec2c6 Mon Sep 17 00:00:00 2001 From: Abhipsa Misra Date: Fri, 21 May 2021 16:34:26 -0700 Subject: [PATCH 04/14] * feat(iot-device): Add support for convention-based properties operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(iot-device): Add support for convention-based properties operations Co-authored-by: James Davis ⛺️🏔 --- iothub/device/src/ClientProperties.cs | 45 +++ .../src/ClientPropertiesUpdateResponse.cs | 25 ++ iothub/device/src/ClientPropertyCollection.cs | 379 ++++++++++++++++++ iothub/device/src/ClientTwinProperties.cs | 32 ++ .../Common/UrlEncodedDictionarySerializer.cs | 11 +- .../DeviceClient.ConventionBasedOperations.cs | 28 +- iothub/device/src/IDelegatingHandler.cs | 6 + ...nternalClient.ConventionBasedOperations.cs | 48 +++ iothub/device/src/MessageBase.cs | 38 +- .../ModuleClient.ConventionBasedOperations.cs | 28 +- .../Transport/Amqp/AmqpTransportHandler.cs | 20 + .../src/Transport/DefaultDelegatingHandler.cs | 12 + .../src/Transport/ErrorDelegatingHandler.cs | 10 + .../src/Transport/HttpTransportHandler.cs | 10 + .../src/Transport/Mqtt/MqttIotHubAdapter.cs | 8 +- .../Transport/Mqtt/MqttTransportHandler.cs | 63 ++- .../src/Transport/RetryDelegatingHandler.cs | 44 ++ .../tests/Mqtt/MqttTransportHandlerTests.cs | 37 +- shared/src/NewtonsoftJsonPayloadSerializer.cs | 2 +- shared/src/StatusCodes.cs | 33 ++ 20 files changed, 841 insertions(+), 38 deletions(-) create mode 100644 iothub/device/src/ClientProperties.cs create mode 100644 iothub/device/src/ClientPropertiesUpdateResponse.cs create mode 100644 iothub/device/src/ClientPropertyCollection.cs create mode 100644 iothub/device/src/ClientTwinProperties.cs create mode 100644 shared/src/StatusCodes.cs diff --git a/iothub/device/src/ClientProperties.cs b/iothub/device/src/ClientProperties.cs new file mode 100644 index 0000000000..08520d5792 --- /dev/null +++ b/iothub/device/src/ClientProperties.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Azure.Devices.Client +{ + /// + /// A container for properties retrieved from the service. + /// + /// + /// The class is not meant to be constructed by customer code. + /// It is intended to be returned fully populated from the client method . + /// + public class ClientProperties : ClientPropertyCollection + { + /// + /// Initializes a new instance of . + /// This is provided for unit testing purposes only. + /// + /// + public ClientProperties() + { + Writable = new ClientPropertyCollection(); + } + + /// + /// Initializes a new instance of with the specified collections. + /// + /// A collection of writable properties returned from IoT Hub. + /// A collection of read-only properties returned from IoT Hub. + internal ClientProperties(ClientPropertyCollection requestedPropertyCollection, ClientPropertyCollection readOnlyPropertyCollection) + { + SetCollection(readOnlyPropertyCollection); + Version = readOnlyPropertyCollection.Version; + Writable = requestedPropertyCollection; + } + + /// + /// The collection of writable properties. + /// + /// + /// See the Writable properties documentation for more information. + /// + public ClientPropertyCollection Writable { get; private set; } + } +} diff --git a/iothub/device/src/ClientPropertiesUpdateResponse.cs b/iothub/device/src/ClientPropertiesUpdateResponse.cs new file mode 100644 index 0000000000..a23deb32f3 --- /dev/null +++ b/iothub/device/src/ClientPropertiesUpdateResponse.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Azure.Devices.Client +{ + /// + /// The response of an operation. + /// + public class ClientPropertiesUpdateResponse + { + /// + /// The request Id that is associated with the operation. + /// + /// + /// This request Id is relevant only for operations over MQTT, and can be used for tracking the operation on service side logs. + /// Note that you would need to contact the support team to track operations on the service side. + /// + public string RequestId { get; internal set; } + + /// + /// The updated version after the property patch has been applied. + /// + public long Version { get; internal set; } + } +} diff --git a/iothub/device/src/ClientPropertyCollection.cs b/iothub/device/src/ClientPropertyCollection.cs new file mode 100644 index 0000000000..df9e17dbad --- /dev/null +++ b/iothub/device/src/ClientPropertyCollection.cs @@ -0,0 +1,379 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using Microsoft.Azure.Devices.Shared; + +namespace Microsoft.Azure.Devices.Client +{ + /// + /// A collection of properties for the client. + /// + public class ClientPropertyCollection : PayloadCollection + { + private const string VersionName = "$version"; + + /// + /// + /// Adds the value to the collection. + /// + /// + /// If the collection has a key that matches the property name this method will throw an . + /// + /// When using this as part of the writable property flow to respond to a writable property update you should pass in the value + /// as an instance of + /// to ensure the correct formatting is applied when the object is serialized. + /// + /// + /// is null + /// The name of the property to add. + /// The value of the property to add. + public override void Add(string propertyName, object propertyValue) + => Add(null, propertyName, propertyValue); + + /// + /// + /// + /// + /// Adds the value to the collection. + /// + /// is null + /// The component with the property to add. + /// The name of the property to add. + /// The value of the property to add. + public void Add(string componentName, string propertyName, object propertyValue) + => AddInternal(new Dictionary { { propertyName, propertyValue } }, componentName, false); + + /// + /// + /// + /// + /// Adds the values to the collection. + /// + /// A collection of properties to add or update. + public void Add(IDictionary properties) + => AddInternal(properties, null, false); + + /// + /// + /// + /// + /// Adds the values to the collection. + /// + /// A collection of properties to add or update. + /// The component with the properties to add or update. + public void Add(string componentName, IDictionary properties) + => AddInternal(properties, componentName, false); + + /// + /// + /// + /// Adds a writable property to the collection. + /// + /// + /// This method will use the method to create an instance of that will be properly serialized. + /// + /// is null + /// The name of the property to add or update. + /// The value of the property to add or update. + /// + /// + /// + public void Add(string propertyName, object propertyValue, int statusCode, long version, string description = default) + => Add(null, propertyName, propertyValue, statusCode, version, description); + + /// + /// + /// + /// Adds a writable property to the collection. + /// + /// + /// This method will use the method to create an instance of that will be properly serialized. + /// + /// is null + /// The name of the property to add or update. + /// The value of the property to add or update. + /// + /// + /// + /// + public void Add(string componentName, string propertyName, object propertyValue, int statusCode, long version, string description = default) + { + if (Convention?.PayloadSerializer == null) + { + Add(componentName, propertyName, new { value = propertyValue, ac = statusCode, av = version, ad = description }); + } + else + { + Add(componentName, propertyName, Convention.PayloadSerializer.CreateWritablePropertyResponse(propertyValue, statusCode, version, description)); + } + } + + /// + /// + /// + /// + /// is null + /// The name of the property to add or update. + /// The value of the property to add or update. + public override void AddOrUpdate(string propertyName, object propertyValue) + => AddOrUpdate(null, propertyName, propertyValue); + + /// + /// + /// + /// + /// is null + /// The component with the property to add or update. + /// The name of the property to add or update. + /// The value of the property to add or update. + public void AddOrUpdate(string componentName, string propertyName, object propertyValue) + => AddInternal(new Dictionary { { propertyName, propertyValue } }, componentName, true); + + /// + /// + /// + /// + /// If the collection has a key that matches this will overwrite the current value. Otherwise it will attempt to add this to the collection. + /// + /// When using this as part of the writable property flow to respond to a writable property update + /// you should pass in the value as an instance of + /// to ensure the correct formatting is applied when the object is serialized. + /// + /// + /// A collection of properties to add or update. + public void AddOrUpdate(IDictionary properties) + => AddInternal(properties, null, true); + + /// + /// + /// + /// + /// If the collection has a key that matches this will overwrite the current value. Otherwise it will attempt to add this to the collection. + /// + /// When using this as part of the writable property flow to respond to a writable property update + /// you should pass in the value as an instance of + /// to ensure the correct formatting is applied when the object is serialized. + /// + /// + /// The component with the properties to add or update. + /// A collection of properties to add or update. + public void AddOrUpdate(string componentName, IDictionary properties) + => AddInternal(properties, componentName, true); + + /// + /// + /// + /// Adds or updates a type of to the collection. + /// + /// is null + /// The name of the writable property to add or update. + /// The value of the writable property to add or update. + /// + /// + /// + public void AddOrUpdate(string propertyName, object propertyValue, int statusCode, long version, string description = default) + => AddOrUpdate(null, propertyName, propertyValue, statusCode, version, description); + + /// + /// + /// + /// + /// Adds or updates a type of to the collection. + /// + /// is null + /// The name of the writable property to add or update. + /// The value of the writable property to add or update. + /// + /// + /// + /// + public void AddOrUpdate(string componentName, string propertyName, object propertyValue, int statusCode, long version, string description = default) + { + if (Convention?.PayloadSerializer == null) + { + AddOrUpdate(componentName, propertyName, new { value = propertyValue, ac = statusCode, av = version, ad = description }); + } + else + { + AddOrUpdate(componentName, propertyName, Convention.PayloadSerializer.CreateWritablePropertyResponse(propertyValue, statusCode, version, description)); + } + } + + /// + /// Adds or updates the value for the collection. + /// + /// + /// + /// + /// A collection of properties to add or update. + /// The component with the properties to add or update. + /// Forces the collection to use the Add or Update behavior. + /// Setting to true will simply overwrite the value. Setting to false will use + private void AddInternal(IDictionary properties, string componentName = default, bool forceUpdate = false) + { + if (properties == null) + { + throw new ArgumentNullException(nameof(properties)); + } + + // If the componentName is null then simply add the key-value pair to Collection dictionary. + // this will either insert a property or overwrite it if it already exists. + if (componentName == null) + { + foreach (KeyValuePair entry in properties) + { + if (forceUpdate) + { + Collection[entry.Key] = entry.Value; + } + else + { + Collection.Add(entry.Key, entry.Value); + } + } + } + else + { + // If the component name already exists within the dictionary, then the value is a dictionary containing the component level property key and values. + // Append this property dictionary to the existing property value dictionary (overwrite entries if they already exist). + // Otherwise, add this as a new entry. + var componentProperties = new Dictionary(); + if (Collection.ContainsKey(componentName)) + { + componentProperties = (Dictionary)Collection[componentName]; + } + foreach (KeyValuePair entry in properties) + { + if (forceUpdate) + { + componentProperties[entry.Key] = entry.Value; + } + else + { + componentProperties.Add(entry.Key, entry.Value); + } + } + + // For a component level property, the property patch needs to contain the {"__t": "c"} component identifier. + if (!componentProperties.ContainsKey(ConventionBasedConstants.ComponentIdentifierKey)) + { + componentProperties[ConventionBasedConstants.ComponentIdentifierKey] = ConventionBasedConstants.ComponentIdentifierValue; + } + + if (forceUpdate) + { + Collection[componentName] = componentProperties; + } + else + { + Collection.Add(componentName, componentProperties); + } + } + } + + /// + /// Determines whether the specified property is present. + /// + /// The property to locate. + /// The component which holds the required property. + /// true if the specified property is present; otherwise, false. + public bool Contains(string componentName, string propertyName) + { + if (!string.IsNullOrEmpty(componentName) && Collection.TryGetValue(componentName, out var component)) + { + return Convention.PayloadSerializer.TryGetNestedObjectValue(component, propertyName, out _); + } + return Collection.TryGetValue(propertyName, out _); + } + + /// + /// Gets the version of the property collection. + /// + /// A that is used to identify the version of the property collection. + public long Version { get; protected set; } + + /// + /// Gets the value of a component-level property. + /// + /// + /// To get the value of a root-level property use . + /// + /// The type to cast the object to. + /// The component which holds the required property. + /// The property to get. + /// The value of the component-level property. + /// true if the property collection contains a component level property with the specified key; otherwise, false. + public virtual bool TryGetValue(string componentName, string propertyName, out T propertyValue) + { + if (Contains(componentName, propertyName)) + { + object componentProperties = Collection[componentName]; + Convention.PayloadSerializer.TryGetNestedObjectValue(componentProperties, propertyName, out propertyValue); + return true; + } + + propertyValue = default; + return false; + } + + /// + /// Converts a collection to a properties collection. + /// + /// This internal class is aware of the implementation of the TwinCollection ad will + /// The TwinCollection object to convert. + /// A convention handler that defines the content encoding and serializer to use for the payload. + /// A new instance of the class from an existing using an optional . + internal static ClientPropertyCollection FromTwinCollection(TwinCollection twinCollection, PayloadConvention payloadConvention) + { + if (twinCollection == null) + { + throw new ArgumentNullException(nameof(twinCollection)); + } + + var propertyCollectionToReturn = new ClientPropertyCollection + { + Convention = payloadConvention, + }; + + foreach (KeyValuePair property in twinCollection) + { + propertyCollectionToReturn.Add(property.Key, payloadConvention.PayloadSerializer.DeserializeToType(Newtonsoft.Json.JsonConvert.SerializeObject(property.Value))); + } + // The version information is not accessible via the enumerator, so assign it separately. + propertyCollectionToReturn.Version = twinCollection.Version; + + return propertyCollectionToReturn; + } + + internal static ClientPropertyCollection FromClientTwinDictionary(IDictionary clientTwinPropertyDictionary, PayloadConvention payloadConvention) + { + if (clientTwinPropertyDictionary == null) + { + throw new ArgumentNullException(nameof(clientTwinPropertyDictionary)); + } + + var propertyCollectionToReturn = new ClientPropertyCollection + { + Convention = payloadConvention, + }; + + foreach (KeyValuePair property in clientTwinPropertyDictionary) + { + // The version information should not be a part of the enumerable ProperyCollection, but rather should be + // accessible through its dedicated accessor. + if (property.Key == VersionName) + { + propertyCollectionToReturn.Version = (long)property.Value; + } + else + { + propertyCollectionToReturn.Add(property.Key, payloadConvention.PayloadSerializer.DeserializeToType(Newtonsoft.Json.JsonConvert.SerializeObject(property.Value))); + } + } + + return propertyCollectionToReturn; + } + } +} diff --git a/iothub/device/src/ClientTwinProperties.cs b/iothub/device/src/ClientTwinProperties.cs new file mode 100644 index 0000000000..63b13195f5 --- /dev/null +++ b/iothub/device/src/ClientTwinProperties.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using Microsoft.Azure.Devices.Shared; +using Newtonsoft.Json; + +namespace Microsoft.Azure.Devices.Client +{ + internal class ClientTwinProperties + { + internal ClientTwinProperties() + { + Desired = new Dictionary(); + Reported = new Dictionary(); + } + + [JsonProperty(PropertyName = "desired", DefaultValueHandling = DefaultValueHandling.Ignore)] + internal IDictionary Desired { get; set; } + + [JsonProperty(PropertyName = "reported", DefaultValueHandling = DefaultValueHandling.Ignore)] + internal IDictionary Reported { get; set; } + + internal ClientProperties ToClientProperties(PayloadConvention payloadConvention) + { + ClientPropertyCollection writablePropertyCollection = ClientPropertyCollection.FromClientTwinDictionary(Desired, payloadConvention); + ClientPropertyCollection propertyCollection = ClientPropertyCollection.FromClientTwinDictionary(Reported, payloadConvention); + + return new ClientProperties(writablePropertyCollection, propertyCollection); + } + } +} diff --git a/iothub/device/src/Common/UrlEncodedDictionarySerializer.cs b/iothub/device/src/Common/UrlEncodedDictionarySerializer.cs index 9bddbdf035..b3e87099a3 100644 --- a/iothub/device/src/Common/UrlEncodedDictionarySerializer.cs +++ b/iothub/device/src/Common/UrlEncodedDictionarySerializer.cs @@ -24,6 +24,11 @@ public class UrlEncodedDictionarySerializer /// public const char PropertySeparator = '&'; + /// + /// The character that marks the start of a query string. + /// + public const string QueryStringIdentifier = "?"; + /// /// The length of property separator string. /// @@ -337,7 +342,11 @@ public IEnumerable GetTokens() private Token CreateToken(TokenType tokenType, int readCount) { - string tokenValue = readCount == 0 ? null : value.Substring(position - readCount, readCount); + // '?' is not a valid character for message property names or values, but instead signifies the start of a query string + // in the case of an MQTT topic. For this reason, we'll replace the '?' from the property key before adding it into + // application properties collection. + // https://docs.microsoft.com/en-us/azure/iot-hub/iot-hub-devguide-messages-construct + string tokenValue = readCount == 0 ? null : value.Substring(position - readCount, readCount).Replace(QueryStringIdentifier, string.Empty); return new Token(tokenType, tokenValue); } diff --git a/iothub/device/src/DeviceClient.ConventionBasedOperations.cs b/iothub/device/src/DeviceClient.ConventionBasedOperations.cs index 7afb17c35d..7931f3ff92 100644 --- a/iothub/device/src/DeviceClient.ConventionBasedOperations.cs +++ b/iothub/device/src/DeviceClient.ConventionBasedOperations.cs @@ -23,7 +23,6 @@ public partial class DeviceClient : IDisposable /// /// The telemetry message. /// A cancellation token to cancel the operation. - /// public Task SendTelemetryAsync(TelemetryMessage telemetryMessage, CancellationToken cancellationToken = default) => InternalClient.SendTelemetryAsync(telemetryMessage, cancellationToken); @@ -37,5 +36,32 @@ public Task SubscribeToCommandsAsync( Func> callback, object userContext, CancellationToken cancellationToken = default) => InternalClient.SubscribeToCommandsAsync(callback, userContext, cancellationToken); + + /// + /// Retrieve the client properties. + /// + /// A cancellation token to cancel the operation. + /// The device properties. + public Task GetClientPropertiesAsync(CancellationToken cancellationToken = default) + => InternalClient.GetClientPropertiesAsync(cancellationToken); + + /// + /// Update the client properties. + /// This operation enables the partial update of the properties of the connected client. + /// + /// Reported properties to push. + /// A cancellation token to cancel the operation. + /// The response of the update operation. + public Task UpdateClientPropertiesAsync(ClientPropertyCollection propertyCollection, CancellationToken cancellationToken = default) + => InternalClient.UpdateClientPropertiesAsync(propertyCollection, cancellationToken); + + /// + /// Sets the global listener for writable property update events. + /// + /// The global call back to handle all writable property updates. + /// Generic parameter to be interpreted by the client code. + /// A cancellation token to cancel the operation. + public Task SubscribeToWritablePropertiesEventAsync(Func callback, object userContext, CancellationToken cancellationToken = default) + => InternalClient.SubscribeToWritablePropertiesEventAsync(callback, userContext, cancellationToken); } } diff --git a/iothub/device/src/IDelegatingHandler.cs b/iothub/device/src/IDelegatingHandler.cs index 7b8cfa8918..3729e0ed47 100644 --- a/iothub/device/src/IDelegatingHandler.cs +++ b/iothub/device/src/IDelegatingHandler.cs @@ -66,5 +66,11 @@ internal interface IDelegatingHandler : IContinuationProvider GetPropertiesAsync(PayloadConvention payloadConvention, CancellationToken cancellationToken); + + Task SendPropertyPatchAsync(ClientPropertyCollection reportedProperties, CancellationToken cancellationToken); } } diff --git a/iothub/device/src/InternalClient.ConventionBasedOperations.cs b/iothub/device/src/InternalClient.ConventionBasedOperations.cs index e92f0d7beb..4e785e96d0 100644 --- a/iothub/device/src/InternalClient.ConventionBasedOperations.cs +++ b/iothub/device/src/InternalClient.ConventionBasedOperations.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Azure.Devices.Client.Exceptions; using Microsoft.Azure.Devices.Shared; namespace Microsoft.Azure.Devices.Client @@ -62,5 +63,52 @@ internal Task SubscribeToCommandsAsync(Func GetClientPropertiesAsync(CancellationToken cancellationToken) + { + try + { + return await InnerHandler.GetPropertiesAsync(PayloadConvention, cancellationToken).ConfigureAwait(false); + } + catch (IotHubCommunicationException ex) when (ex.InnerException is OperationCanceledException) + { + cancellationToken.ThrowIfCancellationRequested(); + throw; + } + } + + internal async Task UpdateClientPropertiesAsync(ClientPropertyCollection clientProperties, CancellationToken cancellationToken) + { + if (clientProperties == null) + { + throw new ArgumentNullException(nameof(clientProperties)); + } + + try + { + clientProperties.Convention = PayloadConvention; + return await InnerHandler.SendPropertyPatchAsync(clientProperties, cancellationToken).ConfigureAwait(false); + } + catch (IotHubCommunicationException ex) when (ex.InnerException is OperationCanceledException) + { + cancellationToken.ThrowIfCancellationRequested(); + throw; + } + } + + internal Task SubscribeToWritablePropertiesEventAsync(Func callback, object userContext, CancellationToken cancellationToken) + { + // Subscribe to DesiredPropertyUpdateCallback internally and use the callback received internally to invoke the user supplied Property callback. + var desiredPropertyUpdateCallback = new DesiredPropertyUpdateCallback((twinCollection, userContext) => + { + // convert a TwinCollection to PropertyCollection + var propertyCollection = ClientPropertyCollection.FromTwinCollection(twinCollection, PayloadConvention); + callback.Invoke(propertyCollection, userContext); + + return TaskHelpers.CompletedTask; + }); + + return SetDesiredPropertyUpdateCallbackAsync(desiredPropertyUpdateCallback, userContext, cancellationToken); + } } } diff --git a/iothub/device/src/MessageBase.cs b/iothub/device/src/MessageBase.cs index 11c491cc27..b9989d36b9 100644 --- a/iothub/device/src/MessageBase.cs +++ b/iothub/device/src/MessageBase.cs @@ -181,15 +181,6 @@ public DateTime CreationTimeUtc /// public bool IsSecurityMessage => CommonConstants.SecurityMessageInterfaceId.Equals(GetSystemProperty(MessageSystemPropertyNames.InterfaceId), StringComparison.Ordinal); - /// - /// Used to specify the content type of the message. - /// - internal string PayloadContentType - { - get => GetSystemProperty(MessageSystemPropertyNames.ContentType); - set => SystemProperties[MessageSystemPropertyNames.ContentType] = value; - } - /// /// Specifies the input name on which the message was sent, if there was one. /// @@ -217,20 +208,11 @@ public string ConnectionModuleId internal set => SystemProperties[MessageSystemPropertyNames.ConnectionModuleId] = value; } - /// - /// Used to specify the content encoding type of the message. - /// - internal string PayloadContentEncoding - { - get => GetSystemProperty(MessageSystemPropertyNames.ContentEncoding); - set => SystemProperties[MessageSystemPropertyNames.ContentEncoding] = value; - } - /// /// The DTDL component name from where the telemetry message has originated. /// This is relevant only for plug and play certified devices. /// - internal string ComponentName + public string ComponentName { get => GetSystemProperty(MessageSystemPropertyNames.ComponentName); set => SystemProperties[MessageSystemPropertyNames.ComponentName] = value; @@ -250,6 +232,24 @@ internal string ComponentName Justification = "Cannot remove public property on a public facing type")] public Stream BodyStream { get { return _bodyStream; } protected set { _bodyStream = value; } } + /// + /// Used to specify the content type of the message. + /// + internal string PayloadContentType + { + get => GetSystemProperty(MessageSystemPropertyNames.ContentType); + set => SystemProperties[MessageSystemPropertyNames.ContentType] = value; + } + + /// + /// Used to specify the content encoding type of the message. + /// + internal string PayloadContentEncoding + { + get => GetSystemProperty(MessageSystemPropertyNames.ContentEncoding); + set => SystemProperties[MessageSystemPropertyNames.ContentEncoding] = value; + } + /// /// For outgoing messages, contains the Mqtt topic that the message is being sent to /// For incoming messages, contains the Mqtt topic that the message arrived on diff --git a/iothub/device/src/ModuleClient.ConventionBasedOperations.cs b/iothub/device/src/ModuleClient.ConventionBasedOperations.cs index d8e19cf128..d7e16690b5 100644 --- a/iothub/device/src/ModuleClient.ConventionBasedOperations.cs +++ b/iothub/device/src/ModuleClient.ConventionBasedOperations.cs @@ -23,7 +23,6 @@ public partial class ModuleClient : IDisposable /// /// The telemetry message. /// A cancellation token to cancel the operation. - /// public Task SendTelemetryAsync(TelemetryMessage telemetryMessage, CancellationToken cancellationToken = default) => InternalClient.SendTelemetryAsync(telemetryMessage, cancellationToken); @@ -37,5 +36,32 @@ public Task SubscribeToCommandsAsync( Func> callback, object userContext, CancellationToken cancellationToken = default) => InternalClient.SubscribeToCommandsAsync(callback, userContext, cancellationToken); + + /// + /// Retrieve the client properties. + /// + /// A cancellation token to cancel the operation. + /// The device properties. + public Task GetClientPropertiesAsync(CancellationToken cancellationToken = default) + => InternalClient.GetClientPropertiesAsync(cancellationToken); + + /// + /// Update the client properties. + /// This operation enables the partial update of the properties of the connected client. + /// + /// Reported properties to push. + /// A cancellation token to cancel the operation. + /// The response of the update operation. + public Task UpdateClientPropertiesAsync(ClientPropertyCollection propertyCollection, CancellationToken cancellationToken = default) + => InternalClient.UpdateClientPropertiesAsync(propertyCollection, cancellationToken); + + /// + /// Sets the global listener for writable property update events. + /// + /// The global call back to handle all writable property updates. + /// Generic parameter to be interpreted by the client code. + /// A cancellation token to cancel the operation. + public Task SubscribeToWritablePropertiesEventAsync(Func callback, object userContext, CancellationToken cancellationToken = default) + => InternalClient.SubscribeToWritablePropertiesEventAsync(callback, userContext, cancellationToken); } } diff --git a/iothub/device/src/Transport/Amqp/AmqpTransportHandler.cs b/iothub/device/src/Transport/Amqp/AmqpTransportHandler.cs index e4ec03001c..f1657f6b1e 100644 --- a/iothub/device/src/Transport/Amqp/AmqpTransportHandler.cs +++ b/iothub/device/src/Transport/Amqp/AmqpTransportHandler.cs @@ -521,6 +521,26 @@ private async Task DisposeMessageAsync(string lockToken, AmqpIotDisposeActions o #endregion Accept-Dispose + #region Convention-based operations + + public override Task GetPropertiesAsync(PayloadConvention payloadConvention, CancellationToken cancellationToken) + { + throw new NotImplementedException("This operation is currently not supported over AMQP, please use MQTT protocol instead. " + + "Note that you can still retrieve a client's properties using the legacy DeviceClient.GetTwinAsync(CancellationToken cancellationToken) or " + + "ModuleClient.GetTwinAsync(CancellationToken cancellationToken) operations, but the properties will not be formatted " + + "as per DTDL terminology."); + } + + public override Task SendPropertyPatchAsync(ClientPropertyCollection reportedProperties, CancellationToken cancellationToken) + { + throw new NotImplementedException("This operation is currently not supported over AMQP, please use MQTT protocol instead. " + + "Note that you can still retrieve a client's properties using the legacy DeviceClient.GetTwinAsync(CancellationToken cancellationToken) or " + + "ModuleClient.GetTwinAsync(CancellationToken cancellationToken) operations, but the properties will not be formatted " + + "as per DTDL terminology."); + } + + #endregion Convention-based operations + #region Helpers private void TwinMessageListener(Twin twin, string correlationId, TwinCollection twinCollection, IotHubException ex = default) diff --git a/iothub/device/src/Transport/DefaultDelegatingHandler.cs b/iothub/device/src/Transport/DefaultDelegatingHandler.cs index d239eaf125..19f9e4a4f5 100644 --- a/iothub/device/src/Transport/DefaultDelegatingHandler.cs +++ b/iothub/device/src/Transport/DefaultDelegatingHandler.cs @@ -202,6 +202,18 @@ public virtual Task DisableEventReceiveAsync(CancellationToken cancellationToken return InnerHandler?.DisableEventReceiveAsync(cancellationToken) ?? TaskHelpers.CompletedTask; } + public virtual Task GetPropertiesAsync(PayloadConvention payloadConvention, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return InnerHandler?.GetPropertiesAsync(payloadConvention, cancellationToken) ?? Task.FromResult(null); + } + + public virtual Task SendPropertyPatchAsync(ClientPropertyCollection reportedProperties, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return InnerHandler?.SendPropertyPatchAsync(reportedProperties, cancellationToken) ?? Task.FromResult(null); + } + public virtual bool IsUsable => InnerHandler?.IsUsable ?? true; protected void ThrowIfDisposed() diff --git a/iothub/device/src/Transport/ErrorDelegatingHandler.cs b/iothub/device/src/Transport/ErrorDelegatingHandler.cs index ee6c9e2d5b..058f6ce00e 100644 --- a/iothub/device/src/Transport/ErrorDelegatingHandler.cs +++ b/iothub/device/src/Transport/ErrorDelegatingHandler.cs @@ -145,6 +145,16 @@ public override Task SendMethodResponseAsync(MethodResponseInternal methodRespon return ExecuteWithErrorHandlingAsync(() => base.SendMethodResponseAsync(methodResponse, cancellationToken)); } + public override Task GetPropertiesAsync(PayloadConvention payloadConvention, CancellationToken cancellationToken) + { + return ExecuteWithErrorHandlingAsync(() => base.GetPropertiesAsync(payloadConvention, cancellationToken)); + } + + public override Task SendPropertyPatchAsync(ClientPropertyCollection reportedProperties, CancellationToken cancellationToken) + { + return ExecuteWithErrorHandlingAsync(() => base.SendPropertyPatchAsync(reportedProperties, cancellationToken)); + } + private static bool IsNetworkExceptionChain(Exception exceptionChain) { return exceptionChain.Unwind(true).Any(e => IsNetwork(e) && !IsTlsSecurity(e)); diff --git a/iothub/device/src/Transport/HttpTransportHandler.cs b/iothub/device/src/Transport/HttpTransportHandler.cs index 7917452398..9d7972d1b6 100644 --- a/iothub/device/src/Transport/HttpTransportHandler.cs +++ b/iothub/device/src/Transport/HttpTransportHandler.cs @@ -397,6 +397,16 @@ public override Task RejectAsync(string lockToken, CancellationToken cancellatio cancellationToken); } + public override Task GetPropertiesAsync(PayloadConvention payloadConvention, CancellationToken cancellationToken) + { + throw new NotImplementedException("Property operations are not supported over HTTP. Please use MQTT protocol instead."); + } + + public override Task SendPropertyPatchAsync(ClientPropertyCollection reportedProperties, CancellationToken cancellationToken) + { + throw new NotImplementedException("Property operations are not supported over HTTP. Please use MQTT protocol instead."); + } + // This is for invoking methods from an edge module to another edge device or edge module. internal Task InvokeMethodAsync(MethodInvokeRequest methodInvokeRequest, Uri uri, CancellationToken cancellationToken) { diff --git a/iothub/device/src/Transport/Mqtt/MqttIotHubAdapter.cs b/iothub/device/src/Transport/Mqtt/MqttIotHubAdapter.cs index af9a82fa73..054dcf580f 100644 --- a/iothub/device/src/Transport/Mqtt/MqttIotHubAdapter.cs +++ b/iothub/device/src/Transport/Mqtt/MqttIotHubAdapter.cs @@ -168,7 +168,7 @@ public override async Task WriteAsync(IChannelHandlerContext context, object dat throw new IotHubCommunicationException("MQTT is disconnected."); } - if (data is Message message) + if (data is MessageBase message) { await SendMessageAsync(context, message).ConfigureAwait(true); return; @@ -839,7 +839,7 @@ private void ProcessPublish(IChannelHandlerContext context, PublishPacket packet Logging.Exit(this, context.Name, packet, nameof(ProcessPublish)); } - private async Task SendMessageAsync(IChannelHandlerContext context, Message message) + private async Task SendMessageAsync(IChannelHandlerContext context, MessageBase message) { if (Logging.IsEnabled) Logging.Enter(this, context.Name, message, nameof(SendMessageAsync)); @@ -1132,7 +1132,7 @@ private ushort GetNextPacketId() return ret; } - public async Task ComposePublishPacketAsync(IChannelHandlerContext context, Message message, QualityOfService qos, string topicName) + public async Task ComposePublishPacketAsync(IChannelHandlerContext context, MessageBase message, QualityOfService qos, string topicName) { if (Logging.IsEnabled) Logging.Enter(this, context.Name, topicName, nameof(ComposePublishPacketAsync)); @@ -1386,7 +1386,7 @@ public static async Task WriteMessageAsync(IChannelHandlerContext context, objec } } - internal static string PopulateMessagePropertiesFromMessage(string topicName, Message message) + internal static string PopulateMessagePropertiesFromMessage(string topicName, MessageBase message) { var systemProperties = new Dictionary(); foreach (KeyValuePair property in message.SystemProperties) diff --git a/iothub/device/src/Transport/Mqtt/MqttTransportHandler.cs b/iothub/device/src/Transport/Mqtt/MqttTransportHandler.cs index 43ae6551e0..1ef3e235f3 100644 --- a/iothub/device/src/Transport/Mqtt/MqttTransportHandler.cs +++ b/iothub/device/src/Transport/Mqtt/MqttTransportHandler.cs @@ -88,6 +88,11 @@ internal sealed class MqttTransportHandler : TransportHandler, IMqttIotHubEventH private const string ReceiveEventMessagePatternFilter = "devices/{0}/modules/{1}/#"; private const string ReceiveEventMessagePrefixPattern = "devices/{0}/modules/{1}/"; + // Identifiers for property update operations. + public const string VersionKey = "$version"; + + public const string RequestIdKey = "$rid"; + private static readonly int s_generationPrefixLength = Guid.NewGuid().ToString().Length; private static readonly Lazy s_eventLoopGroup = new Lazy(GetEventLoopGroup); private static readonly TimeSpan s_regexTimeoutMilliseconds = TimeSpan.FromMilliseconds(500); @@ -972,6 +977,56 @@ public override async Task SendTwinPatchAsync(TwinCollection reportedProperties, await SendTwinRequestAsync(request, rid, cancellationToken).ConfigureAwait(false); } + public override async Task GetPropertiesAsync(PayloadConvention payloadConvention, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + EnsureValidState(); + + using var request = new Message(); + string rid = Guid.NewGuid().ToString(); + request.MqttTopicName = TwinGetTopic.FormatInvariant(rid); + + using Message response = await SendTwinRequestAsync(request, rid, cancellationToken).ConfigureAwait(false); + + using var reader = new StreamReader(response.GetBodyStream(), payloadConvention.PayloadEncoder.ContentEncoding); + string body = reader.ReadToEnd(); + + try + { + ClientTwinProperties twinProperties = JsonConvert.DeserializeObject(body); + var properties = twinProperties.ToClientProperties(payloadConvention); + return properties; + } + catch (JsonReaderException ex) + { + if (Logging.IsEnabled) + Logging.Error(this, $"Failed to parse Twin JSON: {ex}. Message body: '{body}'"); + + throw; + } + } + + public override async Task SendPropertyPatchAsync(ClientPropertyCollection reportedProperties, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + EnsureValidState(); + + byte[] body = reportedProperties.GetPayloadObjectBytes(); + using var bodyStream = new MemoryStream(body); + + using var request = new Message(bodyStream); + + string rid = Guid.NewGuid().ToString(); + request.MqttTopicName = TwinPatchTopic.FormatInvariant(rid); + + using Message message = await SendTwinRequestAsync(request, rid, cancellationToken).ConfigureAwait(false); + return new ClientPropertiesUpdateResponse + { + RequestId = message.Properties[RequestIdKey], + Version = long.Parse(message.Properties[VersionKey], CultureInfo.InvariantCulture) + }; + } + private async Task OpenInternalAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -1098,17 +1153,15 @@ private Task SubscribeTwinResponsesAsync() QualityOfService.AtMostOnce))); } - private bool ParseResponseTopic(string topicName, out string rid, out int status) + private bool ParseResponseTopic(string topicName, out int status) { Match match = _twinResponseTopicRegex.Match(topicName); if (match.Success) { status = Convert.ToInt32(match.Groups[1].Value, CultureInfo.InvariantCulture); - rid = HttpUtility.ParseQueryString(match.Groups[2].Value).Get("$rid"); return true; } - rid = ""; status = 500; return false; } @@ -1125,9 +1178,9 @@ private async Task SendTwinRequestAsync(Message request, string rid, Ca { try { - if (ParseResponseTopic(possibleResponse.MqttTopicName, out string receivedRid, out int status)) + if (ParseResponseTopic(possibleResponse.MqttTopicName, out int status)) { - if (rid == receivedRid) + if (rid == possibleResponse.Properties[RequestIdKey]) { if (status >= 300) { diff --git a/iothub/device/src/Transport/RetryDelegatingHandler.cs b/iothub/device/src/Transport/RetryDelegatingHandler.cs index c3d0c2285b..35ead90d7b 100644 --- a/iothub/device/src/Transport/RetryDelegatingHandler.cs +++ b/iothub/device/src/Transport/RetryDelegatingHandler.cs @@ -606,6 +606,50 @@ await _internalRetryPolicy } } + public override async Task GetPropertiesAsync(PayloadConvention payloadConvention, CancellationToken cancellationToken) + { + try + { + Logging.Enter(this, payloadConvention, cancellationToken, nameof(SendPropertyPatchAsync)); + + return await _internalRetryPolicy + .ExecuteAsync( + async () => + { + await EnsureOpenedAsync(cancellationToken).ConfigureAwait(false); + return await base.GetPropertiesAsync(payloadConvention, cancellationToken).ConfigureAwait(false); + }, + cancellationToken) + .ConfigureAwait(false); + } + finally + { + Logging.Exit(this, payloadConvention, cancellationToken, nameof(SendPropertyPatchAsync)); + } + } + + public override async Task SendPropertyPatchAsync(ClientPropertyCollection reportedProperties, CancellationToken cancellationToken) + { + try + { + Logging.Enter(this, reportedProperties, cancellationToken, nameof(SendPropertyPatchAsync)); + + return await _internalRetryPolicy + .ExecuteAsync( + async () => + { + await EnsureOpenedAsync(cancellationToken).ConfigureAwait(false); + return await base.SendPropertyPatchAsync(reportedProperties, cancellationToken).ConfigureAwait(false); + }, + cancellationToken) + .ConfigureAwait(false); + } + finally + { + Logging.Exit(this, reportedProperties, cancellationToken, nameof(SendPropertyPatchAsync)); + } + } + public override Task OpenAsync(CancellationToken cancellationToken) { return EnsureOpenedAsync(cancellationToken); diff --git a/iothub/device/tests/Mqtt/MqttTransportHandlerTests.cs b/iothub/device/tests/Mqtt/MqttTransportHandlerTests.cs index f56ad26c01..87275156ea 100644 --- a/iothub/device/tests/Mqtt/MqttTransportHandlerTests.cs +++ b/iothub/device/tests/Mqtt/MqttTransportHandlerTests.cs @@ -1,5 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information +// Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; using System.Diagnostics; @@ -37,6 +37,7 @@ public class MqttTransportHandlerTests private const int statusFailure = 400; private const string fakeResponseId = "fakeResponseId"; private static readonly TimeSpan ReceiveTimeoutBuffer = TimeSpan.FromSeconds(5); + private delegate bool MessageMatcher(Message msg); [TestMethod] @@ -369,7 +370,13 @@ public async Task MqttTransportHandlerSendTwinGetAsyncHappyPath() .Returns(msg => { var response = new Message(twinByteStream); - response.MqttTopicName = GetResponseTopic(msg.Arg().MqttTopicName, statusSuccess); + string mqttTopic = GetResponseTopic(msg.Arg().MqttTopicName, statusSuccess); + response.MqttTopicName = mqttTopic; + var publishPacket = new PublishPacket(QualityOfService.AtMostOnce, false, false) + { + TopicName = mqttTopic + }; + MqttIotHubAdapter.PopulateMessagePropertiesFromPacket(response, publishPacket); transport.OnMessageReceived(response); return TaskHelpers.CompletedTask; }); @@ -393,7 +400,13 @@ public async Task MqttTransportHandlerSendTwinGetAsyncReturnsFailure() .Returns(msg => { var response = new Message(); - response.MqttTopicName = GetResponseTopic(msg.Arg().MqttTopicName, statusFailure); + string mqttTopic = GetResponseTopic(msg.Arg().MqttTopicName, statusFailure); + response.MqttTopicName = mqttTopic; + var publishPacket = new PublishPacket(QualityOfService.AtMostOnce, false, false) + { + TopicName = mqttTopic + }; + MqttIotHubAdapter.PopulateMessagePropertiesFromPacket(response, publishPacket); transport.OnMessageReceived(response); return TaskHelpers.CompletedTask; }); @@ -431,8 +444,8 @@ public async Task MqttTransportHandlerSendTwinPatchAsyncHappyPath() // arrange var transport = CreateTransportHandlerWithMockChannel(out IChannel channel); var props = new TwinCollection(); - string receivedBody = null; props["foo"] = "bar"; + string receivedBody = null; channel .WriteAndFlushAsync(Arg.Is(msg => msg.MqttTopicName.StartsWith(twinPatchReportedTopicPrefix))) .Returns(msg => @@ -442,7 +455,13 @@ public async Task MqttTransportHandlerSendTwinPatchAsyncHappyPath() receivedBody = reader.ReadToEnd(); var response = new Message(); - response.MqttTopicName = GetResponseTopic(request.MqttTopicName, statusSuccess); + string mqttTopic = GetResponseTopic(request.MqttTopicName, statusSuccess); + response.MqttTopicName = mqttTopic; + var publishPacket = new PublishPacket(QualityOfService.AtMostOnce, false, false) + { + TopicName = mqttTopic + }; + MqttIotHubAdapter.PopulateMessagePropertiesFromPacket(response, publishPacket); transport.OnMessageReceived(response); return TaskHelpers.CompletedTask; @@ -470,7 +489,13 @@ public async Task MqttTransportHandlerSendTwinPatchAsyncReturnsFailure() { var request = msg.Arg(); var response = new Message(); - response.MqttTopicName = GetResponseTopic(request.MqttTopicName, statusFailure); + string mqttTopic = GetResponseTopic(request.MqttTopicName, statusFailure); + response.MqttTopicName = mqttTopic; + var publishPacket = new PublishPacket(QualityOfService.AtMostOnce, false, false) + { + TopicName = mqttTopic + }; + MqttIotHubAdapter.PopulateMessagePropertiesFromPacket(response, publishPacket); transport.OnMessageReceived(response); return TaskHelpers.CompletedTask; }); diff --git a/shared/src/NewtonsoftJsonPayloadSerializer.cs b/shared/src/NewtonsoftJsonPayloadSerializer.cs index ec0b10cef0..7d90b163a0 100644 --- a/shared/src/NewtonsoftJsonPayloadSerializer.cs +++ b/shared/src/NewtonsoftJsonPayloadSerializer.cs @@ -43,7 +43,7 @@ public override T ConvertFromObject(object objectToConvert) { return default; } - return ((JObject)objectToConvert).ToObject(); + return ((JToken)objectToConvert).ToObject(); } /// diff --git a/shared/src/StatusCodes.cs b/shared/src/StatusCodes.cs new file mode 100644 index 0000000000..a07bba5d10 --- /dev/null +++ b/shared/src/StatusCodes.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + + +namespace Microsoft.Azure.Devices.Shared +{ + /// + /// A list of common status codes to represent the response from the client. + /// + /// + /// These status codes are based on the HTTP status codes listed here + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1052:Static holder types should be Static or NotInheritable", Justification = "To allow customers to extend this class we need to not mark it static.")] + public class StatusCodes + { + /// + /// Status code 200. + /// + public static int OK => 200; + /// + /// Status code 202. + /// + public static int Accepted => 202; + /// + /// Status code 400. + /// + public static int BadRequest => 400; + /// + /// Status code 404. + /// + public static int NotFound => 404; + } +} From fda54b8188a99fa9e54287f73d8af454ab438e3e Mon Sep 17 00:00:00 2001 From: jamdavi <73593426+jamdavi@users.noreply.github.com> Date: Fri, 21 May 2021 21:53:13 -0400 Subject: [PATCH 05/14] feat(e2e-tests): Add telemetry E2E tests --- .../iothub/telemetry/TelemetrySendE2ETests.cs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 e2e/test/iothub/telemetry/TelemetrySendE2ETests.cs diff --git a/e2e/test/iothub/telemetry/TelemetrySendE2ETests.cs b/e2e/test/iothub/telemetry/TelemetrySendE2ETests.cs new file mode 100644 index 0000000000..5eed7aab2c --- /dev/null +++ b/e2e/test/iothub/telemetry/TelemetrySendE2ETests.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Net; +using System.Threading.Tasks; +using Microsoft.Azure.Devices.Client; +using Microsoft.Azure.Devices.E2ETests.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Azure.Devices.E2ETests.Telemetry +{ + [TestClass] + [TestCategory("E2E")] + [TestCategory("IoTHub")] + public partial class TelemetrySendE2ETests : E2EMsTestBase + { + private readonly string DevicePrefix = $"{nameof(TelemetrySendE2ETests)}_"; + private readonly string ModulePrefix = $"{nameof(TelemetrySendE2ETests)}_"; + + [LoggedTestMethod] + public async Task Telemetry_DeviceSendSingleTelemetry_Mqtt() + { + await SendTelemetryAsync(TestDeviceType.Sasl, Client.TransportType.Mqtt_Tcp_Only).ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Telemetry_DeviceSendSingleTelemetry_MqttWs() + { + await SendTelemetryAsync(TestDeviceType.Sasl, Client.TransportType.Mqtt_WebSocket_Only).ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Telemetry_DeviceSendSingleTelemetryWithComponent_Mqtt() + { + await SendTelemetryWithComponentAsync(TestDeviceType.Sasl, Client.TransportType.Mqtt_Tcp_Only).ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Telemetry_DeviceSendSingleTelemetryWithComponent_MqttWs() + { + await SendTelemetryWithComponentAsync(TestDeviceType.Sasl, Client.TransportType.Mqtt_WebSocket_Only).ConfigureAwait(false); + } + + private async Task SendTelemetryAsync(TestDeviceType type, Client.TransportType transport) + { + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, DevicePrefix, type).ConfigureAwait(false); + using DeviceClient deviceClient = testDevice.CreateDeviceClient(transport); + + await deviceClient.OpenAsync().ConfigureAwait(false); + await SendSingleMessageAsync(deviceClient, testDevice.Id, Logger).ConfigureAwait(false); + await deviceClient.CloseAsync().ConfigureAwait(false); + } + + private async Task SendTelemetryWithComponentAsync(TestDeviceType type, Client.TransportType transport) + { + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, DevicePrefix, type).ConfigureAwait(false); + using DeviceClient deviceClient = testDevice.CreateDeviceClient(transport); + + await deviceClient.OpenAsync().ConfigureAwait(false); + await SendSingleMessageWithComponentAsync(deviceClient, testDevice.Id, Logger).ConfigureAwait(false); + await deviceClient.CloseAsync().ConfigureAwait(false); + } + + public static async Task SendSingleMessageAsync(DeviceClient deviceClient, string deviceId, MsTestLogger logger) + { + Client.TelemetryMessage testMessage; + (testMessage, _) = ComposeTelemetryMessage(logger); + + using (testMessage) + { + await deviceClient.SendTelemetryAsync(testMessage).ConfigureAwait(false); + } + } + + public static async Task SendSingleMessageWithComponentAsync(DeviceClient deviceClient, string deviceId, MsTestLogger logger) + { + Client.TelemetryMessage testMessage; + (testMessage, _) = ComposeTelemetryMessageWithComponent(logger); + + using (testMessage) + { + await deviceClient.SendTelemetryAsync(testMessage).ConfigureAwait(false); + } + } + + public static (Client.TelemetryMessage message, string p1Value) ComposeTelemetryMessageWithComponent(MsTestLogger logger) + { + string messageId = Guid.NewGuid().ToString(); + string p1Value = Guid.NewGuid().ToString(); + string userId = Guid.NewGuid().ToString(); + string componentName = Guid.NewGuid().ToString(); + + logger.Trace($"{nameof(ComposeTelemetryMessageWithComponent)}: messageId='{messageId}' userId='{userId}' p1Value='{p1Value}'"); + var message = new TelemetryMessage + { + MessageId = messageId, + UserId = userId, + ComponentName = componentName, + Telemetry = { + { "property1", p1Value }, + { "property2", null}, + } + }; + + return (message, p1Value); + } + + public static (Client.TelemetryMessage message, string p1Value) ComposeTelemetryMessage(MsTestLogger logger) + { + string messageId = Guid.NewGuid().ToString(); + string p1Value = Guid.NewGuid().ToString(); + string userId = Guid.NewGuid().ToString(); + + logger.Trace($"{nameof(ComposeTelemetryMessage)}: messageId='{messageId}' userId='{userId}' p1Value='{p1Value}'"); + var message = new TelemetryMessage + { + MessageId = messageId, + UserId = userId, + Telemetry = { + { "property1", p1Value }, + { "property2", null}, + } + }; + + return (message, p1Value); + } + + } +} From 531f2043069a6a60b636451b70eb8cc36ade0a70 Mon Sep 17 00:00:00 2001 From: jamdavi <73593426+jamdavi@users.noreply.github.com> Date: Fri, 21 May 2021 23:30:42 -0400 Subject: [PATCH 06/14] feat(e2e-tests): Add command E2E tests --- e2e/test/iothub/command/CommandE2ETests.cs | 198 +++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 e2e/test/iothub/command/CommandE2ETests.cs diff --git a/e2e/test/iothub/command/CommandE2ETests.cs b/e2e/test/iothub/command/CommandE2ETests.cs new file mode 100644 index 0000000000..07606a5225 --- /dev/null +++ b/e2e/test/iothub/command/CommandE2ETests.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Diagnostics.Tracing; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.Devices.Client; +using Microsoft.Azure.Devices.Common.Exceptions; +using Microsoft.Azure.Devices.E2ETests.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; + +namespace Microsoft.Azure.Devices.E2ETests.Commands +{ + public class ServiceCommandRequestAssertion + { + public int A => 123; + } + + public class ServiceCommandRequestObject + { + public int A { get; set; } + } + + public class DeviceCommandResponse + { + public string Name => "e2e_test"; + } + + [TestClass] + [TestCategory("E2E")] + [TestCategory("IoTHub")] + public class CommandE2ETests : E2EMsTestBase + { + public const string ComponentName = "testableComponent"; + + private readonly string _devicePrefix = $"E2E_{nameof(CommandE2ETests)}_"; + private readonly string _modulePrefix = $"E2E_{nameof(CommandE2ETests)}_"; + private const string CommandName = "CommandE2ETest"; + + private static readonly TimeSpan s_defaultCommandTimeoutMinutes = TimeSpan.FromMinutes(1); + + [LoggedTestMethod] + public async Task Command_DeviceReceivesCommandAndResponse_Mqtt() + { + await SendCommandAndRespondAsync(Client.TransportType.Mqtt_Tcp_Only, SetDeviceReceiveCommandAsync).ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Command_DeviceReceivesCommandAndResponse_MqttWs() + { + await SendCommandAndRespondAsync(Client.TransportType.Mqtt_WebSocket_Only, SetDeviceReceiveCommandAsync).ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Command_DeviceReceivesCommandAndResponseWithComponent_Mqtt() + { + await SendCommandAndRespondAsync(Client.TransportType.Mqtt_Tcp_Only, SetDeviceReceiveCommandAsync, withComponent: true).ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Command_DeviceReceivesCommandAndResponseWithComponent_MqttWs() + { + await SendCommandAndRespondAsync(Client.TransportType.Mqtt_WebSocket_Only, SetDeviceReceiveCommandAsync, withComponent: true).ConfigureAwait(false); + } + + public static async Task DigitalTwinsSendCommandAndVerifyResponseAsync(string deviceId, string componentName, string commandName, MsTestLogger logger) + { + string payloadToSend = JsonConvert.SerializeObject(new ServiceCommandRequestObject { A = 123 }); + string responseExpected = JsonConvert.SerializeObject(new DeviceCommandResponse()); + string payloadReceived = string.Empty; + int statusCode = 0; +#if NET451 + + ServiceClient serviceClient = ServiceClient.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + + logger.Trace($"{nameof(DigitalTwinsSendCommandAndVerifyResponseAsync)}: Invoke command {commandName}."); + + CloudToDeviceMethodResult serviceClientResponse = null; + if (string.IsNullOrEmpty(componentName)) + { + var serviceCommand = new CloudToDeviceMethod(commandName).SetPayloadJson(payloadToSend); + serviceClientResponse = + await serviceClient.InvokeDeviceMethodAsync( + deviceId, serviceCommand).ConfigureAwait(false); + } + else + { + var serviceCommand = new CloudToDeviceMethod($"{componentName}*{commandName}").SetPayloadJson(payloadToSend); + serviceClientResponse = + await serviceClient.InvokeDeviceMethodAsync( + deviceId, serviceCommand).ConfigureAwait(false); + } + + statusCode = serviceClientResponse.Status; + payloadReceived = serviceClientResponse.GetPayloadAsJson(); + + serviceClient.Dispose(); +#else + DigitalTwinClient digitalTwinClient = DigitalTwinClient.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + + logger.Trace($"{nameof(DigitalTwinsSendCommandAndVerifyResponseAsync)}: Invoke command {commandName}."); + + Rest.HttpOperationResponse response = null; + if (string.IsNullOrEmpty(componentName)) + { + response = + await digitalTwinClient.InvokeCommandAsync( + deviceId, + commandName, + payloadToSend).ConfigureAwait(false); + } + else + { + response = + await digitalTwinClient.InvokeComponentCommandAsync( + deviceId, + componentName, + commandName, + payloadToSend).ConfigureAwait(false); + } + + statusCode = (int)response.Response.StatusCode; + payloadReceived = response.Body.Payload; + + digitalTwinClient.Dispose(); +#endif + logger.Trace($"{nameof(DigitalTwinsSendCommandAndVerifyResponseAsync)}: Command status: {statusCode}."); + Assert.AreEqual(200, statusCode, $"The expected response status should be 200 but was {statusCode}"); + Assert.AreEqual(responseExpected, payloadReceived, $"The expected response payload should be {responseExpected} but was {payloadReceived}"); + + } + + public static async Task SetDeviceReceiveCommandAsync(DeviceClient deviceClient, string componentName, string commandName, MsTestLogger logger) + { + var commandCallReceived = new TaskCompletionSource(); + + await deviceClient.SubscribeToCommandsAsync( + (request, context) => + { + logger.Trace($"{nameof(SetDeviceReceiveCommandAsync)}: DeviceClient command: {request.CommandName}."); + + try + { + var valueToTest = request.GetData(); + if (string.IsNullOrEmpty(componentName)) + { + Assert.AreEqual(null, request.ComponentName, $"The expected component name should be null but was {request.ComponentName}"); + } + else + { + Assert.AreEqual(componentName, request.ComponentName, $"The expected component name should be {componentName} but was {request.ComponentName}"); + + } + var assertionObject = new ServiceCommandRequestAssertion(); + string responseExpected = JsonConvert.SerializeObject(assertionObject); + Assert.AreEqual(responseExpected, request.DataAsJson, $"The expected response payload should be {responseExpected} but was {request.DataAsJson}"); + Assert.AreEqual(assertionObject.A, valueToTest.A, $"The expected response object did not decode properly. Value a should be {assertionObject.A} but was {valueToTest?.A ?? int.MinValue}"); + commandCallReceived.SetResult(true); + } + catch (Exception ex) + { + commandCallReceived.SetException(ex); + } + + return Task.FromResult(new CommandResponse(new DeviceCommandResponse(), 200)); + }, + null).ConfigureAwait(false); + + // Return the task that tells us we have received the callback. + return commandCallReceived.Task; + } + + private async Task SendCommandAndRespondAsync(Client.TransportType transport, Func> setDeviceReceiveCommand, bool withComponent = false) + { + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + string componentName = null; + if (withComponent) + { + componentName = ComponentName; + } + + Task commandReceivedTask = await setDeviceReceiveCommand(deviceClient, componentName, CommandName, Logger).ConfigureAwait(false); + + await Task + .WhenAll( + DigitalTwinsSendCommandAndVerifyResponseAsync(testDevice.Id, componentName, CommandName, Logger), + commandReceivedTask) + .ConfigureAwait(false); + + await deviceClient.CloseAsync().ConfigureAwait(false); + } + } +} \ No newline at end of file From e2d88e468e6453ac4cd630f29f82f9b01837e084 Mon Sep 17 00:00:00 2001 From: jamdavi <73593426+jamdavi@users.noreply.github.com> Date: Mon, 24 May 2021 16:12:46 -0400 Subject: [PATCH 07/14] fix(iot-device): Updating client property collection to handle no convention --- iothub/device/src/ClientPropertyCollection.cs | 26 +++++++++++++++++-- iothub/device/src/PayloadCollection.cs | 6 +++++ shared/src/SystemTextJsonPayloadSerializer.cs | 3 +-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/iothub/device/src/ClientPropertyCollection.cs b/iothub/device/src/ClientPropertyCollection.cs index df9e17dbad..cd6737e15a 100644 --- a/iothub/device/src/ClientPropertyCollection.cs +++ b/iothub/device/src/ClientPropertyCollection.cs @@ -283,6 +283,10 @@ public bool Contains(string componentName, string propertyName) { if (!string.IsNullOrEmpty(componentName) && Collection.TryGetValue(componentName, out var component)) { + if (component is IDictionary nestedDictionary) + { + return nestedDictionary.TryGetValue(propertyName, out var _); + } return Convention.PayloadSerializer.TryGetNestedObjectValue(component, propertyName, out _); } return Collection.TryGetValue(propertyName, out _); @@ -307,11 +311,29 @@ public bool Contains(string componentName, string propertyName) /// true if the property collection contains a component level property with the specified key; otherwise, false. public virtual bool TryGetValue(string componentName, string propertyName, out T propertyValue) { + if (Logging.IsEnabled && Convention == null) + { + Logging.Info(this, $"The convention for this collection is not set; this typically means this collection was not created by the client. " + + $"TryGetValue will attempt to get the property value but may not behave as expected.", nameof(TryGetValue)); + } + if (Contains(componentName, propertyName)) { object componentProperties = Collection[componentName]; - Convention.PayloadSerializer.TryGetNestedObjectValue(componentProperties, propertyName, out propertyValue); - return true; + + if (componentProperties is IDictionary nestedDictionary) + { + if (nestedDictionary.TryGetValue(propertyName, out object dictionaryElement) && dictionaryElement is T valueRef) + { + propertyValue = valueRef; + return true; + } + } + else + { + Convention.PayloadSerializer.TryGetNestedObjectValue(componentProperties, propertyName, out propertyValue); + return true; + } } propertyValue = default; diff --git a/iothub/device/src/PayloadCollection.cs b/iothub/device/src/PayloadCollection.cs index b417db2ee6..4710157b06 100644 --- a/iothub/device/src/PayloadCollection.cs +++ b/iothub/device/src/PayloadCollection.cs @@ -100,6 +100,12 @@ public bool Contains(string key) /// True if the collection contains an element with the specified key; otherwise, it returns false. public bool TryGetValue(string key, out T value) { + if (Logging.IsEnabled && Convention == null) + { + Logging.Info(this, $"The convention for this collection is not set; this typically means this collection was not created by the client. " + + $"TryGetValue will attempt to get the property value but may not behave as expected.", nameof(TryGetValue)); + } + if (Collection.ContainsKey(key)) { // If the object is of type T go ahead and return it. diff --git a/shared/src/SystemTextJsonPayloadSerializer.cs b/shared/src/SystemTextJsonPayloadSerializer.cs index 8a5ab3f235..31602cbc6e 100644 --- a/shared/src/SystemTextJsonPayloadSerializer.cs +++ b/shared/src/SystemTextJsonPayloadSerializer.cs @@ -4,9 +4,8 @@ #if !NET451 using System.Text.Json; -using Microsoft.Azure.Devices.Shared; -namespace Microsoft.Azure.Devices.Client.Samples +namespace Microsoft.Azure.Devices.Shared { /// /// A implementation. From ecaaaeb37f95c6d7147b70c768e1de0fc1a1d720 Mon Sep 17 00:00:00 2001 From: Abhipsa Misra Date: Mon, 24 May 2021 17:20:05 -0700 Subject: [PATCH 08/14] feat(samples): Add thermostat and temperature controller sample --- azureiot.sln | 20 + .../Models/DeviceInformation.json | 64 +++ .../Models/TemperatureController.json | 83 ++++ .../Models/Thermostat.json | 89 ++++ .../Models/Thermostat2.json | 89 ++++ .../TemperatureController/Parameter.cs | 86 ++++ .../TemperatureController/Program.cs | 167 +++++++ .../Properties/launchSettings.template.json | 22 + .../SystemTextJsonPayloadConvention.cs | 19 + .../SystemTextJsonPayloadSerializer.cs | 9 +- .../TemperatureController.csproj | 19 + .../TemperatureControllerSample.cs | 407 ++++++++++++++++++ .../TemperatureReport.cs | 26 ++ .../Thermostat/Models/Thermostat.json | 89 ++++ .../Thermostat/Parameter.cs | 86 ++++ .../Thermostat/Program.cs | 162 +++++++ .../Properties/launchSettings.template.json | 22 + .../Thermostat/TemperatureReport.cs | 26 ++ .../Thermostat/Thermostat.csproj | 19 + .../Thermostat/ThermostatSample.cs | 200 +++++++++ .../convention-based-samples/readme.md | 58 +++ .../Transport/Amqp/AmqpTransportHandler.cs | 4 +- 22 files changed, 1758 insertions(+), 8 deletions(-) create mode 100644 iothub/device/samples/convention-based-samples/TemperatureController/Models/DeviceInformation.json create mode 100644 iothub/device/samples/convention-based-samples/TemperatureController/Models/TemperatureController.json create mode 100644 iothub/device/samples/convention-based-samples/TemperatureController/Models/Thermostat.json create mode 100644 iothub/device/samples/convention-based-samples/TemperatureController/Models/Thermostat2.json create mode 100644 iothub/device/samples/convention-based-samples/TemperatureController/Parameter.cs create mode 100644 iothub/device/samples/convention-based-samples/TemperatureController/Program.cs create mode 100644 iothub/device/samples/convention-based-samples/TemperatureController/Properties/launchSettings.template.json create mode 100644 iothub/device/samples/convention-based-samples/TemperatureController/SystemTextJsonPayloadConvention.cs rename {shared/src => iothub/device/samples/convention-based-samples/TemperatureController}/SystemTextJsonPayloadSerializer.cs (93%) create mode 100644 iothub/device/samples/convention-based-samples/TemperatureController/TemperatureController.csproj create mode 100644 iothub/device/samples/convention-based-samples/TemperatureController/TemperatureControllerSample.cs create mode 100644 iothub/device/samples/convention-based-samples/TemperatureController/TemperatureReport.cs create mode 100644 iothub/device/samples/convention-based-samples/Thermostat/Models/Thermostat.json create mode 100644 iothub/device/samples/convention-based-samples/Thermostat/Parameter.cs create mode 100644 iothub/device/samples/convention-based-samples/Thermostat/Program.cs create mode 100644 iothub/device/samples/convention-based-samples/Thermostat/Properties/launchSettings.template.json create mode 100644 iothub/device/samples/convention-based-samples/Thermostat/TemperatureReport.cs create mode 100644 iothub/device/samples/convention-based-samples/Thermostat/Thermostat.csproj create mode 100644 iothub/device/samples/convention-based-samples/Thermostat/ThermostatSample.cs create mode 100644 iothub/device/samples/convention-based-samples/readme.md diff --git a/azureiot.sln b/azureiot.sln index dd21735d2b..5b4d4d37ae 100644 --- a/azureiot.sln +++ b/azureiot.sln @@ -77,6 +77,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.Devices.Shared.Tests", "shared\tests\Microsoft.Azure.Devices.Shared.Tests.csproj", "{CEEE435F-32FC-4DE5-8735-90F6AC950A01}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{2368415A-9C09-4F47-9636-FDCA4B85C88C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "convention-based-samples", "convention-based-samples", "{22318FE4-1453-41BF-A38D-9401C4F16023}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Thermostat", "iothub\device\samples\convention-based-samples\Thermostat\Thermostat.csproj", "{5658A5DF-EDEF-4561-9F0B-A37EEABC8135}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TemperatureController", "iothub\device\samples\convention-based-samples\TemperatureController\TemperatureController.csproj", "{B557FCFE-015C-4A65-81B6-B4987E07BFB7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -150,6 +158,14 @@ Global {CEEE435F-32FC-4DE5-8735-90F6AC950A01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CEEE435F-32FC-4DE5-8735-90F6AC950A01}.Debug|Any CPU.Build.0 = Debug|Any CPU {CEEE435F-32FC-4DE5-8735-90F6AC950A01}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5658A5DF-EDEF-4561-9F0B-A37EEABC8135}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5658A5DF-EDEF-4561-9F0B-A37EEABC8135}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5658A5DF-EDEF-4561-9F0B-A37EEABC8135}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5658A5DF-EDEF-4561-9F0B-A37EEABC8135}.Release|Any CPU.Build.0 = Release|Any CPU + {B557FCFE-015C-4A65-81B6-B4987E07BFB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B557FCFE-015C-4A65-81B6-B4987E07BFB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B557FCFE-015C-4A65-81B6-B4987E07BFB7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B557FCFE-015C-4A65-81B6-B4987E07BFB7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -184,6 +200,10 @@ Global {275DEE86-1EEA-47C4-A9C5-797DF20EC8A7} = {3AA089A9-A035-439E-BAF6-C3975A334379} {8E25CDE3-992D-4942-8C38-51A0D8E8EB70} = {9C260BF0-1CCA-45A2-AAB8-6419291B8B88} {CEEE435F-32FC-4DE5-8735-90F6AC950A01} = {3AA089A9-A035-439E-BAF6-C3975A334379} + {2368415A-9C09-4F47-9636-FDCA4B85C88C} = {A48437BA-3C5B-431E-9B2F-96C850E9E1A5} + {22318FE4-1453-41BF-A38D-9401C4F16023} = {2368415A-9C09-4F47-9636-FDCA4B85C88C} + {5658A5DF-EDEF-4561-9F0B-A37EEABC8135} = {22318FE4-1453-41BF-A38D-9401C4F16023} + {B557FCFE-015C-4A65-81B6-B4987E07BFB7} = {22318FE4-1453-41BF-A38D-9401C4F16023} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AF61665D-340A-494B-9705-571456BDC752} diff --git a/iothub/device/samples/convention-based-samples/TemperatureController/Models/DeviceInformation.json b/iothub/device/samples/convention-based-samples/TemperatureController/Models/DeviceInformation.json new file mode 100644 index 0000000000..6d59180dc8 --- /dev/null +++ b/iothub/device/samples/convention-based-samples/TemperatureController/Models/DeviceInformation.json @@ -0,0 +1,64 @@ +{ + "@id": "dtmi:azure:DeviceManagement:DeviceInformation;1", + "@type": "Interface", + "displayName": "Device Information", + "contents": [ + { + "@type": "Property", + "name": "manufacturer", + "displayName": "Manufacturer", + "schema": "string", + "description": "Company name of the device manufacturer. This could be the same as the name of the original equipment manufacturer (OEM). Ex. Contoso." + }, + { + "@type": "Property", + "name": "model", + "displayName": "Device model", + "schema": "string", + "description": "Device model name or ID. Ex. Surface Book 2." + }, + { + "@type": "Property", + "name": "swVersion", + "displayName": "Software version", + "schema": "string", + "description": "Version of the software on your device. This could be the version of your firmware. Ex. 1.3.45" + }, + { + "@type": "Property", + "name": "osName", + "displayName": "Operating system name", + "schema": "string", + "description": "Name of the operating system on the device. Ex. Windows 10 IoT Core." + }, + { + "@type": "Property", + "name": "processorArchitecture", + "displayName": "Processor architecture", + "schema": "string", + "description": "Architecture of the processor on the device. Ex. x64 or ARM." + }, + { + "@type": "Property", + "name": "processorManufacturer", + "displayName": "Processor manufacturer", + "schema": "string", + "description": "Name of the manufacturer of the processor on the device. Ex. Intel." + }, + { + "@type": "Property", + "name": "totalStorage", + "displayName": "Total storage", + "schema": "double", + "description": "Total available storage on the device in kilobytes. Ex. 2048000 kilobytes." + }, + { + "@type": "Property", + "name": "totalMemory", + "displayName": "Total memory", + "schema": "double", + "description": "Total available memory on the device in kilobytes. Ex. 256000 kilobytes." + } + ], + "@context": "dtmi:dtdl:context;2" +} \ No newline at end of file diff --git a/iothub/device/samples/convention-based-samples/TemperatureController/Models/TemperatureController.json b/iothub/device/samples/convention-based-samples/TemperatureController/Models/TemperatureController.json new file mode 100644 index 0000000000..997b5cd34a --- /dev/null +++ b/iothub/device/samples/convention-based-samples/TemperatureController/Models/TemperatureController.json @@ -0,0 +1,83 @@ +{ + "@context": [ + "dtmi:iotcentral:context;2", + "dtmi:dtdl:context;2" + ], + "@id": "dtmi:com:example:TemperatureController;2", + "@type": "Interface", + "contents": [ + { + "@type": [ + "Telemetry", + "DataSize" + ], + "description": { + "en": "Current working set of the device memory in KiB." + }, + "displayName": { + "en": "Working Set" + }, + "name": "workingSet", + "schema": "double", + "unit": "kibibit" + }, + { + "@type": "Property", + "displayName": { + "en": "Serial Number" + }, + "name": "serialNumber", + "schema": "string", + "writable": false + }, + { + "@type": "Command", + "commandType": "synchronous", + "description": { + "en": "Reboots the device after waiting the number of seconds specified." + }, + "displayName": { + "en": "Reboot" + }, + "name": "reboot", + "request": { + "@type": "CommandPayload", + "description": { + "en": "Number of seconds to wait before rebooting the device." + }, + "displayName": { + "en": "Delay" + }, + "name": "delay", + "schema": "integer" + } + }, + { + "@type": "Component", + "displayName": { + "en": "thermostat1" + }, + "name": "thermostat1", + "schema": "dtmi:com:example:Thermostat;1" + }, + { + "@type": "Component", + "displayName": { + "en": "thermostat2" + }, + "name": "thermostat2", + "schema": "dtmi:com:example:Thermostat;2" + }, + { + "@type": "Component", + "displayName": { + "en": "DeviceInfo" + }, + "name": "deviceInformation", + "schema": "dtmi:azure:DeviceManagement:DeviceInformation;1" + } + ], + "displayName": { + "en": "Temperature Controller" + } + } \ No newline at end of file diff --git a/iothub/device/samples/convention-based-samples/TemperatureController/Models/Thermostat.json b/iothub/device/samples/convention-based-samples/TemperatureController/Models/Thermostat.json new file mode 100644 index 0000000000..46b21f85cb --- /dev/null +++ b/iothub/device/samples/convention-based-samples/TemperatureController/Models/Thermostat.json @@ -0,0 +1,89 @@ +{ + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:com:example:Thermostat;1", + "@type": "Interface", + "displayName": "Thermostat", + "description": "Reports current temperature and provides desired temperature control.", + "contents": [ + { + "@type": [ + "Telemetry", + "Temperature" + ], + "name": "temperature", + "displayName" : "Temperature", + "description" : "Temperature in degrees Celsius.", + "schema": "double", + "unit": "degreeCelsius" + }, + { + "@type": [ + "Property", + "Temperature" + ], + "name": "targetTemperature", + "schema": "double", + "displayName": "Target Temperature", + "description": "Allows to remotely specify the desired target temperature.", + "unit" : "degreeCelsius", + "writable": true + }, + { + "@type": [ + "Property", + "Temperature" + ], + "name": "maxTempSinceLastReboot", + "schema": "double", + "unit" : "degreeCelsius", + "displayName": "Max temperature since last reboot.", + "description": "Returns the max temperature since last device reboot." + }, + { + "@type": "Command", + "name": "getMaxMinReport", + "displayName": "Get Max-Min report.", + "description": "This command returns the max, min and average temperature from the specified time to the current time.", + "request": { + "name": "since", + "displayName": "Since", + "description": "Period to return the max-min report.", + "schema": "dateTime" + }, + "response": { + "name" : "tempReport", + "displayName": "Temperature Report", + "schema": { + "@type": "Object", + "fields": [ + { + "name": "maxTemp", + "displayName": "Max temperature", + "schema": "double" + }, + { + "name": "minTemp", + "displayName": "Min temperature", + "schema": "double" + }, + { + "name" : "avgTemp", + "displayName": "Average Temperature", + "schema": "double" + }, + { + "name" : "startTime", + "displayName": "Start Time", + "schema": "dateTime" + }, + { + "name" : "endTime", + "displayName": "End Time", + "schema": "dateTime" + } + ] + } + } + } + ] +} diff --git a/iothub/device/samples/convention-based-samples/TemperatureController/Models/Thermostat2.json b/iothub/device/samples/convention-based-samples/TemperatureController/Models/Thermostat2.json new file mode 100644 index 0000000000..2309b4a5d4 --- /dev/null +++ b/iothub/device/samples/convention-based-samples/TemperatureController/Models/Thermostat2.json @@ -0,0 +1,89 @@ +{ + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:com:example:Thermostat;2", + "@type": "Interface", + "displayName": "Thermostat", + "description": "Reports current temperature and provides desired temperature control.", + "contents": [ + { + "@type": [ + "Telemetry", + "Temperature" + ], + "name": "temperature", + "displayName" : "Temperature", + "description" : "Temperature in degrees Celsius.", + "schema": "double", + "unit": "degreeCelsius" + }, + { + "@type": [ + "Property", + "Temperature" + ], + "name": "targetTemperature", + "schema": "double", + "displayName": "Target Temperature", + "description": "Allows to remotely specify the desired target temperature.", + "unit" : "degreeCelsius", + "writable": true + }, + { + "@type": [ + "Property", + "Temperature" + ], + "name": "maxTempSinceLastReboot", + "schema": "double", + "unit" : "degreeCelsius", + "displayName": "Max temperature since last reboot.", + "description": "Returns the max temperature since last device reboot." + }, + { + "@type": "Command", + "name": "getMaxMinReport", + "displayName": "Get Max-Min report.", + "description": "This command returns the max, min and average temperature from the specified time to the current time.", + "request": { + "name": "since", + "displayName": "Since", + "description": "Period to return the max-min report.", + "schema": "dateTime" + }, + "response": { + "name" : "tempReport", + "displayName": "Temperature Report", + "schema": { + "@type": "Object", + "fields": [ + { + "name": "maxTemp", + "displayName": "Max temperature", + "schema": "double" + }, + { + "name": "minTemp", + "displayName": "Min temperature", + "schema": "double" + }, + { + "name" : "avgTemp", + "displayName": "Average Temperature", + "schema": "double" + }, + { + "name" : "startTime", + "displayName": "Start Time", + "schema": "dateTime" + }, + { + "name" : "endTime", + "displayName": "End Time", + "schema": "dateTime" + } + ] + } + } + } + ] +} diff --git a/iothub/device/samples/convention-based-samples/TemperatureController/Parameter.cs b/iothub/device/samples/convention-based-samples/TemperatureController/Parameter.cs new file mode 100644 index 0000000000..9f8f5b99ff --- /dev/null +++ b/iothub/device/samples/convention-based-samples/TemperatureController/Parameter.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using CommandLine; +using Microsoft.Extensions.Logging; +using System; + +namespace Microsoft.Azure.Devices.Client.Samples +{ + /// + /// Parameters for the application supplied via command line arguments. + /// If the parameter is not supplied via command line args, it will look for it in environment variables. + /// + internal class Parameters + { + [Option( + "DeviceSecurityType", + HelpText = "(Required) The flow that will be used for connecting the device for the sample. Possible case-insensitive values include: dps, connectionString." + + "\nDefaults to environment variable \"IOTHUB_DEVICE_SECURITY_TYPE\".")] + public string DeviceSecurityType { get; set; } = Environment.GetEnvironmentVariable("IOTHUB_DEVICE_SECURITY_TYPE"); + + [Option( + 'p', + "PrimaryConnectionString", + HelpText = "(Required if DeviceSecurityType is \"connectionString\"). \nThe primary connection string for the device to simulate." + + "\nDefaults to environment variable \"IOTHUB_DEVICE_CONNECTION_STRING\".")] + public string PrimaryConnectionString { get; set; } = Environment.GetEnvironmentVariable("IOTHUB_DEVICE_CONNECTION_STRING"); + + [Option( + 'e', + "DpsEndpoint", + HelpText = "(Required if DeviceSecurityType is \"dps\"). \nThe DPS endpoint to use during device provisioning." + + "\nDefaults to environment variable \"IOTHUB_DEVICE_DPS_ENDPOINT\".")] + public string DpsEndpoint { get; set; } = Environment.GetEnvironmentVariable("IOTHUB_DEVICE_DPS_ENDPOINT"); + + [Option( + 'i', + "DpsIdScope", + HelpText = "(Required if DeviceSecurityType is \"dps\"). \nThe DPS ID Scope to use during device provisioning." + + "\nDefaults to environment variable \"IOTHUB_DEVICE_DPS_ID_SCOPE\".")] + public string DpsIdScope { get; set; } = Environment.GetEnvironmentVariable("IOTHUB_DEVICE_DPS_ID_SCOPE"); + + [Option( + 'd', + "DeviceId", + HelpText = "(Required if DeviceSecurityType is \"dps\"). \nThe device registration Id to use during device provisioning." + + "\nDefaults to environment variable \"IOTHUB_DEVICE_DPS_DEVICE_ID\".")] + public string DeviceId { get; set; } = Environment.GetEnvironmentVariable("IOTHUB_DEVICE_DPS_DEVICE_ID"); + + [Option( + 'k', + "DeviceSymmetricKey", + HelpText = "(Required if DeviceSecurityType is \"dps\"). \nThe device symmetric key to use during device provisioning." + + "\nDefaults to environment variable \"IOTHUB_DEVICE_DPS_DEVICE_KEY\".")] + public string DeviceSymmetricKey { get; set; } = Environment.GetEnvironmentVariable("IOTHUB_DEVICE_DPS_DEVICE_KEY"); + + [Option( + 'r', + "Application running time (in seconds)", + Required = false, + HelpText = "The running time for this console application. Leave it unassigned to run the application until it is explicitly canceled using Control+C.")] + public double? ApplicationRunningTime { get; set; } + + public bool Validate(ILogger logger) + { + if (string.IsNullOrWhiteSpace(DeviceSecurityType)) + { + logger.LogWarning("Device provisioning type not set, please set the environment variable \"IOTHUB_DEVICE_SECURITY_TYPE\"" + + "or pass in \"-s | --DeviceSecurityType\" through command line. \nWill default to using \"dps\" flow."); + + DeviceSecurityType = "dps"; + } + + return (DeviceSecurityType.ToLowerInvariant()) switch + { + "dps" => !string.IsNullOrWhiteSpace(DpsEndpoint) + && !string.IsNullOrWhiteSpace(DpsIdScope) + && !string.IsNullOrWhiteSpace(DeviceId) + && !string.IsNullOrWhiteSpace(DeviceSymmetricKey), + "connectionstring" => !string.IsNullOrWhiteSpace(PrimaryConnectionString), + _ => throw new ArgumentException($"Unrecognized value for device provisioning received: {DeviceSecurityType}." + + $" It should be either \"dps\" or \"connectionString\" (case-insensitive)."), + }; + } + } +} diff --git a/iothub/device/samples/convention-based-samples/TemperatureController/Program.cs b/iothub/device/samples/convention-based-samples/TemperatureController/Program.cs new file mode 100644 index 0000000000..f31383627b --- /dev/null +++ b/iothub/device/samples/convention-based-samples/TemperatureController/Program.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using CommandLine; +using Microsoft.Azure.Devices.Provisioning.Client; +using Microsoft.Azure.Devices.Provisioning.Client.PlugAndPlay; +using Microsoft.Azure.Devices.Provisioning.Client.Transport; +using Microsoft.Azure.Devices.Shared; +using Microsoft.Extensions.Logging; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.Devices.Client.Samples +{ + public class Program + { + // DTDL interface used: https://github.com/Azure/iot-plugandplay-models/blob/main/dtmi/com/example/temperaturecontroller-2.json + // The TemperatureController model contains 2 Thermostat components that implement different versions of Thermostat models. + // Both Thermostat models are identical in definition but this is done to allow IoT Central to handle + // TemperatureController model correctly. + private const string ModelId = "dtmi:com:example:TemperatureController;2"; + + private static ILogger s_logger; + + public static async Task Main(string[] args) + { + // Parse application parameters + Parameters parameters = null; + ParserResult result = Parser.Default.ParseArguments(args) + .WithParsed(parsedParams => + { + parameters = parsedParams; + }) + .WithNotParsed(errors => + { + Environment.Exit(1); + }); + + s_logger = InitializeConsoleDebugLogger(); + if (!parameters.Validate(s_logger)) + { + throw new ArgumentException("Required parameters are not set. Please recheck required variables by using \"--help\""); + } + + var runningTime = parameters.ApplicationRunningTime != null + ? TimeSpan.FromSeconds((double)parameters.ApplicationRunningTime) + : Timeout.InfiniteTimeSpan; + + s_logger.LogInformation("Press Control+C to quit the sample."); + using var cts = new CancellationTokenSource(runningTime); + Console.CancelKeyPress += (sender, eventArgs) => + { + eventArgs.Cancel = true; + cts.Cancel(); + s_logger.LogInformation("Sample execution cancellation requested; will exit."); + }; + + s_logger.LogDebug($"Set up the device client."); + using DeviceClient deviceClient = await SetupDeviceClientAsync(parameters, cts.Token); + var sample = new TemperatureControllerSample(deviceClient, s_logger); + await sample.PerformOperationsAsync(cts.Token); + + // PerformOperationsAsync is designed to run until cancellation has been explicitly requested, either through + // cancellation token expiration or by Console.CancelKeyPress. + // As a result, by the time the control reaches the call to close the device client, the cancellation token source would + // have already had cancellation requested. + // Hence, if you want to pass a cancellation token to any subsequent calls, a new token needs to be generated. + // For device client APIs, you can also call them without a cancellation token, which will set a default + // cancellation timeout of 4 minutes: https://github.com/Azure/azure-iot-sdk-csharp/blob/64f6e9f24371bc40ab3ec7a8b8accbfb537f0fe1/iothub/device/src/InternalClient.cs#L1922 + await deviceClient.CloseAsync(); + } + + private static ILogger InitializeConsoleDebugLogger() + { + ILoggerFactory loggerFactory = LoggerFactory.Create(builder => + { + builder + .AddFilter(level => level >= LogLevel.Debug) + .AddConsole(options => + { + options.TimestampFormat = "[MM/dd/yyyy HH:mm:ss]"; + }); + }); + + return loggerFactory.CreateLogger(); + } + + private static async Task SetupDeviceClientAsync(Parameters parameters, CancellationToken cancellationToken) + { + DeviceClient deviceClient; + switch (parameters.DeviceSecurityType.ToLowerInvariant()) + { + case "dps": + s_logger.LogDebug($"Initializing via DPS"); + DeviceRegistrationResult dpsRegistrationResult = await ProvisionDeviceAsync(parameters, cancellationToken); + var authMethod = new DeviceAuthenticationWithRegistrySymmetricKey(dpsRegistrationResult.DeviceId, parameters.DeviceSymmetricKey); + deviceClient = InitializeDeviceClient(dpsRegistrationResult.AssignedHub, authMethod); + break; + + case "connectionstring": + s_logger.LogDebug($"Initializing via IoT Hub connection string"); + deviceClient = InitializeDeviceClient(parameters.PrimaryConnectionString); + break; + + default: + throw new ArgumentException($"Unrecognized value for device provisioning received: {parameters.DeviceSecurityType}." + + $" It should be either \"dps\" or \"connectionString\" (case-insensitive)."); + } + return deviceClient; + } + + // Provision a device via DPS, by sending the PnP model Id as DPS payload. + private static async Task ProvisionDeviceAsync(Parameters parameters, CancellationToken cancellationToken) + { + SecurityProvider symmetricKeyProvider = new SecurityProviderSymmetricKey(parameters.DeviceId, parameters.DeviceSymmetricKey, null); + ProvisioningTransportHandler mqttTransportHandler = new ProvisioningTransportHandlerMqtt(); + ProvisioningDeviceClient pdc = ProvisioningDeviceClient.Create(parameters.DpsEndpoint, parameters.DpsIdScope, symmetricKeyProvider, mqttTransportHandler); + + var pnpPayload = new ProvisioningRegistrationAdditionalData + { + JsonData = PnpConvention.CreateDpsPayload(ModelId), + }; + return await pdc.RegisterAsync(pnpPayload, cancellationToken); + } + + // Initialize the device client instance using connection string based authentication, over Mqtt protocol (TCP, with fallback over Websocket) and + // setting the ModelId into ClientOptions.This method also sets a connection status change callback, that will get triggered any time the device's + // connection status changes. + private static DeviceClient InitializeDeviceClient(string deviceConnectionString) + { + var options = new ClientOptions + { + ModelId = ModelId, + + // Specify a custom System.Text.Json based PayloadConvention to be used. + PayloadConvention = SystemTextJsonPayloadConvention.Instance, + }; + + DeviceClient deviceClient = DeviceClient.CreateFromConnectionString(deviceConnectionString, TransportType.Mqtt, options); + deviceClient.SetConnectionStatusChangesHandler((status, reason) => + { + s_logger.LogDebug($"Connection status change registered - status={status}, reason={reason}."); + }); + + return deviceClient; + } + + // Initialize the device client instance using symmetric key based authentication, over Mqtt protocol (TCP, with fallback over Websocket) + // and setting the ModelId into ClientOptions. This method also sets a connection status change callback, that will get triggered any time the device's connection status changes. + private static DeviceClient InitializeDeviceClient(string hostname, IAuthenticationMethod authenticationMethod) + { + var options = new ClientOptions + { + ModelId = ModelId, + }; + + DeviceClient deviceClient = DeviceClient.Create(hostname, authenticationMethod, TransportType.Mqtt, options); + deviceClient.SetConnectionStatusChangesHandler((status, reason) => + { + s_logger.LogDebug($"Connection status change registered - status={status}, reason={reason}."); + }); + + return deviceClient; + } + } +} diff --git a/iothub/device/samples/convention-based-samples/TemperatureController/Properties/launchSettings.template.json b/iothub/device/samples/convention-based-samples/TemperatureController/Properties/launchSettings.template.json new file mode 100644 index 0000000000..6ae8d6e736 --- /dev/null +++ b/iothub/device/samples/convention-based-samples/TemperatureController/Properties/launchSettings.template.json @@ -0,0 +1,22 @@ +{ + "profiles": { + "Hub": { + "commandName": "Project", + "environmentVariables": { + "IOTHUB_DEVICE_SECURITY_TYPE": "connectionString", + "IOTHUB_DEVICE_CONNECTION_STRING": "" + } + }, + "DPS": { + "commandName": "Project", + "environmentVariables": { + "IOTHUB_DEVICE_SECURITY_TYPE": "dps", + "IOTHUB_DEVICE_DPS_ID_SCOPE": "", + "IOTHUB_DEVICE_DPS_DEVICE_ID": "", + "IOTHUB_DEVICE_DPS_DEVICE_KEY": "", + "IOTHUB_DEVICE_DPS_ENDPOINT": "global.azure-devices-provisioning.net" + }, + "sqlDebugging": false + } + } +} \ No newline at end of file diff --git a/iothub/device/samples/convention-based-samples/TemperatureController/SystemTextJsonPayloadConvention.cs b/iothub/device/samples/convention-based-samples/TemperatureController/SystemTextJsonPayloadConvention.cs new file mode 100644 index 0000000000..02cced0002 --- /dev/null +++ b/iothub/device/samples/convention-based-samples/TemperatureController/SystemTextJsonPayloadConvention.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Azure.Devices.Shared; + +namespace Microsoft.Azure.Devices.Client.Samples +{ + /// + /// A that uses . + /// + public class SystemTextJsonPayloadConvention : PayloadConvention + { + public static readonly SystemTextJsonPayloadConvention Instance = new SystemTextJsonPayloadConvention(); + + public override PayloadSerializer PayloadSerializer { get; } = SystemTextJsonPayloadSerializer.Instance; + + public override PayloadEncoder PayloadEncoder { get; } = Utf8PayloadEncoder.Instance; + } +} diff --git a/shared/src/SystemTextJsonPayloadSerializer.cs b/iothub/device/samples/convention-based-samples/TemperatureController/SystemTextJsonPayloadSerializer.cs similarity index 93% rename from shared/src/SystemTextJsonPayloadSerializer.cs rename to iothub/device/samples/convention-based-samples/TemperatureController/SystemTextJsonPayloadSerializer.cs index 31602cbc6e..c3cad86935 100644 --- a/shared/src/SystemTextJsonPayloadSerializer.cs +++ b/iothub/device/samples/convention-based-samples/TemperatureController/SystemTextJsonPayloadSerializer.cs @@ -1,11 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -#if !NET451 - using System.Text.Json; +using Microsoft.Azure.Devices.Shared; -namespace Microsoft.Azure.Devices.Shared +namespace Microsoft.Azure.Devices.Client.Samples { /// /// A implementation. @@ -40,7 +39,7 @@ public override T DeserializeToType(string stringToDeserialize) /// public override T ConvertFromObject(object objectToConvert) { - return DeserializeToType(((JsonElement)objectToConvert).ToString()); + return DeserializeToType(SerializeToString(objectToConvert)); } /// @@ -66,5 +65,3 @@ public override IWritablePropertyResponse CreateWritablePropertyResponse(object } } } - -#endif diff --git a/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureController.csproj b/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureController.csproj new file mode 100644 index 0000000000..d0fbcbc34a --- /dev/null +++ b/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureController.csproj @@ -0,0 +1,19 @@ + + + + Exe + netcoreapp3.1 + $(MSBuildProjectDirectory)\..\..\..\..\.. + + + + + + + + + + + + + diff --git a/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureControllerSample.cs b/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureControllerSample.cs new file mode 100644 index 0000000000..fad873354b --- /dev/null +++ b/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureControllerSample.cs @@ -0,0 +1,407 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.Devices.Shared; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace Microsoft.Azure.Devices.Client.Samples +{ + public class TemperatureControllerSample + { + private const string Thermostat1 = "thermostat1"; + private const string Thermostat2 = "thermostat2"; + + private static readonly Random s_random = new Random(); + private static readonly TimeSpan s_sleepDuration = TimeSpan.FromSeconds(5); + + private readonly DeviceClient _deviceClient; + private readonly ILogger _logger; + + // Dictionary to hold the temperature updates sent over each "Thermostat" component. + // NOTE: Memory constrained devices should leverage storage capabilities of an external service to store this + // information and perform computation. + // See https://docs.microsoft.com/en-us/azure/event-grid/compare-messaging-services for more details. + private readonly Dictionary> _temperatureReadingsDateTimeOffset = + new Dictionary>(); + + // Dictionary to hold the current temperature for each "Thermostat" component. + private readonly Dictionary _temperature = new Dictionary(); + + // Dictionary to hold the max temperature since last reboot, for each "Thermostat" component. + private readonly Dictionary _maxTemp = new Dictionary(); + + public TemperatureControllerSample(DeviceClient deviceClient, ILogger logger) + { + _deviceClient = deviceClient ?? throw new ArgumentNullException($"{nameof(deviceClient)} cannot be null."); + _logger = logger ?? LoggerFactory.Create(builer => builer.AddConsole()).CreateLogger(); + } + + public async Task PerformOperationsAsync(CancellationToken cancellationToken) + { + // Set handler to receive and respond to writable property update requests. + _logger.LogDebug("Subscribe to writable property updates."); + await _deviceClient.SubscribeToWritablePropertiesEventAsync(HandlePropertyUpdatesAsync, null, cancellationToken); + + // Set handler to receive and respond to commands. + _logger.LogDebug($"Subscribe to commands."); + await _deviceClient.SubscribeToCommandsAsync(HandleCommandsAsync, null, cancellationToken); + + // Report device information on "deviceInformation" component. + // This is a component-level property update call. + await UpdateDeviceInformationPropertyAsync(cancellationToken); + + // Verify if the device has previously reported the current value for property "serialNumber". + // If the expected value has not been previously reported then send device serial number over property update. + // This is a root-level property update call. + await SendDeviceSerialNumberPropertyIfNotCurrentAsync(cancellationToken); + + bool temperatureReset = true; + _maxTemp[Thermostat1] = 0d; + _maxTemp[Thermostat2] = 0d; + + // Periodically send "temperature" over telemetry - on "Thermostat" components. + // Send "maxTempSinceLastReboot" over property update, when a new max temperature is reached - on "Thermostat" components. + while (!cancellationToken.IsCancellationRequested) + { + if (temperatureReset) + { + // Generate a random value between 5.0°C and 45.0°C for the current temperature reading for each "Thermostat" component. + _temperature[Thermostat1] = GenerateTemperatureWithinRange(45, 5); + _temperature[Thermostat2] = GenerateTemperatureWithinRange(45, 5); + } + + // Send temperature updates over telemetry and the value of max temperature since last reboot over property update. + // Both of these are component-level calls. + await SendTemperatureAsync(Thermostat1, cancellationToken); + await SendTemperatureAsync(Thermostat2, cancellationToken); + + // Send working set of device memory over telemetry. + // This is a root-level telemetry call. + await SendDeviceMemoryTelemetryAsync(cancellationToken); + + temperatureReset = _temperature[Thermostat1] == 0 && _temperature[Thermostat2] == 0; + await Task.Delay(s_sleepDuration); + } + } + + // The callback to handle property update requests. + private async Task HandlePropertyUpdatesAsync(ClientPropertyCollection writableProperties, object userContext) + { + foreach (KeyValuePair writableProperty in writableProperties) + { + // The dispatcher key will be either a root-level property name or a component name. + switch (writableProperty.Key) + { + case Thermostat1: + case Thermostat2: + const string targetTemperatureProperty = "targetTemperature"; + if (writableProperties.TryGetValue(writableProperty.Key, targetTemperatureProperty, out double targetTemperatureRequested)) + { + await HandleTargetTemperatureUpdateRequestAsync(writableProperty.Key, targetTemperatureRequested, writableProperties.Version, userContext); + break; + } + else + { + _logger.LogWarning($"Property: Received an unrecognized property update from service for component {writableProperty.Key}:" + + $"\n[ {writableProperty.Value} ]."); + break; + } + + default: + _logger.LogWarning($"Property: Received an unrecognized property update from service:" + + $"\n[ {writableProperty.Key}: {writableProperty.Value} ]."); + break; + } + } + } + + // The callback to handle target temperature property update requests for a component. + private async Task HandleTargetTemperatureUpdateRequestAsync(string componentName, double targetTemperature, long version, object userContext) + { + const string targetTemperatureProperty = "targetTemperature"; + _logger.LogDebug($"Property: Received - component=\"{componentName}\", [ \"{targetTemperatureProperty}\": {targetTemperature}°C ]."); + + _temperature[componentName] = targetTemperature; + var reportedProperty = new ClientPropertyCollection(); + reportedProperty.Add(componentName, targetTemperatureProperty, _temperature[componentName], StatusCodes.Accepted, version, "Successfully updated target temperature."); + + ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(reportedProperty); + + _logger.LogDebug($"Property: Update - component=\"{componentName}\", {reportedProperty.GetSerializedString()} is {nameof(StatusCodes.OK)} " + + $"with a version of {updateResponse.Version}."); + } + + // The callback to handle command invocation requests. + private Task HandleCommandsAsync(CommandRequest commandRequest, object userContext) + { + // In this approach, we'll first switch through the component name returned and handle each component-level command. + // For the "default" case, we'll first check if the component name is null. + // If null, then this would be a root-level command request, so we'll switch through each root-level command. + // If not null, then this is a component-level command that has not been implemented. + + // Switch through CommandRequest.ComponentName to handle all component-level commands. + switch (commandRequest.ComponentName) + { + case Thermostat1: + case Thermostat2: + // For each component, switch through CommandRequest.CommandName to handle the specific component-level command. + switch (commandRequest.CommandName) + { + case "getMaxMinReport": + return HandleMaxMinReportCommandAsync(commandRequest, userContext); + + default: + _logger.LogWarning($"Received a command request that isn't" + + $" implemented - component name = {commandRequest.ComponentName}, command name = {commandRequest.CommandName}"); + + return Task.FromResult(new CommandResponse(StatusCodes.NotFound)); + } + + // For the default case, first check if CommandRequest.ComponentName is null. + default: + // If CommandRequest.ComponentName is null, then this is a root-level command request. + if (commandRequest.ComponentName == null) + { + // Switch through CommandRequest.CommandName to handle all root-level commands. + switch (commandRequest.CommandName) + { + case "reboot": + return HandleRebootCommandAsync(commandRequest, userContext); + + default: + _logger.LogWarning($"Received a command request that isn't" + + $" implemented - command name = {commandRequest.CommandName}"); + + return Task.FromResult(new CommandResponse(StatusCodes.NotFound)); + } + } + else + { + _logger.LogWarning($"Received a command request that isn't" + + $" implemented - component name = {commandRequest.ComponentName}, command name = {commandRequest.CommandName}"); + + return Task.FromResult(new CommandResponse(StatusCodes.NotFound)); + } + } + } + + // The callback to handle root-level "reboot" command. + // This method will send a temperature update (of 0°C) over telemetry for both associated components. + private async Task HandleRebootCommandAsync(CommandRequest commandRequest, object userContext) + { + try + { + int delay = commandRequest.GetData(); + + _logger.LogDebug($"Command: Received - Rebooting thermostat (resetting temperature reading to 0°C after {delay} seconds)."); + await Task.Delay(delay * 1000); + + _temperature[Thermostat1] = _maxTemp[Thermostat1] = 0; + _temperature[Thermostat2] = _maxTemp[Thermostat2] = 0; + + _temperatureReadingsDateTimeOffset.Clear(); + _logger.LogDebug($"Command: Reboot completed."); + + return new CommandResponse(StatusCodes.OK); + } + catch (JsonReaderException ex) + { + _logger.LogDebug($"Command input for {commandRequest.CommandName} is invalid: {ex.Message}."); + return new CommandResponse(StatusCodes.BadRequest); + } + } + + // The callback to handle component-level "getMaxMinReport" command. + // This method will returns the max, min and average temperature from the specified time to the current time. + private Task HandleMaxMinReportCommandAsync(CommandRequest commandRequest, object userContext) + { + try + { + DateTimeOffset sinceInUtc = commandRequest.GetData(); + _logger.LogDebug($"Command: Received - Generating max, min and avg temperature report since " + + $"{sinceInUtc.LocalDateTime}."); + + if (_temperatureReadingsDateTimeOffset.ContainsKey(commandRequest.ComponentName)) + { + Dictionary allReadings = _temperatureReadingsDateTimeOffset[commandRequest.ComponentName]; + Dictionary filteredReadings = allReadings.Where(i => i.Key > sinceInUtc) + .ToDictionary(i => i.Key, i => i.Value); + + if (filteredReadings != null && filteredReadings.Any()) + { + var report = new TemperatureReport + { + MaximumTemperature = filteredReadings.Values.Max(), + MinimumTemperature = filteredReadings.Values.Min(), + AverageTemperature = filteredReadings.Values.Average(), + StartTime = filteredReadings.Keys.Min(), + EndTime = filteredReadings.Keys.Max(), + }; + + _logger.LogDebug($"Command: component=\"{commandRequest.ComponentName}\", MaxMinReport since {sinceInUtc.LocalDateTime}:" + + $" maxTemp={report.MaximumTemperature}, minTemp={report.MinimumTemperature}, avgTemp={report.AverageTemperature}, " + + $"startTime={report.StartTime.LocalDateTime}, endTime={report.EndTime.LocalDateTime}"); + + return Task.FromResult(new CommandResponse(report, StatusCodes.OK)); + } + + _logger.LogDebug($"Command: component=\"{commandRequest.ComponentName}\"," + + $" no relevant readings found since {sinceInUtc.LocalDateTime}, cannot generate any report."); + + return Task.FromResult(new CommandResponse(StatusCodes.NotFound)); + } + + _logger.LogDebug($"Command: component=\"{commandRequest.ComponentName}\", no temperature readings sent yet," + + $" cannot generate any report."); + + return Task.FromResult(new CommandResponse(StatusCodes.NotFound)); + } + catch (JsonReaderException ex) + { + _logger.LogError($"Command input for {commandRequest.CommandName} is invalid: {ex.Message}."); + + return Task.FromResult(new CommandResponse(StatusCodes.BadRequest)); + } + } + + // Report the property values on "deviceInformation" component. + // This is a component-level property update call. + private async Task UpdateDeviceInformationPropertyAsync(CancellationToken cancellationToken) + { + const string componentName = "deviceInformation"; + var deviceInformationProperties = new Dictionary + { + { "manufacturer", "element15" }, + { "model", "ModelIDxcdvmk" }, + { "swVersion", "1.0.0" }, + { "osName", "Windows 10" }, + { "processorArchitecture", "64-bit" }, + { "processorManufacturer", "Intel" }, + { "totalStorage", 256 }, + { "totalMemory", 1024 }, + }; + var deviceInformation = new ClientPropertyCollection(); + deviceInformation.Add(componentName, deviceInformationProperties); + + ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(deviceInformation, cancellationToken); + + _logger.LogDebug($"Property: Update - component = '{componentName}', properties update is complete " + + $"with a version of {updateResponse.Version}."); + } + + // Send working set of device memory over telemetry. + // This is a root-level telemetry call. + private async Task SendDeviceMemoryTelemetryAsync(CancellationToken cancellationToken) + { + const string workingSetName = "workingSet"; + long workingSet = Process.GetCurrentProcess().PrivateMemorySize64 / 1024; + using var telemetryMessage = new TelemetryMessage + { + Telemetry = { [workingSetName] = workingSet }, + }; + + await _deviceClient.SendTelemetryAsync(telemetryMessage, cancellationToken); + + _logger.LogDebug($"Telemetry: Sent - {telemetryMessage.Telemetry.GetSerializedString()} in KB."); + } + + // Verify if the device has previously reported the current value for property "serialNumber". + // If the expected value has not been previously reported then send device serial number over property update. + // This is a root-level property update call. + private async Task SendDeviceSerialNumberPropertyIfNotCurrentAsync(CancellationToken cancellationToken) + { + const string serialNumber = "serialNumber"; + const string currentSerialNumber = "SR-123456"; + + // Verify if the device has previously reported the current value for property "serialNumber". + // If the expected value has not been previously reported then report it. + + // Retrieve the device's properties. + ClientProperties properties = await _deviceClient.GetClientPropertiesAsync(cancellationToken); + + if (!properties.TryGetValue(serialNumber, out string serialNumberReported) + || serialNumberReported != currentSerialNumber) + { + var reportedProperties = new ClientPropertyCollection(); + reportedProperties.Add(serialNumber, currentSerialNumber); + + ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(reportedProperties, cancellationToken); + + _logger.LogDebug($"Property: Update - {reportedProperties.GetSerializedString()} is complete " + + $"with a version of {updateResponse.Version}."); + } + } + + // Send temperature updates over telemetry. + // This also sends the value of max temperature since last reboot over property update. + private async Task SendTemperatureAsync(string componentName, CancellationToken cancellationToken) + { + await SendTemperatureTelemetryAsync(componentName, cancellationToken); + + double maxTemp = _temperatureReadingsDateTimeOffset[componentName].Values.Max(); + if (maxTemp > _maxTemp[componentName]) + { + _maxTemp[componentName] = maxTemp; + await UpdateMaxTemperatureSinceLastRebootAsync(componentName, cancellationToken); + } + } + + // Send temperature update over telemetry. + // This is a component-level telemetry call. + private async Task SendTemperatureTelemetryAsync(string componentName, CancellationToken cancellationToken) + { + const string telemetryName = "temperature"; + double currentTemperature = _temperature[componentName]; + + using var telemtryMessage = new TelemetryMessage(componentName) + { + Telemetry = { [telemetryName] = currentTemperature }, + }; + + await _deviceClient.SendTelemetryAsync(telemtryMessage, cancellationToken); + + _logger.LogDebug($"Telemetry: Sent - component=\"{componentName}\", {telemtryMessage.Telemetry.GetSerializedString()} in °C."); + + if (_temperatureReadingsDateTimeOffset.ContainsKey(componentName)) + { + _temperatureReadingsDateTimeOffset[componentName].TryAdd(DateTimeOffset.UtcNow, currentTemperature); + } + else + { + _temperatureReadingsDateTimeOffset.TryAdd( + componentName, + new Dictionary + { + { DateTimeOffset.UtcNow, currentTemperature }, + }); + } + } + + // Send temperature over reported property update. + // This is a component-level property update. + private async Task UpdateMaxTemperatureSinceLastRebootAsync(string componentName, CancellationToken cancellationToken) + { + const string propertyName = "maxTempSinceLastReboot"; + double maxTemp = _maxTemp[componentName]; + var reportedProperties = new ClientPropertyCollection(); + reportedProperties.Add(componentName, propertyName, maxTemp); + + ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(reportedProperties, cancellationToken); + + _logger.LogDebug($"Property: Update - component=\"{componentName}\", {reportedProperties.GetSerializedString()}" + + $" in °C is complete with a version of {updateResponse.Version}."); + } + + private static double GenerateTemperatureWithinRange(int max = 50, int min = 0) + { + return Math.Round(s_random.NextDouble() * (max - min) + min, 1); + } + } +} diff --git a/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureReport.cs b/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureReport.cs new file mode 100644 index 0000000000..f7b25e9ec7 --- /dev/null +++ b/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureReport.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Text.Json.Serialization; + +namespace Microsoft.Azure.Devices.Client.Samples +{ + public class TemperatureReport + { + [JsonPropertyName("maxTemp")] + public double MaximumTemperature { get; set; } + + [JsonPropertyName("minTemp")] + public double MinimumTemperature { get; set; } + + [JsonPropertyName("avgTemp")] + public double AverageTemperature { get; set; } + + [JsonPropertyName("startTime")] + public DateTimeOffset StartTime { get; set; } + + [JsonPropertyName("endTime")] + public DateTimeOffset EndTime { get; set; } + } +} diff --git a/iothub/device/samples/convention-based-samples/Thermostat/Models/Thermostat.json b/iothub/device/samples/convention-based-samples/Thermostat/Models/Thermostat.json new file mode 100644 index 0000000000..46b21f85cb --- /dev/null +++ b/iothub/device/samples/convention-based-samples/Thermostat/Models/Thermostat.json @@ -0,0 +1,89 @@ +{ + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:com:example:Thermostat;1", + "@type": "Interface", + "displayName": "Thermostat", + "description": "Reports current temperature and provides desired temperature control.", + "contents": [ + { + "@type": [ + "Telemetry", + "Temperature" + ], + "name": "temperature", + "displayName" : "Temperature", + "description" : "Temperature in degrees Celsius.", + "schema": "double", + "unit": "degreeCelsius" + }, + { + "@type": [ + "Property", + "Temperature" + ], + "name": "targetTemperature", + "schema": "double", + "displayName": "Target Temperature", + "description": "Allows to remotely specify the desired target temperature.", + "unit" : "degreeCelsius", + "writable": true + }, + { + "@type": [ + "Property", + "Temperature" + ], + "name": "maxTempSinceLastReboot", + "schema": "double", + "unit" : "degreeCelsius", + "displayName": "Max temperature since last reboot.", + "description": "Returns the max temperature since last device reboot." + }, + { + "@type": "Command", + "name": "getMaxMinReport", + "displayName": "Get Max-Min report.", + "description": "This command returns the max, min and average temperature from the specified time to the current time.", + "request": { + "name": "since", + "displayName": "Since", + "description": "Period to return the max-min report.", + "schema": "dateTime" + }, + "response": { + "name" : "tempReport", + "displayName": "Temperature Report", + "schema": { + "@type": "Object", + "fields": [ + { + "name": "maxTemp", + "displayName": "Max temperature", + "schema": "double" + }, + { + "name": "minTemp", + "displayName": "Min temperature", + "schema": "double" + }, + { + "name" : "avgTemp", + "displayName": "Average Temperature", + "schema": "double" + }, + { + "name" : "startTime", + "displayName": "Start Time", + "schema": "dateTime" + }, + { + "name" : "endTime", + "displayName": "End Time", + "schema": "dateTime" + } + ] + } + } + } + ] +} diff --git a/iothub/device/samples/convention-based-samples/Thermostat/Parameter.cs b/iothub/device/samples/convention-based-samples/Thermostat/Parameter.cs new file mode 100644 index 0000000000..9f8f5b99ff --- /dev/null +++ b/iothub/device/samples/convention-based-samples/Thermostat/Parameter.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using CommandLine; +using Microsoft.Extensions.Logging; +using System; + +namespace Microsoft.Azure.Devices.Client.Samples +{ + /// + /// Parameters for the application supplied via command line arguments. + /// If the parameter is not supplied via command line args, it will look for it in environment variables. + /// + internal class Parameters + { + [Option( + "DeviceSecurityType", + HelpText = "(Required) The flow that will be used for connecting the device for the sample. Possible case-insensitive values include: dps, connectionString." + + "\nDefaults to environment variable \"IOTHUB_DEVICE_SECURITY_TYPE\".")] + public string DeviceSecurityType { get; set; } = Environment.GetEnvironmentVariable("IOTHUB_DEVICE_SECURITY_TYPE"); + + [Option( + 'p', + "PrimaryConnectionString", + HelpText = "(Required if DeviceSecurityType is \"connectionString\"). \nThe primary connection string for the device to simulate." + + "\nDefaults to environment variable \"IOTHUB_DEVICE_CONNECTION_STRING\".")] + public string PrimaryConnectionString { get; set; } = Environment.GetEnvironmentVariable("IOTHUB_DEVICE_CONNECTION_STRING"); + + [Option( + 'e', + "DpsEndpoint", + HelpText = "(Required if DeviceSecurityType is \"dps\"). \nThe DPS endpoint to use during device provisioning." + + "\nDefaults to environment variable \"IOTHUB_DEVICE_DPS_ENDPOINT\".")] + public string DpsEndpoint { get; set; } = Environment.GetEnvironmentVariable("IOTHUB_DEVICE_DPS_ENDPOINT"); + + [Option( + 'i', + "DpsIdScope", + HelpText = "(Required if DeviceSecurityType is \"dps\"). \nThe DPS ID Scope to use during device provisioning." + + "\nDefaults to environment variable \"IOTHUB_DEVICE_DPS_ID_SCOPE\".")] + public string DpsIdScope { get; set; } = Environment.GetEnvironmentVariable("IOTHUB_DEVICE_DPS_ID_SCOPE"); + + [Option( + 'd', + "DeviceId", + HelpText = "(Required if DeviceSecurityType is \"dps\"). \nThe device registration Id to use during device provisioning." + + "\nDefaults to environment variable \"IOTHUB_DEVICE_DPS_DEVICE_ID\".")] + public string DeviceId { get; set; } = Environment.GetEnvironmentVariable("IOTHUB_DEVICE_DPS_DEVICE_ID"); + + [Option( + 'k', + "DeviceSymmetricKey", + HelpText = "(Required if DeviceSecurityType is \"dps\"). \nThe device symmetric key to use during device provisioning." + + "\nDefaults to environment variable \"IOTHUB_DEVICE_DPS_DEVICE_KEY\".")] + public string DeviceSymmetricKey { get; set; } = Environment.GetEnvironmentVariable("IOTHUB_DEVICE_DPS_DEVICE_KEY"); + + [Option( + 'r', + "Application running time (in seconds)", + Required = false, + HelpText = "The running time for this console application. Leave it unassigned to run the application until it is explicitly canceled using Control+C.")] + public double? ApplicationRunningTime { get; set; } + + public bool Validate(ILogger logger) + { + if (string.IsNullOrWhiteSpace(DeviceSecurityType)) + { + logger.LogWarning("Device provisioning type not set, please set the environment variable \"IOTHUB_DEVICE_SECURITY_TYPE\"" + + "or pass in \"-s | --DeviceSecurityType\" through command line. \nWill default to using \"dps\" flow."); + + DeviceSecurityType = "dps"; + } + + return (DeviceSecurityType.ToLowerInvariant()) switch + { + "dps" => !string.IsNullOrWhiteSpace(DpsEndpoint) + && !string.IsNullOrWhiteSpace(DpsIdScope) + && !string.IsNullOrWhiteSpace(DeviceId) + && !string.IsNullOrWhiteSpace(DeviceSymmetricKey), + "connectionstring" => !string.IsNullOrWhiteSpace(PrimaryConnectionString), + _ => throw new ArgumentException($"Unrecognized value for device provisioning received: {DeviceSecurityType}." + + $" It should be either \"dps\" or \"connectionString\" (case-insensitive)."), + }; + } + } +} diff --git a/iothub/device/samples/convention-based-samples/Thermostat/Program.cs b/iothub/device/samples/convention-based-samples/Thermostat/Program.cs new file mode 100644 index 0000000000..69dff54071 --- /dev/null +++ b/iothub/device/samples/convention-based-samples/Thermostat/Program.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using CommandLine; +using Microsoft.Azure.Devices.Provisioning.Client; +using Microsoft.Azure.Devices.Provisioning.Client.Transport; +using Microsoft.Azure.Devices.Shared; +using Microsoft.Extensions.Logging; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.Devices.Client.Samples +{ + public class Program + { + // DTDL interface used: https://github.com/Azure/iot-plugandplay-models/blob/main/dtmi/com/example/thermostat-1.json + private const string ModelId = "dtmi:com:example:Thermostat;1"; + + private static ILogger s_logger; + + public static async Task Main(string[] args) + { + // Parse application parameters + Parameters parameters = null; + ParserResult result = Parser.Default.ParseArguments(args) + .WithParsed(parsedParams => + { + parameters = parsedParams; + }) + .WithNotParsed(errors => + { + Environment.Exit(1); + }); + + s_logger = InitializeConsoleDebugLogger(); + if (!parameters.Validate(s_logger)) + { + throw new ArgumentException("Required parameters are not set. Please recheck required variables by using \"--help\""); + } + + var runningTime = parameters.ApplicationRunningTime != null + ? TimeSpan.FromSeconds((double)parameters.ApplicationRunningTime) + : Timeout.InfiniteTimeSpan; + + s_logger.LogInformation("Press Control+C to quit the sample."); + using var cts = new CancellationTokenSource(runningTime); + Console.CancelKeyPress += (sender, eventArgs) => + { + eventArgs.Cancel = true; + cts.Cancel(); + s_logger.LogInformation("Sample execution cancellation requested; will exit."); + }; + + s_logger.LogDebug($"Set up the device client."); + using DeviceClient deviceClient = await SetupDeviceClientAsync(parameters, cts.Token); + var sample = new ThermostatSample(deviceClient, s_logger); + await sample.PerformOperationsAsync(cts.Token); + + // PerformOperationsAsync is designed to run until cancellation has been explicitly requested, either through + // cancellation token expiration or by Console.CancelKeyPress. + // As a result, by the time the control reaches the call to close the device client, the cancellation token source would + // have already had cancellation requested. + // Hence, if you want to pass a cancellation token to any subsequent calls, a new token needs to be generated. + // For device client APIs, you can also call them without a cancellation token, which will set a default + // cancellation timeout of 4 minutes: https://github.com/Azure/azure-iot-sdk-csharp/blob/64f6e9f24371bc40ab3ec7a8b8accbfb537f0fe1/iothub/device/src/InternalClient.cs#L1922 + await deviceClient.CloseAsync(); + } + + private static ILogger InitializeConsoleDebugLogger() + { + ILoggerFactory loggerFactory = LoggerFactory.Create(builder => + { + builder + .AddFilter(level => level >= LogLevel.Debug) + .AddConsole(options => + { + options.TimestampFormat = "[MM/dd/yyyy HH:mm:ss]"; + }); + }); + + return loggerFactory.CreateLogger(); + } + + private static async Task SetupDeviceClientAsync(Parameters parameters, CancellationToken cancellationToken) + { + DeviceClient deviceClient; + switch (parameters.DeviceSecurityType.ToLowerInvariant()) + { + case "dps": + s_logger.LogDebug($"Initializing via DPS"); + DeviceRegistrationResult dpsRegistrationResult = await ProvisionDeviceAsync(parameters, cancellationToken); + var authMethod = new DeviceAuthenticationWithRegistrySymmetricKey(dpsRegistrationResult.DeviceId, parameters.DeviceSymmetricKey); + deviceClient = InitializeDeviceClient(dpsRegistrationResult.AssignedHub, authMethod); + break; + + case "connectionstring": + s_logger.LogDebug($"Initializing via IoT Hub connection string"); + deviceClient = InitializeDeviceClient(parameters.PrimaryConnectionString); + break; + + default: + throw new ArgumentException($"Unrecognized value for device provisioning received: {parameters.DeviceSecurityType}." + + $" It should be either \"dps\" or \"connectionString\" (case-insensitive)."); + } + + return deviceClient; + } + + // Provision a device via DPS, by sending the PnP model Id as DPS payload. + private static async Task ProvisionDeviceAsync(Parameters parameters, CancellationToken cancellationToken) + { + SecurityProvider symmetricKeyProvider = new SecurityProviderSymmetricKey(parameters.DeviceId, parameters.DeviceSymmetricKey, null); + ProvisioningTransportHandler mqttTransportHandler = new ProvisioningTransportHandlerMqtt(); + ProvisioningDeviceClient pdc = ProvisioningDeviceClient.Create(parameters.DpsEndpoint, parameters.DpsIdScope, + symmetricKeyProvider, mqttTransportHandler); + + var pnpPayload = new ProvisioningRegistrationAdditionalData + { + JsonData = $"{{ \"modelId\": \"{ModelId}\" }}", + }; + return await pdc.RegisterAsync(pnpPayload, cancellationToken); + } + + // Initialize the device client instance using connection string based authentication, over Mqtt protocol (TCP, with fallback over Websocket) + // and setting the ModelId into ClientOptions. + // This method also sets a connection status change callback, that will get triggered any time the device's connection status changes. + private static DeviceClient InitializeDeviceClient(string deviceConnectionString) + { + var options = new ClientOptions + { + ModelId = ModelId, + }; + + DeviceClient deviceClient = DeviceClient.CreateFromConnectionString(deviceConnectionString, TransportType.Mqtt, options); + deviceClient.SetConnectionStatusChangesHandler((status, reason) => + { + s_logger.LogDebug($"Connection status change registered - status={status}, reason={reason}."); + }); + + return deviceClient; + } + + // Initialize the device client instance using symmetric key based authentication, over Mqtt protocol (TCP, with fallback over Websocket) and setting the ModelId into ClientOptions. + // This method also sets a connection status change callback, that will get triggered any time the device's connection status changes. + private static DeviceClient InitializeDeviceClient(string hostname, IAuthenticationMethod authenticationMethod) + { + var options = new ClientOptions + { + ModelId = ModelId, + }; + + DeviceClient deviceClient = DeviceClient.Create(hostname, authenticationMethod, TransportType.Mqtt, options); + deviceClient.SetConnectionStatusChangesHandler((status, reason) => + { + s_logger.LogDebug($"Connection status change registered - status={status}, reason={reason}."); + }); + + return deviceClient; + } + } +} diff --git a/iothub/device/samples/convention-based-samples/Thermostat/Properties/launchSettings.template.json b/iothub/device/samples/convention-based-samples/Thermostat/Properties/launchSettings.template.json new file mode 100644 index 0000000000..6ae8d6e736 --- /dev/null +++ b/iothub/device/samples/convention-based-samples/Thermostat/Properties/launchSettings.template.json @@ -0,0 +1,22 @@ +{ + "profiles": { + "Hub": { + "commandName": "Project", + "environmentVariables": { + "IOTHUB_DEVICE_SECURITY_TYPE": "connectionString", + "IOTHUB_DEVICE_CONNECTION_STRING": "" + } + }, + "DPS": { + "commandName": "Project", + "environmentVariables": { + "IOTHUB_DEVICE_SECURITY_TYPE": "dps", + "IOTHUB_DEVICE_DPS_ID_SCOPE": "", + "IOTHUB_DEVICE_DPS_DEVICE_ID": "", + "IOTHUB_DEVICE_DPS_DEVICE_KEY": "", + "IOTHUB_DEVICE_DPS_ENDPOINT": "global.azure-devices-provisioning.net" + }, + "sqlDebugging": false + } + } +} \ No newline at end of file diff --git a/iothub/device/samples/convention-based-samples/Thermostat/TemperatureReport.cs b/iothub/device/samples/convention-based-samples/Thermostat/TemperatureReport.cs new file mode 100644 index 0000000000..44996a7961 --- /dev/null +++ b/iothub/device/samples/convention-based-samples/Thermostat/TemperatureReport.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using Newtonsoft.Json; + +namespace Microsoft.Azure.Devices.Client.Samples +{ + public class TemperatureReport + { + [JsonProperty("maxTemp")] + public double MaximumTemperature { get; set; } + + [JsonProperty("minTemp")] + public double MinimumTemperature { get; set; } + + [JsonProperty("avgTemp")] + public double AverageTemperature { get; set; } + + [JsonProperty("startTime")] + public DateTimeOffset StartTime { get; set; } + + [JsonProperty("endTime")] + public DateTimeOffset EndTime { get; set; } + } +} diff --git a/iothub/device/samples/convention-based-samples/Thermostat/Thermostat.csproj b/iothub/device/samples/convention-based-samples/Thermostat/Thermostat.csproj new file mode 100644 index 0000000000..d0fbcbc34a --- /dev/null +++ b/iothub/device/samples/convention-based-samples/Thermostat/Thermostat.csproj @@ -0,0 +1,19 @@ + + + + Exe + netcoreapp3.1 + $(MSBuildProjectDirectory)\..\..\..\..\.. + + + + + + + + + + + + + diff --git a/iothub/device/samples/convention-based-samples/Thermostat/ThermostatSample.cs b/iothub/device/samples/convention-based-samples/Thermostat/ThermostatSample.cs new file mode 100644 index 0000000000..73d6e0c072 --- /dev/null +++ b/iothub/device/samples/convention-based-samples/Thermostat/ThermostatSample.cs @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.Devices.Shared; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace Microsoft.Azure.Devices.Client.Samples +{ + public class ThermostatSample + { + private static readonly Random s_random = new Random(); + private static readonly TimeSpan s_sleepDuration = TimeSpan.FromSeconds(5); + + private double _temperature = 0d; + private double _maxTemp = 0d; + + // Dictionary to hold the temperature updates sent over. + // NOTE: Memory constrained devices should leverage storage capabilities of an external service to store this information and perform computation. + // See https://docs.microsoft.com/en-us/azure/event-grid/compare-messaging-services for more details. + private readonly Dictionary _temperatureReadingsDateTimeOffset = new Dictionary(); + + private readonly DeviceClient _deviceClient; + private readonly ILogger _logger; + + public ThermostatSample(DeviceClient deviceClient, ILogger logger) + { + _deviceClient = deviceClient ?? throw new ArgumentNullException($"{nameof(deviceClient)} cannot be null."); + _logger = logger ?? LoggerFactory.Create(builer => builer.AddConsole()).CreateLogger(); + } + + public async Task PerformOperationsAsync(CancellationToken cancellationToken) + { + // Set handler to receive and respond to writable property update requests. + _logger.LogDebug($"Subscribe to writable property updates."); + await _deviceClient.SubscribeToWritablePropertiesEventAsync(HandlePropertyUpdatesAsync, null, cancellationToken); + + // Set handler to receive and respond to commands. + _logger.LogDebug($"Subscribe to commands."); + await _deviceClient.SubscribeToCommandsAsync(HandleCommandsAsync, null, cancellationToken); + + bool temperatureReset = true; + + // Periodically send "temperature" over telemetry. + // Send "maxTempSinceLastReboot" over property update, when a new max temperature is reached. + while (!cancellationToken.IsCancellationRequested) + { + if (temperatureReset) + { + // Generate a random value between 5.0°C and 45.0°C for the current temperature reading. + _temperature = GenerateTemperatureWithinRange(45, 5); + temperatureReset = false; + } + + // Send temperature updates over telemetry and the value of max temperature since last reboot over property update. + await SendTemperatureAsync(); + + await Task.Delay(s_sleepDuration); + } + } + + // The callback to handle property update requests. + private async Task HandlePropertyUpdatesAsync(ClientPropertyCollection writableProperties, object userContext) + { + foreach (KeyValuePair writableProperty in writableProperties) + { + switch (writableProperty.Key) + { + case "targetTemperature": + const string tagetTemperatureProperty = "targetTemperature"; + double targetTemperatureRequested = Convert.ToDouble(writableProperty.Value); + _logger.LogDebug($"Property: Received - [ \"{tagetTemperatureProperty}\": {targetTemperatureRequested}°C ]."); + + _temperature = targetTemperatureRequested; + var reportedProperty = new ClientPropertyCollection(); + reportedProperty.Add(tagetTemperatureProperty, _temperature, StatusCodes.OK, writableProperties.Version, "Successfully updated target temperature"); + + ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(reportedProperty); + + _logger.LogDebug($"Property: Update - {reportedProperty.GetSerializedString()} is {nameof(StatusCodes.OK)} " + + $"with a version of {updateResponse.Version}."); + + break; + + default: + _logger.LogWarning($"Property: Received an unrecognized property update from service:\n[ {writableProperty.Key}: {writableProperty.Value} ]."); + break; + } + } + } + + // The callback to handle command invocation requests. + private Task HandleCommandsAsync(CommandRequest commandRequest, object userContext) + { + // In this approach, we'll switch through the command name returned and handle each root-level command. + switch (commandRequest.CommandName) + { + case "getMaxMinReport": + try + { + DateTimeOffset sinceInUtc = commandRequest.GetData(); + _logger.LogDebug($"Command: Received - Generating max, min and avg temperature report since " + + $"{sinceInUtc.LocalDateTime}."); + + Dictionary filteredReadings = _temperatureReadingsDateTimeOffset + .Where(i => i.Key > sinceInUtc) + .ToDictionary(i => i.Key, i => i.Value); + + if (filteredReadings != null && filteredReadings.Any()) + { + var report = new TemperatureReport + { + MaximumTemperature = filteredReadings.Values.Max(), + MinimumTemperature = filteredReadings.Values.Min(), + AverageTemperature = filteredReadings.Values.Average(), + StartTime = filteredReadings.Keys.Min(), + EndTime = filteredReadings.Keys.Max(), + }; + + _logger.LogDebug($"Command: MaxMinReport since {sinceInUtc.LocalDateTime}:" + + $" maxTemp={report.MaximumTemperature}, minTemp={report.MinimumTemperature}, avgTemp={report.AverageTemperature}, " + + $"startTime={report.StartTime.LocalDateTime}, endTime={report.EndTime.LocalDateTime}"); + + return Task.FromResult(new CommandResponse(report, StatusCodes.OK)); + } + + _logger.LogDebug($"Command: No relevant readings found since {sinceInUtc.LocalDateTime}, cannot generate any report."); + + return Task.FromResult(new CommandResponse(StatusCodes.NotFound)); + } + catch (JsonReaderException ex) + { + _logger.LogError($"Command input for {commandRequest.CommandName} is invalid: {ex.Message}."); + + return Task.FromResult(new CommandResponse(StatusCodes.BadRequest)); + } + + default: + _logger.LogWarning($"Received a command request that isn't" + + $" implemented - command name = {commandRequest.CommandName}"); + + return Task.FromResult(new CommandResponse(StatusCodes.NotFound)); + } + } + + // Send temperature updates over telemetry. + // This also sends the value of max temperature since last reboot over property update. + private async Task SendTemperatureAsync() + { + await SendTemperatureTelemetryAsync(); + + double maxTemp = _temperatureReadingsDateTimeOffset.Values.Max(); + if (maxTemp > _maxTemp) + { + _maxTemp = maxTemp; + await UpdateMaxTemperatureSinceLastRebootPropertyAsync(); + } + } + + // Send temperature update over telemetry. + private async Task SendTemperatureTelemetryAsync() + { + const string telemetryName = "temperature"; + + using var telemetryMessage = new TelemetryMessage + { + Telemetry = { [telemetryName] = _temperature } + }; + await _deviceClient.SendTelemetryAsync(telemetryMessage); + + _logger.LogDebug($"Telemetry: Sent - {telemetryMessage.Telemetry.GetSerializedString()}."); + _temperatureReadingsDateTimeOffset.Add(DateTimeOffset.Now, _temperature); + } + + // Send temperature over reported property update. + private async Task UpdateMaxTemperatureSinceLastRebootPropertyAsync() + { + const string propertyName = "maxTempSinceLastReboot"; + var reportedProperties = new ClientPropertyCollection(); + reportedProperties.Add(propertyName, _maxTemp); + + ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(reportedProperties); + + _logger.LogDebug($"Property: Update - {reportedProperties.GetSerializedString()} is {nameof(StatusCodes.OK)} " + + $"with a version of {updateResponse.Version}."); + } + + private static double GenerateTemperatureWithinRange(int max = 50, int min = 0) + { + return Math.Round(s_random.NextDouble() * (max - min) + min, 1); + } + } +} diff --git a/iothub/device/samples/convention-based-samples/readme.md b/iothub/device/samples/convention-based-samples/readme.md new file mode 100644 index 0000000000..578c1b72c6 --- /dev/null +++ b/iothub/device/samples/convention-based-samples/readme.md @@ -0,0 +1,58 @@ +--- +page_type: sample +description: "A set of samples that show how a device that uses the IoT Plug and Play conventions interacts with either IoT Hub or IoT Central." +languages: +- csharp +products: +- azure +- azure-iot-hub +- azure-iot-central +- azure-iot-pnp +- dotnet +urlFragment: azure-iot-pnp-device-samples-for-csharp-net +--- + +# IoT Plug And Play device samples + +These samples demonstrate how a device that follows the [IoT Plug and Play conventions][pnp-convention] interacts with IoT Hub or IoT Central, to: + +- Send telemetry. +- Update read-only and read-write properties. +- Respond to command invocation. + +The samples demonstrate two scenarios: + +- An IoT Plug and Play device that implements the [Thermostat][d-thermostat] model. This model has a single interface that defines telemetry, read-only and read-write properties, and commands. +- An IoT Plug and Play device that implements the [Temperature controller][d-temperature-controller] model. This model uses multiple components: + - The top-level interface defines telemetry, read-only property and commands. + - The model includes two [Thermostat][thermostat-model] components, and a [device information][d-device-info] component. + +## Configuring the samples in Visual Studio + +These samples use the `launchSettings.json` in Visual Studio for different configuration settings, one for direct connection strings and one for the Device Provisioning Service (DPS). + +The configuration file is committed to the repository as `launchSettings.template.json`. Rename the file to `launchSettings.json` and then configure it from the **Debug** tab in the project properties. + +## Configuring the samples in VSCode + +These samples use the `launch.json` in Visual Studio Code for different configuration settings, one for direct connection strings and one for DPS. + +The configuration file is committed to the repository as `launch.template.json`. Rename it to `launch.json` to take effect when you start a debugging session. + +## Quickstarts and tutorials + +To learn more about how to configure and run the Thermostat device sample with IoT Hub, see [Quickstart: Connect a sample IoT Plug and Play device application running on Linux or Windows to IoT Hub][thermostat-hub-qs]. + +To learn more about how to configure and run the Temperature Controller device sample with: + +- IoT Hub, see [Tutorial: Connect an IoT Plug and Play multiple component device application running on Linux or Windows to IoT Hub][temp-controller-hub-tutorial] +- IoT Central, see [Tutorial: Create and connect a client application to your Azure IoT Central application][temp-controller-central-tutorial] + +[pnp-convention]: https://docs.microsoft.com/azure/iot-pnp/concepts-convention +[d-thermostat]: ./Thermostat +[d-temperature-controller]: ./TemperatureController +[thermostat-model]: /iot-hub/Samples/device/convention-based-samples/Thermostat/Models/Thermostat.json +[d-device-info]: https://devicemodels.azure.com/dtmi/azure/devicemanagement/deviceinformation-1.json +[thermostat-hub-qs]: https://docs.microsoft.com/azure/iot-pnp/quickstart-connect-device?pivots=programming-language-csharp +[temp-controller-hub-tutorial]: https://docs.microsoft.com/azure/iot-pnp/tutorial-multiple-components?pivots=programming-language-csharp +[temp-controller-central-tutorial]: https://docs.microsoft.com/azure/iot-central/core/tutorial-connect-device?pivots=programming-language-csharp diff --git a/iothub/device/src/Transport/Amqp/AmqpTransportHandler.cs b/iothub/device/src/Transport/Amqp/AmqpTransportHandler.cs index f1657f6b1e..91c32fbace 100644 --- a/iothub/device/src/Transport/Amqp/AmqpTransportHandler.cs +++ b/iothub/device/src/Transport/Amqp/AmqpTransportHandler.cs @@ -526,7 +526,7 @@ private async Task DisposeMessageAsync(string lockToken, AmqpIotDisposeActions o public override Task GetPropertiesAsync(PayloadConvention payloadConvention, CancellationToken cancellationToken) { throw new NotImplementedException("This operation is currently not supported over AMQP, please use MQTT protocol instead. " + - "Note that you can still retrieve a client's properties using the legacy DeviceClient.GetTwinAsync(CancellationToken cancellationToken) or " + + "Note that you can still retrieve a client's properties using DeviceClient.GetTwinAsync(CancellationToken cancellationToken) or " + "ModuleClient.GetTwinAsync(CancellationToken cancellationToken) operations, but the properties will not be formatted " + "as per DTDL terminology."); } @@ -534,7 +534,7 @@ public override Task GetPropertiesAsync(PayloadConvention payl public override Task SendPropertyPatchAsync(ClientPropertyCollection reportedProperties, CancellationToken cancellationToken) { throw new NotImplementedException("This operation is currently not supported over AMQP, please use MQTT protocol instead. " + - "Note that you can still retrieve a client's properties using the legacy DeviceClient.GetTwinAsync(CancellationToken cancellationToken) or " + + "Note that you can still retrieve a client's properties using DeviceClient.GetTwinAsync(CancellationToken cancellationToken) or " + "ModuleClient.GetTwinAsync(CancellationToken cancellationToken) operations, but the properties will not be formatted " + "as per DTDL terminology."); } From 36125ee5d10ea73b70780cb04bdc8a415726d31b Mon Sep 17 00:00:00 2001 From: Abhipsa Misra Date: Tue, 25 May 2021 16:58:07 -0700 Subject: [PATCH 09/14] fix(doc, samples): Update API design doc and move SystemTextJson helper to samples --- .../devdoc/Convention-based operations.md | 41 ++++++++++++++----- .../SystemTextJsonWritablePropertyResponse.cs | 7 +--- 2 files changed, 32 insertions(+), 16 deletions(-) rename {shared/src => iothub/device/samples/convention-based-samples/TemperatureController}/SystemTextJsonWritablePropertyResponse.cs (96%) diff --git a/iothub/device/devdoc/Convention-based operations.md b/iothub/device/devdoc/Convention-based operations.md index 1af4489ab9..83c3c0ab08 100644 --- a/iothub/device/devdoc/Convention-based operations.md +++ b/iothub/device/devdoc/Convention-based operations.md @@ -2,6 +2,12 @@ #### Common +```diff +public class ClientOptions { ++ public PayloadConvention PayloadConvention { get; set; } +} +``` + ```csharp public abstract class PayloadConvention { @@ -24,7 +30,7 @@ public abstract class PayloadSerializer { public abstract IWritablePropertyResponse CreateWritablePropertyResponse(object value, int statusCode, long version, string description = null); public abstract T DeserializeToType(string stringToDeserialize); public abstract string SerializeToString(object objectToSerialize); - public abstract bool TryGetNestedObjectValue(object objectToConvert, string propertyName, out T outValue); + public abstract bool TryGetNestedObjectValue(object nestedObject, string propertyName, out T outValue); } public sealed class DefaultPayloadConvention : PayloadConvention { @@ -49,7 +55,7 @@ public class NewtonsoftJsonPayloadSerializer : PayloadSerializer { public override IWritablePropertyResponse CreateWritablePropertyResponse(object value, int statusCode, long version, string description = null); public override T DeserializeToType(string stringToDeserialize); public override string SerializeToString(object objectToSerialize); - public override bool TryGetNestedObjectValue(object objectToConvert, string propertyName, out T outValue); + public override bool TryGetNestedObjectValue(object nestedObject, string propertyName, out T outValue); } public abstract class PayloadCollection : IEnumerable, IEnumerable { @@ -69,6 +75,7 @@ public abstract class PayloadCollection : IEnumerable, IEnumerable { } public static class ConventionBasedConstants { + public const char ComponentLevelCommandSeparator = '*'; public const string AckCodePropertyName = "ac"; public const string AckDescriptionPropertyName = "ad"; public const string AckVersionPropertyName = "av"; @@ -76,6 +83,14 @@ public static class ConventionBasedConstants { public const string ComponentIdentifierValue = "c"; public const string ValuePropertyName = "value"; } + +public class StatusCodes { + public StatusCodes(); + public static int Accepted { get; } + public static int BadRequest { get; } + public static int NotFound { get; } + public static int OK { get; } +} ``` ### Properties @@ -109,20 +124,25 @@ public Task SubscribeToWritablePropertiesEventAsync(Func properties, string componentName = null); + public void Add(IDictionary properties); + public void Add(string componentName, IDictionary properties); public override void Add(string propertyName, object propertyValue); - public void Add(string propertyName, object propertyValue, int statusCode, long version, string description = null, string componentName = null); - public void Add(string propertyName, object propertyValue, string componentName); - public void AddOrUpdate(IDictionary properties, string componentNam = null); + public void Add(string propertyName, object propertyValue, int statusCode, long version, string description = null); + public void Add(string componentName, string propertyName, object propertyValue); + public void Add(string componentName, string propertyName, object propertyValue, int statusCode, long version, string description = null); + public void AddOrUpdate(IDictionary properties); + public void AddOrUpdate(string componentName, IDictionary properties); public override void AddOrUpdate(string propertyName, object propertyValue); - public void AddOrUpdate(string propertyName, object propertyValue, int statusCode, long version, string description = null, string componentName = null); - public void AddOrUpdate(string propertyName, object propertyValue, string componentName); + public void AddOrUpdate(string propertyName, object propertyValue, int statusCode, long version, string description = null); + public void AddOrUpdate(string componentName, string propertyName, object propertyValue); + public void AddOrUpdate(string componentName, string propertyName, object propertyValue, int statusCode, long version, string description = null); public bool Contains(string componentName, string propertyName); public virtual bool TryGetValue(string componentName, string propertyName, out T propertyValue); } @@ -172,10 +192,8 @@ public class TelemetryCollection : PayloadCollection { public override void AddOrUpdate(string telemetryName, object telemetryValue); } -public class TelemetryMessage : Message { +public sealed class TelemetryMessage : MessageBase { public TelemetryMessage(string componentName = null); - public new string ContentEncoding { get; internal set; } - public new string ContentType { get; internal set; } public TelemetryCollection Telemetry { get; set; } public override Stream GetBodyStream(); } @@ -200,6 +218,7 @@ public sealed class CommandRequest { public string ComponentName { get; private set; } public string DataAsJson { get; } public T GetData(); + public byte[] GetDataAsBytes(); } public sealed class CommandResponse { diff --git a/shared/src/SystemTextJsonWritablePropertyResponse.cs b/iothub/device/samples/convention-based-samples/TemperatureController/SystemTextJsonWritablePropertyResponse.cs similarity index 96% rename from shared/src/SystemTextJsonWritablePropertyResponse.cs rename to iothub/device/samples/convention-based-samples/TemperatureController/SystemTextJsonWritablePropertyResponse.cs index 8873736908..d0bb306ed7 100644 --- a/shared/src/SystemTextJsonWritablePropertyResponse.cs +++ b/iothub/device/samples/convention-based-samples/TemperatureController/SystemTextJsonWritablePropertyResponse.cs @@ -1,11 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -#if !NET451 - using System.Text.Json.Serialization; +using Microsoft.Azure.Devices.Shared; -namespace Microsoft.Azure.Devices.Shared +namespace Microsoft.Azure.Devices.Client.Samples { /// /// An optional, helper class for constructing a writable property response. @@ -56,5 +55,3 @@ public SystemTextJsonWritablePropertyResponse(object propertyValue, int ackCode, public string AckDescription { get; set; } } } - -#endif From 739a9df62be838661eb78d0e791e4ab425b2c090 Mon Sep 17 00:00:00 2001 From: Abhipsa Misra Date: Thu, 3 Jun 2021 15:16:31 -0700 Subject: [PATCH 10/14] fix(iot-device): Separate out root-level and component-level property addition operations --- e2e/test/iothub/command/CommandE2ETests.cs | 10 +- .../devdoc/Convention-based operations.md | 17 +- .../TemperatureControllerSample.cs | 13 +- .../Thermostat/ThermostatSample.cs | 13 +- iothub/device/src/ClientPropertyCollection.cs | 335 ++++++++---------- .../DeviceClient.ConventionBasedOperations.cs | 6 + .../ModuleClient.ConventionBasedOperations.cs | 6 + iothub/device/src/NumericHelpers.cs | 45 +++ iothub/device/src/PayloadCollection.cs | 32 +- iothub/device/src/TelemetryCollection.cs | 3 +- 10 files changed, 258 insertions(+), 222 deletions(-) create mode 100644 iothub/device/src/NumericHelpers.cs diff --git a/e2e/test/iothub/command/CommandE2ETests.cs b/e2e/test/iothub/command/CommandE2ETests.cs index 07606a5225..02bea0171d 100644 --- a/e2e/test/iothub/command/CommandE2ETests.cs +++ b/e2e/test/iothub/command/CommandE2ETests.cs @@ -2,12 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; -using System.Diagnostics.Tracing; -using System.Net; -using System.Text; using System.Threading.Tasks; using Microsoft.Azure.Devices.Client; -using Microsoft.Azure.Devices.Common.Exceptions; using Microsoft.Azure.Devices.E2ETests.Helpers; using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json; @@ -77,7 +73,7 @@ public static async Task DigitalTwinsSendCommandAndVerifyResponseAsync(string de ServiceClient serviceClient = ServiceClient.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); logger.Trace($"{nameof(DigitalTwinsSendCommandAndVerifyResponseAsync)}: Invoke command {commandName}."); - + CloudToDeviceMethodResult serviceClientResponse = null; if (string.IsNullOrEmpty(componentName)) { @@ -130,7 +126,6 @@ await digitalTwinClient.InvokeComponentCommandAsync( logger.Trace($"{nameof(DigitalTwinsSendCommandAndVerifyResponseAsync)}: Command status: {statusCode}."); Assert.AreEqual(200, statusCode, $"The expected response status should be 200 but was {statusCode}"); Assert.AreEqual(responseExpected, payloadReceived, $"The expected response payload should be {responseExpected} but was {payloadReceived}"); - } public static async Task SetDeviceReceiveCommandAsync(DeviceClient deviceClient, string componentName, string commandName, MsTestLogger logger) @@ -152,7 +147,6 @@ await deviceClient.SubscribeToCommandsAsync( else { Assert.AreEqual(componentName, request.ComponentName, $"The expected component name should be {componentName} but was {request.ComponentName}"); - } var assertionObject = new ServiceCommandRequestAssertion(); string responseExpected = JsonConvert.SerializeObject(assertionObject); @@ -195,4 +189,4 @@ await Task await deviceClient.CloseAsync().ConfigureAwait(false); } } -} \ No newline at end of file +} diff --git a/iothub/device/devdoc/Convention-based operations.md b/iothub/device/devdoc/Convention-based operations.md index 83c3c0ab08..0e8d97735a 100644 --- a/iothub/device/devdoc/Convention-based operations.md +++ b/iothub/device/devdoc/Convention-based operations.md @@ -65,6 +65,7 @@ public abstract class PayloadCollection : IEnumerable, IEnumerable { public virtual object this[string key] { get; set; } public virtual void Add(string key, object value); public virtual void AddOrUpdate(string key, object value); + public void ClearCollection(); public bool Contains(string key); public IEnumerator GetEnumerator(); public virtual byte[] GetPayloadObjectBytes(); @@ -132,17 +133,13 @@ public class ClientPropertyCollection : PayloadCollection { public ClientPropertyCollection(); public long Version { get; protected set; } public void Add(IDictionary properties); - public void Add(string componentName, IDictionary properties); - public override void Add(string propertyName, object propertyValue); - public void Add(string propertyName, object propertyValue, int statusCode, long version, string description = null); - public void Add(string componentName, string propertyName, object propertyValue); - public void Add(string componentName, string propertyName, object propertyValue, int statusCode, long version, string description = null); + public void AddComponentProperties(string componentName, IDictionary properties); + public void AddComponentProperty(string componentName, string propertyName, object propertyValue); public void AddOrUpdate(IDictionary properties); - public void AddOrUpdate(string componentName, IDictionary properties); - public override void AddOrUpdate(string propertyName, object propertyValue); - public void AddOrUpdate(string propertyName, object propertyValue, int statusCode, long version, string description = null); - public void AddOrUpdate(string componentName, string propertyName, object propertyValue); - public void AddOrUpdate(string componentName, string propertyName, object propertyValue, int statusCode, long version, string description = null); + public void AddOrUpdateComponentProperties(string componentName, IDictionary properties); + public void AddOrUpdateComponentProperty(string componentName, string propertyName, object propertyValue); + public void AddOrUpdateRootProperty(string propertyName, object propertyValue); + public void AddRootProperty(string propertyName, object propertyValue); public bool Contains(string componentName, string propertyName); public virtual bool TryGetValue(string componentName, string propertyName, out T propertyValue); } diff --git a/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureControllerSample.cs b/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureControllerSample.cs index fad873354b..a4d3ee42be 100644 --- a/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureControllerSample.cs +++ b/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureControllerSample.cs @@ -129,8 +129,13 @@ private async Task HandleTargetTemperatureUpdateRequestAsync(string componentNam _logger.LogDebug($"Property: Received - component=\"{componentName}\", [ \"{targetTemperatureProperty}\": {targetTemperature}°C ]."); _temperature[componentName] = targetTemperature; + IWritablePropertyResponse writableResponse = _deviceClient + .PayloadConvention + .PayloadSerializer + .CreateWritablePropertyResponse(_temperature[componentName], StatusCodes.OK, version, "Successfully updated target temperature."); + var reportedProperty = new ClientPropertyCollection(); - reportedProperty.Add(componentName, targetTemperatureProperty, _temperature[componentName], StatusCodes.Accepted, version, "Successfully updated target temperature."); + reportedProperty.AddComponentProperty(componentName, targetTemperatureProperty, writableResponse); ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(reportedProperty); @@ -288,7 +293,7 @@ private async Task UpdateDeviceInformationPropertyAsync(CancellationToken cancel { "totalMemory", 1024 }, }; var deviceInformation = new ClientPropertyCollection(); - deviceInformation.Add(componentName, deviceInformationProperties); + deviceInformation.AddComponentProperties(componentName, deviceInformationProperties); ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(deviceInformation, cancellationToken); @@ -330,7 +335,7 @@ private async Task SendDeviceSerialNumberPropertyIfNotCurrentAsync(CancellationT || serialNumberReported != currentSerialNumber) { var reportedProperties = new ClientPropertyCollection(); - reportedProperties.Add(serialNumber, currentSerialNumber); + reportedProperties.AddRootProperty(serialNumber, currentSerialNumber); ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(reportedProperties, cancellationToken); @@ -391,7 +396,7 @@ private async Task UpdateMaxTemperatureSinceLastRebootAsync(string componentName const string propertyName = "maxTempSinceLastReboot"; double maxTemp = _maxTemp[componentName]; var reportedProperties = new ClientPropertyCollection(); - reportedProperties.Add(componentName, propertyName, maxTemp); + reportedProperties.AddComponentProperty(componentName, propertyName, maxTemp); ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(reportedProperties, cancellationToken); diff --git a/iothub/device/samples/convention-based-samples/Thermostat/ThermostatSample.cs b/iothub/device/samples/convention-based-samples/Thermostat/ThermostatSample.cs index 73d6e0c072..d59d4bd3ab 100644 --- a/iothub/device/samples/convention-based-samples/Thermostat/ThermostatSample.cs +++ b/iothub/device/samples/convention-based-samples/Thermostat/ThermostatSample.cs @@ -74,13 +74,18 @@ private async Task HandlePropertyUpdatesAsync(ClientPropertyCollection writableP switch (writableProperty.Key) { case "targetTemperature": - const string tagetTemperatureProperty = "targetTemperature"; + const string targetTemperatureProperty = "targetTemperature"; double targetTemperatureRequested = Convert.ToDouble(writableProperty.Value); - _logger.LogDebug($"Property: Received - [ \"{tagetTemperatureProperty}\": {targetTemperatureRequested}°C ]."); + _logger.LogDebug($"Property: Received - [ \"{targetTemperatureProperty}\": {targetTemperatureRequested}°C ]."); _temperature = targetTemperatureRequested; + IWritablePropertyResponse writableResponse = _deviceClient + .PayloadConvention + .PayloadSerializer + .CreateWritablePropertyResponse(_temperature, StatusCodes.OK, writableProperties.Version, "Successfully updated target temperature"); + var reportedProperty = new ClientPropertyCollection(); - reportedProperty.Add(tagetTemperatureProperty, _temperature, StatusCodes.OK, writableProperties.Version, "Successfully updated target temperature"); + reportedProperty.AddRootProperty(targetTemperatureProperty, writableResponse); ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(reportedProperty); @@ -184,7 +189,7 @@ private async Task UpdateMaxTemperatureSinceLastRebootPropertyAsync() { const string propertyName = "maxTempSinceLastReboot"; var reportedProperties = new ClientPropertyCollection(); - reportedProperties.Add(propertyName, _maxTemp); + reportedProperties.AddRootProperty(propertyName, _maxTemp); ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(reportedProperties); diff --git a/iothub/device/src/ClientPropertyCollection.cs b/iothub/device/src/ClientPropertyCollection.cs index cd6737e15a..45aef54502 100644 --- a/iothub/device/src/ClientPropertyCollection.cs +++ b/iothub/device/src/ClientPropertyCollection.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using Microsoft.Azure.Devices.Shared; namespace Microsoft.Azure.Devices.Client @@ -14,126 +15,104 @@ public class ClientPropertyCollection : PayloadCollection { private const string VersionName = "$version"; - /// /// /// Adds the value to the collection. /// /// - /// If the collection has a key that matches the property name this method will throw an . + /// If the collection already has a key matching a property name supplied this method will throw an . /// /// When using this as part of the writable property flow to respond to a writable property update you should pass in the value /// as an instance of /// to ensure the correct formatting is applied when the object is serialized. /// /// - /// is null /// The name of the property to add. /// The value of the property to add. - public override void Add(string propertyName, object propertyValue) - => Add(null, propertyName, propertyValue); + /// is null. + /// already exists in the collection. + public void AddRootProperty(string propertyName, object propertyValue) + => AddInternal(new Dictionary { { propertyName, propertyValue } }, null, false); - /// + /// /// - /// + /// + /// /// /// Adds the value to the collection. /// - /// is null /// The component with the property to add. /// The name of the property to add. /// The value of the property to add. - public void Add(string componentName, string propertyName, object propertyValue) + public void AddComponentProperty(string componentName, string propertyName, object propertyValue) => AddInternal(new Dictionary { { propertyName, propertyValue } }, componentName, false); - /// + /// /// - /// + /// + /// /// - /// Adds the values to the collection. + /// Adds the value to the collection. /// - /// A collection of properties to add or update. - public void Add(IDictionary properties) - => AddInternal(properties, null, false); + /// The component with the properties to add. + /// A collection of properties to add. + public void AddComponentProperties(string componentName, IDictionary properties) + => AddInternal(properties, componentName, true); - /// /// - /// + /// + /// /// /// Adds the values to the collection. /// - /// A collection of properties to add or update. - /// The component with the properties to add or update. - public void Add(string componentName, IDictionary properties) - => AddInternal(properties, componentName, false); - - /// - /// - /// - /// Adds a writable property to the collection. - /// - /// - /// This method will use the method to create an instance of that will be properly serialized. - /// - /// is null - /// The name of the property to add or update. - /// The value of the property to add or update. - /// - /// - /// - public void Add(string propertyName, object propertyValue, int statusCode, long version, string description = default) - => Add(null, propertyName, propertyValue, statusCode, version, description); - - /// - /// - /// - /// Adds a writable property to the collection. - /// /// - /// This method will use the method to create an instance of that will be properly serialized. + /// If the collection already has a key matching a property name supplied this method will throw an . + /// + /// When using this as part of the writable property flow to respond to a writable property update you should pass in the value + /// as an instance of + /// to ensure the correct formatting is applied when the object is serialized. + /// + /// + /// This method directly adds the supplied to the collection. + /// For component-level properties, either ensure that you include the component identifier markers {"__t": "c"} as a part of the supplied , + /// or use the convenience method instead. + /// For more information see . + /// /// - /// is null - /// The name of the property to add or update. - /// The value of the property to add or update. - /// - /// - /// - /// - public void Add(string componentName, string propertyName, object propertyValue, int statusCode, long version, string description = default) + /// A collection of properties to add. + public void Add(IDictionary properties) { - if (Convention?.PayloadSerializer == null) - { - Add(componentName, propertyName, new { value = propertyValue, ac = statusCode, av = version, ad = description }); - } - else + if (properties == null) { - Add(componentName, propertyName, Convention.PayloadSerializer.CreateWritablePropertyResponse(propertyValue, statusCode, version, description)); + throw new ArgumentNullException(nameof(properties)); } + + properties + .ToList() + .ForEach(entry => Collection.Add(entry.Key, entry.Value)); } /// - /// - /// + /// /// - /// is null + /// /// The name of the property to add or update. /// The value of the property to add or update. - public override void AddOrUpdate(string propertyName, object propertyValue) - => AddOrUpdate(null, propertyName, propertyValue); + public void AddOrUpdateRootProperty(string propertyName, object propertyValue) + => AddInternal(new Dictionary { { propertyName, propertyValue } }, null, true); /// - /// - /// + /// /// - /// is null + /// /// The component with the property to add or update. /// The name of the property to add or update. /// The value of the property to add or update. - public void AddOrUpdate(string componentName, string propertyName, object propertyValue) + public void AddOrUpdateComponentProperty(string componentName, string propertyName, object propertyValue) => AddInternal(new Dictionary { { propertyName, propertyValue } }, componentName, true); /// - /// /// + /// /// /// If the collection has a key that matches this will overwrite the current value. Otherwise it will attempt to add this to the collection. /// @@ -142,9 +121,10 @@ public void AddOrUpdate(string componentName, string propertyName, object proper /// to ensure the correct formatting is applied when the object is serialized. /// /// + /// The component with the properties to add or update. /// A collection of properties to add or update. - public void AddOrUpdate(IDictionary properties) - => AddInternal(properties, null, true); + public void AddOrUpdateComponentProperties(string componentName, IDictionary properties) + => AddInternal(properties, componentName, true); /// /// @@ -152,126 +132,22 @@ public void AddOrUpdate(IDictionary properties) /// /// If the collection has a key that matches this will overwrite the current value. Otherwise it will attempt to add this to the collection. /// - /// When using this as part of the writable property flow to respond to a writable property update - /// you should pass in the value as an instance of + /// When using this as part of the writable property flow to respond to a writable property update you should pass in the value + /// as an instance of /// to ensure the correct formatting is applied when the object is serialized. /// + /// + /// This method directly adds or updates the supplied to the collection. + /// For component-level properties, either ensure that you include the component identifier markers {"__t": "c"} as a part of the supplied , + /// or use the convenience method instead. + /// For more information see . + /// /// - /// The component with the properties to add or update. - /// A collection of properties to add or update. - public void AddOrUpdate(string componentName, IDictionary properties) - => AddInternal(properties, componentName, true); - - /// - /// - /// - /// Adds or updates a type of to the collection. - /// - /// is null - /// The name of the writable property to add or update. - /// The value of the writable property to add or update. - /// - /// - /// - public void AddOrUpdate(string propertyName, object propertyValue, int statusCode, long version, string description = default) - => AddOrUpdate(null, propertyName, propertyValue, statusCode, version, description); - - /// - /// - /// - /// - /// Adds or updates a type of to the collection. - /// - /// is null - /// The name of the writable property to add or update. - /// The value of the writable property to add or update. - /// - /// - /// - /// - public void AddOrUpdate(string componentName, string propertyName, object propertyValue, int statusCode, long version, string description = default) - { - if (Convention?.PayloadSerializer == null) - { - AddOrUpdate(componentName, propertyName, new { value = propertyValue, ac = statusCode, av = version, ad = description }); - } - else - { - AddOrUpdate(componentName, propertyName, Convention.PayloadSerializer.CreateWritablePropertyResponse(propertyValue, statusCode, version, description)); - } - } - - /// - /// Adds or updates the value for the collection. - /// - /// - /// - /// /// A collection of properties to add or update. - /// The component with the properties to add or update. - /// Forces the collection to use the Add or Update behavior. - /// Setting to true will simply overwrite the value. Setting to false will use - private void AddInternal(IDictionary properties, string componentName = default, bool forceUpdate = false) - { - if (properties == null) - { - throw new ArgumentNullException(nameof(properties)); - } - - // If the componentName is null then simply add the key-value pair to Collection dictionary. - // this will either insert a property or overwrite it if it already exists. - if (componentName == null) - { - foreach (KeyValuePair entry in properties) - { - if (forceUpdate) - { - Collection[entry.Key] = entry.Value; - } - else - { - Collection.Add(entry.Key, entry.Value); - } - } - } - else - { - // If the component name already exists within the dictionary, then the value is a dictionary containing the component level property key and values. - // Append this property dictionary to the existing property value dictionary (overwrite entries if they already exist). - // Otherwise, add this as a new entry. - var componentProperties = new Dictionary(); - if (Collection.ContainsKey(componentName)) - { - componentProperties = (Dictionary)Collection[componentName]; - } - foreach (KeyValuePair entry in properties) - { - if (forceUpdate) - { - componentProperties[entry.Key] = entry.Value; - } - else - { - componentProperties.Add(entry.Key, entry.Value); - } - } - - // For a component level property, the property patch needs to contain the {"__t": "c"} component identifier. - if (!componentProperties.ContainsKey(ConventionBasedConstants.ComponentIdentifierKey)) - { - componentProperties[ConventionBasedConstants.ComponentIdentifierKey] = ConventionBasedConstants.ComponentIdentifierValue; - } - - if (forceUpdate) - { - Collection[componentName] = componentProperties; - } - else - { - Collection.Add(componentName, componentProperties); - } - } - } + public void AddOrUpdate(IDictionary properties) + => properties + .ToList() + .ForEach(entry => Collection[entry.Key] = entry.Value); /// /// Determines whether the specified property is present. @@ -323,14 +199,27 @@ public virtual bool TryGetValue(string componentName, string propertyName, ou if (componentProperties is IDictionary nestedDictionary) { - if (nestedDictionary.TryGetValue(propertyName, out object dictionaryElement) && dictionaryElement is T valueRef) + if (nestedDictionary.TryGetValue(propertyName, out object dictionaryElement)) { - propertyValue = valueRef; - return true; + // If the value is null, go ahead and return it. + if (dictionaryElement == null) + { + propertyValue = default; + return true; + } + + // If the object is of type T or can be cast to type T, go ahead and return it. + if (dictionaryElement is T valueRef + || NumericHelpers.TryCastNumericTo(dictionaryElement, out valueRef)) + { + propertyValue = valueRef; + return true; + } } } else { + // If it's not, we need to try to convert it using the serializer. Convention.PayloadSerializer.TryGetNestedObjectValue(componentProperties, propertyName, out propertyValue); return true; } @@ -397,5 +286,71 @@ internal static ClientPropertyCollection FromClientTwinDictionary(IDictionary + /// Adds or updates the value for the collection. + /// + /// + /// + /// + /// A collection of properties to add or update. + /// The component with the properties to add or update. + /// Forces the collection to use the Add or Update behavior. + /// Setting to true will simply overwrite the value. Setting to false will use + /// is null. + private void AddInternal(IDictionary properties, string componentName = default, bool forceUpdate = false) + { + if (properties == null) + { + throw new ArgumentNullException(nameof(properties)); + } + + // If the componentName is null then simply add the key-value pair to Collection dictionary. + // This will either insert a property or overwrite it if it already exists. + if (componentName == null) + { + foreach (KeyValuePair entry in properties) + { + if (forceUpdate) + { + Collection[entry.Key] = entry.Value; + } + else + { + Collection.Add(entry.Key, entry.Value); + } + } + } + else + { + // If the component name already exists within the dictionary, then the value is a dictionary containing the component level property key and values. + // Append this property dictionary to the existing property value dictionary (overwrite entries if they already exist, if forceUpdate is true). + // Otherwise, if the component name does not exist in the dictionary, then add this as a new entry. + var componentProperties = new Dictionary(); + if (Collection.ContainsKey(componentName)) + { + componentProperties = (Dictionary)Collection[componentName]; + } + foreach (KeyValuePair entry in properties) + { + if (forceUpdate) + { + componentProperties[entry.Key] = entry.Value; + } + else + { + componentProperties.Add(entry.Key, entry.Value); + } + } + + // For a component level property, the property patch needs to contain the {"__t": "c"} component identifier. + if (!componentProperties.ContainsKey(ConventionBasedConstants.ComponentIdentifierKey)) + { + componentProperties[ConventionBasedConstants.ComponentIdentifierKey] = ConventionBasedConstants.ComponentIdentifierValue; + } + + Collection[componentName] = componentProperties; + } + } } } diff --git a/iothub/device/src/DeviceClient.ConventionBasedOperations.cs b/iothub/device/src/DeviceClient.ConventionBasedOperations.cs index 7931f3ff92..3c1e63468d 100644 --- a/iothub/device/src/DeviceClient.ConventionBasedOperations.cs +++ b/iothub/device/src/DeviceClient.ConventionBasedOperations.cs @@ -4,6 +4,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.Azure.Devices.Shared; namespace Microsoft.Azure.Devices.Client { @@ -14,6 +15,11 @@ namespace Microsoft.Azure.Devices.Client /// public partial class DeviceClient : IDisposable { + /// + /// The that the client uses for convention-based operations. + /// + public PayloadConvention PayloadConvention => InternalClient.PayloadConvention; + /// /// Send telemetry using the specified message. /// diff --git a/iothub/device/src/ModuleClient.ConventionBasedOperations.cs b/iothub/device/src/ModuleClient.ConventionBasedOperations.cs index d7e16690b5..8f8e7eedfb 100644 --- a/iothub/device/src/ModuleClient.ConventionBasedOperations.cs +++ b/iothub/device/src/ModuleClient.ConventionBasedOperations.cs @@ -4,6 +4,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.Azure.Devices.Shared; namespace Microsoft.Azure.Devices.Client { @@ -14,6 +15,11 @@ namespace Microsoft.Azure.Devices.Client /// public partial class ModuleClient : IDisposable { + /// + /// The that the client uses for convention-based operations. + /// + public PayloadConvention PayloadConvention => InternalClient.PayloadConvention; + /// /// Send telemetry using the specified message. /// diff --git a/iothub/device/src/NumericHelpers.cs b/iothub/device/src/NumericHelpers.cs new file mode 100644 index 0000000000..d9b836c90e --- /dev/null +++ b/iothub/device/src/NumericHelpers.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Globalization; + +namespace Microsoft.Azure.Devices.Client +{ + internal class NumericHelpers + { + internal static bool TryCastNumericTo(object input, out T result) + { + if (TryGetNumeric(input)) + { + try + { + result = (T)Convert.ChangeType(input, typeof(T), CultureInfo.InvariantCulture); + return true; + } + catch + { + } + } + + result = default; + return false; + } + + private static bool TryGetNumeric(object expression) + { + if (expression == null) + { + return false; + } + + return double.TryParse( + Convert.ToString( + expression, + CultureInfo.InvariantCulture), + NumberStyles.Any, + NumberFormatInfo.InvariantInfo, + out _); + } + } +} diff --git a/iothub/device/src/PayloadCollection.cs b/iothub/device/src/PayloadCollection.cs index 4710157b06..2faaca58c0 100644 --- a/iothub/device/src/PayloadCollection.cs +++ b/iothub/device/src/PayloadCollection.cs @@ -45,6 +45,10 @@ public virtual object this[string key] /// /// Adds the key-value pair to the collection. /// + /// + /// For property operations see + /// and instead. + /// /// /// /// @@ -57,6 +61,10 @@ public virtual void Add(string key, object value) /// /// Adds or updates the key-value pair to the collection. /// + /// + /// For property operations see + /// and instead. + /// /// The name of the telemetry. /// The value of the telemetry. /// is null. @@ -108,14 +116,22 @@ public bool TryGetValue(string key, out T value) if (Collection.ContainsKey(key)) { - // If the object is of type T go ahead and return it. - if (Collection[key] is T valueRef) + // If the value is null, go ahead and return it. + if (Collection[key] == null) + { + value = default; + return true; + } + + // If the object is of type T or can be cast to type T, go ahead and return it. + if (Collection[key] is T valueRef + || NumericHelpers.TryCastNumericTo(Collection[key], out valueRef)) { value = valueRef; return true; } - // If it's not we need to try to convert it using the serializer. - // JObject or JsonElement + + // If it's not, we need to try to convert it using the serializer. value = Convention.PayloadSerializer.ConvertFromObject(Collection[key]); return true; } @@ -133,6 +149,14 @@ public virtual string GetSerializedString() return Convention.PayloadSerializer.SerializeToString(Collection); } + /// + /// Remove all items from the collection. + /// + public void ClearCollection() + { + Collection.Clear(); + } + /// public IEnumerator GetEnumerator() { diff --git a/iothub/device/src/TelemetryCollection.cs b/iothub/device/src/TelemetryCollection.cs index 677a6d16e0..99d0164aff 100644 --- a/iothub/device/src/TelemetryCollection.cs +++ b/iothub/device/src/TelemetryCollection.cs @@ -2,12 +2,11 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; -using Microsoft.Azure.Devices.Shared; namespace Microsoft.Azure.Devices.Client { /// - /// The telmetry collection used to populate a . + /// The telemetry collection used to populate a . /// public class TelemetryCollection : PayloadCollection { From 3c14bcdfb8009bac029a90cbf14b970638754bc7 Mon Sep 17 00:00:00 2001 From: jamdavi <73593426+jamdavi@users.noreply.github.com> Date: Fri, 4 Jun 2021 13:21:04 -0400 Subject: [PATCH 11/14] feat(tests): Add unit tests for ClientPropertyCollection feat(tests): Add unit tests for ClientPropertyCollection Co-authored-by: Abhipsa Misra --- .../SystemTextJsonWritablePropertyResponse.cs | 6 +- .../tests/ClientPropertyCollectionTests.cs | 320 ++++++++++++++++++ ...ClientPropertyCollectionTestsNewtonsoft.cs | 284 ++++++++++++++++ iothub/device/tests/NumericHelpersTests.cs | 36 ++ shared/src/NewtonsoftJsonPayloadSerializer.cs | 10 +- shared/src/TwinCollection.cs | 48 +-- 6 files changed, 672 insertions(+), 32 deletions(-) create mode 100644 iothub/device/tests/ClientPropertyCollectionTests.cs create mode 100644 iothub/device/tests/ClientPropertyCollectionTestsNewtonsoft.cs create mode 100644 iothub/device/tests/NumericHelpersTests.cs diff --git a/iothub/device/samples/convention-based-samples/TemperatureController/SystemTextJsonWritablePropertyResponse.cs b/iothub/device/samples/convention-based-samples/TemperatureController/SystemTextJsonWritablePropertyResponse.cs index d0bb306ed7..97a8a84d64 100644 --- a/iothub/device/samples/convention-based-samples/TemperatureController/SystemTextJsonWritablePropertyResponse.cs +++ b/iothub/device/samples/convention-based-samples/TemperatureController/SystemTextJsonWritablePropertyResponse.cs @@ -18,13 +18,13 @@ public sealed class SystemTextJsonWritablePropertyResponse : IWritablePropertyRe /// /// Convenience constructor for specifying the properties. /// - /// The unserialized property value. + /// The unserialized property value. /// The acknowledgment code, usually an HTTP Status Code e.g. 200, 400. /// The acknowledgment version, as supplied in the property update request. /// The acknowledgment description, an optional, human-readable message about the result of the property update. - public SystemTextJsonWritablePropertyResponse(object propertyValue, int ackCode, long ackVersion, string ackDescription = default) + public SystemTextJsonWritablePropertyResponse(object value, int ackCode, long ackVersion, string ackDescription = default) { - Value = propertyValue; + Value = value; AckCode = ackCode; AckVersion = ackVersion; AckDescription = ackDescription; diff --git a/iothub/device/tests/ClientPropertyCollectionTests.cs b/iothub/device/tests/ClientPropertyCollectionTests.cs new file mode 100644 index 0000000000..71c58426c2 --- /dev/null +++ b/iothub/device/tests/ClientPropertyCollectionTests.cs @@ -0,0 +1,320 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.Azure.Devices.Shared; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Azure.Devices.Client.Tests +{ + [TestClass] + [TestCategory("Unit")] + public class ClientPropertyCollectionTests + { + private const string BoolPropertyName = "boolProperty"; + private const string DoublePropertyName = "doubleProperty"; + private const string FloatPropertyName = "floatProperty"; + private const string IntPropertyName = "intProperty"; + private const string ShortPropertyName = "shortProperty"; + private const string StringPropertyName = "stringPropertyName"; + private const string ObjectPropertyName = "objectPropertyName"; + private const string ArrayPropertyName = "arrayPropertyName"; + private const string MapPropertyName = "mapPropertyName"; + private const string DateTimePropertyName = "dateTimePropertyName"; + + private const bool BoolPropertyValue = false; + private const double DoublePropertyValue = 1.001; + private const float FloatPropertyValue = 1.2f; + private const int IntPropertyValue = 12345678; + private const short ShortPropertyValue = 1234; + private const string StringPropertyValue = "propertyValue"; + + private const string ComponentName = "testableComponent"; + private const string WritablePropertyDescription = "testableWritablePropertyDescription"; + private const string UpdatedPropertyValue = "updatedPropertyValue"; + + private static readonly DateTimeOffset s_dateTimePropertyValue = DateTimeOffset.Now; + private static readonly CustomClientProperty s_objectPropertyValue = new CustomClientProperty { Id = 123, Name = "testName" }; + + private static readonly List s_arrayPropertyValues = new List + { + 1, + "someString", + false, + s_objectPropertyValue + }; + + private static readonly Dictionary s_mapPropertyValues = new Dictionary + { + { "key1", "value1" }, + { "key2", 123 }, + { "key3", s_objectPropertyValue } + }; + + [TestMethod] + public void ClientPropertyCollection_CanAddSimpleObjectsAndGetBackWithoutDeviceClient() + { + var clientProperties = new ClientPropertyCollection + { + { StringPropertyName, StringPropertyValue }, + { BoolPropertyName, BoolPropertyValue }, + { DoublePropertyName, DoublePropertyValue }, + { FloatPropertyName, FloatPropertyValue }, + { IntPropertyName, IntPropertyValue }, + { ShortPropertyName, ShortPropertyValue }, + { ObjectPropertyName, s_objectPropertyValue }, + { ArrayPropertyName, s_arrayPropertyValues }, + { MapPropertyName, s_mapPropertyValues }, + { DateTimePropertyName, s_dateTimePropertyValue } + }; + + clientProperties.TryGetValue(StringPropertyName, out string stringOutValue); + stringOutValue.Should().Be(StringPropertyValue); + + clientProperties.TryGetValue(BoolPropertyName, out bool boolOutValue); + boolOutValue.Should().Be(BoolPropertyValue); + + clientProperties.TryGetValue(DoublePropertyName, out double doubleOutValue); + doubleOutValue.Should().Be(DoublePropertyValue); + + clientProperties.TryGetValue(FloatPropertyName, out float floatOutValue); + floatOutValue.Should().Be(FloatPropertyValue); + + clientProperties.TryGetValue(IntPropertyName, out int intOutValue); + intOutValue.Should().Be(IntPropertyValue); + + clientProperties.TryGetValue(ShortPropertyName, out short shortOutValue); + shortOutValue.Should().Be(ShortPropertyValue); + + clientProperties.TryGetValue(ObjectPropertyName, out CustomClientProperty objectOutValue); + objectOutValue.Id.Should().Be(s_objectPropertyValue.Id); + objectOutValue.Name.Should().Be(s_objectPropertyValue.Name); + + clientProperties.TryGetValue(ArrayPropertyName, out List arrayOutValue); + arrayOutValue.Should().HaveSameCount(s_arrayPropertyValues); + arrayOutValue.Should().BeEquivalentTo(s_arrayPropertyValues); + + clientProperties.TryGetValue(MapPropertyName, out Dictionary mapOutValue); + mapOutValue.Should().HaveSameCount(s_mapPropertyValues); + mapOutValue.Should().BeEquivalentTo(s_mapPropertyValues); + + clientProperties.TryGetValue(DateTimePropertyName, out DateTimeOffset dateTimeOutValue); + dateTimeOutValue.Should().Be(s_dateTimePropertyValue); + } + + [TestMethod] + public void ClientPropertyCollection_AddSimpleObjectAgainThrowsException() + { + var clientProperties = new ClientPropertyCollection + { + { StringPropertyName, StringPropertyValue } + }; + + Action act = () => clientProperties.AddRootProperty(StringPropertyName, StringPropertyValue); + act.Should().Throw("\"Add\" method does not support adding a key that already exists in the collection."); + } + + [TestMethod] + public void ClientPropertyCollection_CanUpdateSimpleObjectAndGetBackWithoutDeviceClient() + { + var clientProperties = new ClientPropertyCollection + { + { StringPropertyName, StringPropertyValue } + }; + clientProperties.TryGetValue(StringPropertyName, out string outValue); + outValue.Should().Be(StringPropertyValue); + + clientProperties.AddOrUpdateRootProperty(StringPropertyName, UpdatedPropertyValue); + clientProperties.TryGetValue(StringPropertyName, out string outValueChanged); + outValueChanged.Should().Be(UpdatedPropertyValue, "\"AddOrUpdate\" should overwrite the value if the key already exists in the collection."); + } + + [TestMethod] + public void ClientPropertyCollection_CanAddNullPropertyAndGetBackWithoutDeviceClient() + { + var clientProperties = new ClientPropertyCollection(); + clientProperties.AddRootProperty(StringPropertyName, StringPropertyValue); + clientProperties.AddRootProperty(IntPropertyName, null); + + clientProperties.TryGetValue(StringPropertyName, out string outStringValue); + outStringValue.Should().Be(StringPropertyValue); + + bool nullPropertyPresent = clientProperties.TryGetValue(IntPropertyName, out int? outIntValue); + nullPropertyPresent.Should().BeTrue(); + outIntValue.Should().BeNull(); + } + + [TestMethod] + public void ClientPropertyCollection_CanAddMultiplePropertyAndGetBackWithoutDeviceClient() + { + var clientProperties = new ClientPropertyCollection(); + clientProperties.AddRootProperty(StringPropertyName, StringPropertyValue); + clientProperties.AddRootProperty(IntPropertyName, IntPropertyValue); + + clientProperties.TryGetValue(StringPropertyName, out string outStringValue); + outStringValue.Should().Be(StringPropertyValue); + + clientProperties.TryGetValue(IntPropertyName, out int outIntValue); + outIntValue.Should().Be(IntPropertyValue); + } + + [TestMethod] + public void ClientPropertyCollection_CanAddSimpleObjectWithComponentAndGetBackWithoutDeviceClient() + { + var clientProperties = new ClientPropertyCollection + { + { ComponentName, new Dictionary { + { StringPropertyName, StringPropertyValue }, + { BoolPropertyName, BoolPropertyValue }, + { DoublePropertyName, DoublePropertyValue }, + { FloatPropertyName, FloatPropertyValue }, + { IntPropertyName, IntPropertyValue }, + { ShortPropertyName, ShortPropertyValue }, + { ObjectPropertyName, s_objectPropertyValue }, + { ArrayPropertyName, s_arrayPropertyValues }, + { MapPropertyName, s_mapPropertyValues }, + { DateTimePropertyName, s_dateTimePropertyValue } } + } + }; + + clientProperties.TryGetValue(ComponentName, StringPropertyName, out string stringOutValue); + stringOutValue.Should().Be(StringPropertyValue); + + clientProperties.TryGetValue(ComponentName, BoolPropertyName, out bool boolOutValue); + boolOutValue.Should().Be(BoolPropertyValue); + + clientProperties.TryGetValue(ComponentName, DoublePropertyName, out double doubleOutValue); + doubleOutValue.Should().Be(DoublePropertyValue); + + clientProperties.TryGetValue(ComponentName, FloatPropertyName, out float floatOutValue); + floatOutValue.Should().Be(FloatPropertyValue); + + clientProperties.TryGetValue(ComponentName, IntPropertyName, out int intOutValue); + intOutValue.Should().Be(IntPropertyValue); + + clientProperties.TryGetValue(ComponentName, ShortPropertyName, out short shortOutValue); + shortOutValue.Should().Be(ShortPropertyValue); + + clientProperties.TryGetValue(ComponentName, ObjectPropertyName, out CustomClientProperty objectOutValue); + objectOutValue.Id.Should().Be(s_objectPropertyValue.Id); + objectOutValue.Name.Should().Be(s_objectPropertyValue.Name); + + clientProperties.TryGetValue(ComponentName, ArrayPropertyName, out List arrayOutValue); + arrayOutValue.Should().HaveSameCount(s_arrayPropertyValues); + arrayOutValue.Should().BeEquivalentTo(s_arrayPropertyValues); + + clientProperties.TryGetValue(ComponentName, MapPropertyName, out Dictionary mapOutValue); + mapOutValue.Should().HaveSameCount(s_mapPropertyValues); + mapOutValue.Should().BeEquivalentTo(s_mapPropertyValues); + + clientProperties.TryGetValue(ComponentName, DateTimePropertyName, out DateTimeOffset dateTimeOutValue); + dateTimeOutValue.Should().Be(s_dateTimePropertyValue); + } + + [TestMethod] + public void ClientPropertyCollection_AddSimpleObjectWithComponentAgainThrowsException() + { + var clientProperties = new ClientPropertyCollection(); + clientProperties.AddComponentProperty(ComponentName, StringPropertyName, StringPropertyValue); + + Action act = () => clientProperties.AddComponentProperty(ComponentName, StringPropertyName, StringPropertyValue); + act.Should().Throw("\"Add\" method does not support adding a key that already exists in the collection."); + } + + [TestMethod] + public void ClientPropertyCollection_CanUpdateSimpleObjectWithComponentAndGetBackWithoutDeviceClient() + { + var clientProperties = new ClientPropertyCollection(); + clientProperties.AddComponentProperty(ComponentName, StringPropertyName, StringPropertyValue); + + clientProperties.TryGetValue(ComponentName, StringPropertyName, out string outValue); + outValue.Should().Be(StringPropertyValue); + + clientProperties.AddOrUpdateComponentProperty(ComponentName, StringPropertyName, UpdatedPropertyValue); + clientProperties.TryGetValue(ComponentName, StringPropertyName, out string outValueChanged); + outValueChanged.Should().Be(UpdatedPropertyValue, "\"AddOrUpdate\" should overwrite the value if the key already exists in the collection."); + } + + [TestMethod] + public void ClientPropertyCollection_CanAddNullPropertyWithComponentAndGetBackWithoutDeviceClient() + { + var clientProperties = new ClientPropertyCollection(); + clientProperties.AddComponentProperty(ComponentName, StringPropertyName, StringPropertyValue); + clientProperties.AddComponentProperty(ComponentName, IntPropertyName, null); + + clientProperties.TryGetValue(ComponentName, StringPropertyName, out string outStringValue); + outStringValue.Should().Be(StringPropertyValue); + + bool nullPropertyPresent = clientProperties.TryGetValue(ComponentName, IntPropertyName, out int? outIntValue); + nullPropertyPresent.Should().BeTrue(); + outIntValue.Should().BeNull(); + } + + [TestMethod] + public void ClientPropertyCollection_CanAddMultiplePropertyWithComponentAndGetBackWithoutDeviceClient() + { + var clientProperties = new ClientPropertyCollection(); + clientProperties.AddComponentProperty(ComponentName, StringPropertyName, StringPropertyValue); + clientProperties.AddComponentProperty(ComponentName, IntPropertyName, IntPropertyValue); + + clientProperties.TryGetValue(ComponentName, StringPropertyName, out string outStringValue); + outStringValue.Should().Be(StringPropertyValue); + + clientProperties.TryGetValue(ComponentName, IntPropertyName, out int outIntValue); + outIntValue.Should().Be(IntPropertyValue); + } + + [TestMethod] + public void ClientPropertyCollection_CanAddSimpleWritablePropertyAndGetBackWithoutDeviceClient() + { + var clientProperties = new ClientPropertyCollection(); + + var writableResponse = new NewtonsoftJsonWritablePropertyResponse(StringPropertyValue, StatusCodes.OK, 2, WritablePropertyDescription); + clientProperties.AddRootProperty(StringPropertyName, writableResponse); + + clientProperties.TryGetValue(StringPropertyName, out NewtonsoftJsonWritablePropertyResponse outValue); + outValue.Value.Should().Be(writableResponse.Value); + outValue.AckCode.Should().Be(writableResponse.AckCode); + outValue.AckVersion.Should().Be(writableResponse.AckVersion); + outValue.AckDescription.Should().Be(writableResponse.AckDescription); + } + + [TestMethod] + public void ClientPropertyCollection_CanAddWritablePropertyWithComponentAndGetBackWithoutDeviceClient() + { + var clientProperties = new ClientPropertyCollection(); + + var writableResponse = new NewtonsoftJsonWritablePropertyResponse(StringPropertyValue, StatusCodes.OK, 2, WritablePropertyDescription); + clientProperties.AddComponentProperty(ComponentName, StringPropertyName, writableResponse); + + clientProperties.TryGetValue(ComponentName, StringPropertyName, out NewtonsoftJsonWritablePropertyResponse outValue); + outValue.Value.Should().Be(writableResponse.Value); + outValue.AckCode.Should().Be(writableResponse.AckCode); + outValue.AckVersion.Should().Be(writableResponse.AckVersion); + outValue.AckDescription.Should().Be(writableResponse.AckDescription); + } + + [TestMethod] + public void ClientPropertyCollection_AddingComponentAddsComponentIdentifier() + { + var clientProperties = new ClientPropertyCollection(); + clientProperties.AddComponentProperty(ComponentName, StringPropertyName, StringPropertyValue); + + clientProperties.TryGetValue(ComponentName, StringPropertyName, out string outValue); + clientProperties.TryGetValue(ComponentName, ConventionBasedConstants.ComponentIdentifierKey, out string componentOut); + + outValue.Should().Be(StringPropertyValue); + componentOut.Should().Be(ConventionBasedConstants.ComponentIdentifierValue); + } + } + + internal class CustomClientProperty + { + // The properties in here need to be public otherwise NewtonSoft.Json cannot serialize and deserialize them properly. + public int Id { get; set; } + + public string Name { get; set; } + } +} diff --git a/iothub/device/tests/ClientPropertyCollectionTestsNewtonsoft.cs b/iothub/device/tests/ClientPropertyCollectionTestsNewtonsoft.cs new file mode 100644 index 0000000000..fb7c35bf9d --- /dev/null +++ b/iothub/device/tests/ClientPropertyCollectionTestsNewtonsoft.cs @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.Azure.Devices.Shared; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.Devices.Client.Tests +{ + [TestClass] + [TestCategory("Unit")] + // These tests test the deserialization of the service response to a ClientPropertyCollection. + // This flow is convention aware and uses NewtonSoft.Json for deserialization. + // For the purpose of these tests we will create an instance of a Twin class to simulate the service response. + public class ClientPropertyCollectionTestsNewtonsoft + { + internal const string BoolPropertyName = "boolProperty"; + internal const string DoublePropertyName = "doubleProperty"; + internal const string FloatPropertyName = "floatProperty"; + internal const string IntPropertyName = "intProperty"; + internal const string ShortPropertyName = "shortProperty"; + internal const string StringPropertyName = "stringPropertyName"; + internal const string ObjectPropertyName = "objectPropertyName"; + internal const string ArrayPropertyName = "arrayPropertyName"; + internal const string MapPropertyName = "mapPropertyName"; + internal const string DateTimePropertyName = "dateTimePropertyName"; + internal const string ComponentName = "testableComponent"; + + private const bool BoolPropertyValue = false; + private const double DoublePropertyValue = 1.001; + private const float FloatPropertyValue = 1.2f; + private const int IntPropertyValue = 12345678; + private const short ShortPropertyValue = 1234; + private const string StringPropertyValue = "propertyValue"; + + private const string UpdatedPropertyValue = "updatedPropertyValue"; + + private static readonly DateTimeOffset s_dateTimePropertyValue = DateTimeOffset.Now; + private static readonly CustomClientProperty s_objectPropertyValue = new CustomClientProperty { Id = 123, Name = "testName" }; + + private static readonly List s_arrayPropertyValues = new List + { + 1, + "someString", + false, + s_objectPropertyValue + }; + + private static readonly Dictionary s_mapPropertyValues = new Dictionary + { + { "key1", "value1" }, + { "key2", 123 }, + { "key3", s_objectPropertyValue } + }; + + // Create an object that represents all of the properties as root-level properties. + private static readonly RootLevelProperties s_rootLevelProperties = new RootLevelProperties + { + BooleanProperty = BoolPropertyValue, + DoubleProperty = DoublePropertyValue, + FloatProperty = FloatPropertyValue, + IntProperty = IntPropertyValue, + ShortProperty = ShortPropertyValue, + StringProperty = StringPropertyValue, + ObjectProperty = s_objectPropertyValue, + ArrayProperty = s_arrayPropertyValues, + MapProperty = s_mapPropertyValues, + DateTimeProperty = s_dateTimePropertyValue + }; + + // Create an object that represents all of the properties as component-level properties. + // This adds the "__t": "c" component identifier as a part of "ComponentProperties" class declaration. + private static readonly ComponentLevelProperties s_componentLevelProperties = new ComponentLevelProperties + { + Properties = new ComponentProperties + { + BooleanProperty = BoolPropertyValue, + DoubleProperty = DoublePropertyValue, + FloatProperty = FloatPropertyValue, + IntProperty = IntPropertyValue, + ShortProperty = ShortPropertyValue, + StringProperty = StringPropertyValue, + ObjectProperty = s_objectPropertyValue, + ArrayProperty = s_arrayPropertyValues, + MapProperty = s_mapPropertyValues, + DateTimeProperty = s_dateTimePropertyValue + }, + }; + + // Create a writable property response with the expected values. + private static readonly IWritablePropertyResponse s_writablePropertyResponse = new NewtonsoftJsonWritablePropertyResponse( + propertyValue: StringPropertyValue, + ackCode: StatusCodes.OK, + ackVersion: 2, + ackDescription: "testableWritablePropertyDescription"); + + // Create a JObject instance that represents a writable property response sent for a root-level property. + private static readonly JObject s_writablePropertyResponseJObject = new JObject( + new JProperty(StringPropertyName, JObject.FromObject(s_writablePropertyResponse))); + + // Create a JObject instance that represents a writable property response sent for a component-level property. + // This adds the "__t": "c" component identifier to the constructed JObject. + private static readonly JObject s_writablePropertyResponseWithComponentJObject = new JObject( + new JProperty(ComponentName, new JObject( + new JProperty(ConventionBasedConstants.ComponentIdentifierKey, ConventionBasedConstants.ComponentIdentifierValue), + new JProperty(StringPropertyName, JObject.FromObject(s_writablePropertyResponse))))); + + // The above constructed json objects are used for initializing a twin response. + // This is because we are using a Twin instance to simulate the service response. + + private static TwinCollection collectionToRoundTrip = new TwinCollection(JsonConvert.SerializeObject(s_rootLevelProperties)); + private static TwinCollection collectionWithComponentToRoundTrip = new TwinCollection(JsonConvert.SerializeObject(s_componentLevelProperties)); + private static TwinCollection collectionWritablePropertyToRoundTrip = new TwinCollection(s_writablePropertyResponseJObject, null); + private static TwinCollection collectionWritablePropertyWithComponentToRoundTrip = new TwinCollection(s_writablePropertyResponseWithComponentJObject, null); + + [TestMethod] + public void ClientPropertyCollectionNewtonsoft_CanGetValue() + { + var clientProperties = ClientPropertyCollection.FromTwinCollection(collectionToRoundTrip, DefaultPayloadConvention.Instance); + + clientProperties.TryGetValue(StringPropertyName, out string stringOutValue); + stringOutValue.Should().Be(StringPropertyValue); + + clientProperties.TryGetValue(BoolPropertyName, out bool boolOutValue); + boolOutValue.Should().Be(BoolPropertyValue); + + clientProperties.TryGetValue(DoublePropertyName, out double doubleOutValue); + doubleOutValue.Should().Be(DoublePropertyValue); + + clientProperties.TryGetValue(FloatPropertyName, out float floatOutValue); + floatOutValue.Should().Be(FloatPropertyValue); + + clientProperties.TryGetValue(IntPropertyName, out int intOutValue); + intOutValue.Should().Be(IntPropertyValue); + + clientProperties.TryGetValue(ShortPropertyName, out short shortOutValue); + shortOutValue.Should().Be(ShortPropertyValue); + + clientProperties.TryGetValue(ObjectPropertyName, out CustomClientProperty objectOutValue); + objectOutValue.Id.Should().Be(s_objectPropertyValue.Id); + objectOutValue.Name.Should().Be(s_objectPropertyValue.Name); + + // The two lists won't be exactly equal since TryGetValue doesn't implement nested deserialization + // => the complex object inside the list is deserialized to a JObject. + clientProperties.TryGetValue(ArrayPropertyName, out List arrayOutValue); + arrayOutValue.Should().HaveSameCount(s_arrayPropertyValues); + + // The two dictionaries won't be exactly equal since TryGetValue doesn't implement nested deserialization + // => the complex object inside the dictionary is deserialized to a JObject. + clientProperties.TryGetValue(MapPropertyName, out Dictionary mapOutValue); + mapOutValue.Should().HaveSameCount(s_mapPropertyValues); + + clientProperties.TryGetValue(DateTimePropertyName, out DateTimeOffset dateTimeOutValue); + dateTimeOutValue.Should().Be(s_dateTimePropertyValue); + } + + [TestMethod] + public void ClientPropertyCollectionNewtonsoft_CanGetValueWithComponent() + { + var clientProperties = ClientPropertyCollection.FromTwinCollection(collectionWithComponentToRoundTrip, DefaultPayloadConvention.Instance); + + clientProperties.TryGetValue(ComponentName, StringPropertyName, out string stringOutValue); + stringOutValue.Should().Be(StringPropertyValue); + + clientProperties.TryGetValue(ComponentName, BoolPropertyName, out bool boolOutValue); + boolOutValue.Should().Be(BoolPropertyValue); + + clientProperties.TryGetValue(ComponentName, DoublePropertyName, out double doubleOutValue); + doubleOutValue.Should().Be(DoublePropertyValue); + + clientProperties.TryGetValue(ComponentName, FloatPropertyName, out float floatOutValue); + floatOutValue.Should().Be(FloatPropertyValue); + + clientProperties.TryGetValue(ComponentName, IntPropertyName, out int intOutValue); + intOutValue.Should().Be(IntPropertyValue); + + clientProperties.TryGetValue(ComponentName, ShortPropertyName, out short shortOutValue); + shortOutValue.Should().Be(ShortPropertyValue); + + clientProperties.TryGetValue(ComponentName, ObjectPropertyName, out CustomClientProperty objectOutValue); + objectOutValue.Id.Should().Be(s_objectPropertyValue.Id); + objectOutValue.Name.Should().Be(s_objectPropertyValue.Name); + + // The two lists won't be exactly equal since TryGetValue doesn't implement nested deserialization + // => the complex object inside the list is deserialized to a JObject. + clientProperties.TryGetValue(ComponentName, ArrayPropertyName, out List arrayOutValue); + arrayOutValue.Should().HaveSameCount(s_arrayPropertyValues); + + // The two dictionaries won't be exactly equal since TryGetValue doesn't implement nested deserialization + // => the complex object inside the dictionary is deserialized to a JObject. + clientProperties.TryGetValue(ComponentName, MapPropertyName, out Dictionary mapOutValue); + mapOutValue.Should().HaveSameCount(s_mapPropertyValues); + + clientProperties.TryGetValue(ComponentName, DateTimePropertyName, out DateTimeOffset dateTimeOutValue); + dateTimeOutValue.Should().Be(s_dateTimePropertyValue); + } + + [TestMethod] + public void ClientPropertyCollectionNewtonsoft_CanAddSimpleWritablePropertyAndGetBack() + { + var clientProperties = ClientPropertyCollection.FromTwinCollection(collectionWritablePropertyToRoundTrip, DefaultPayloadConvention.Instance); + + clientProperties.TryGetValue(StringPropertyName, out NewtonsoftJsonWritablePropertyResponse outValue); + outValue.Value.Should().Be(StringPropertyValue); + outValue.AckCode.Should().Be(s_writablePropertyResponse.AckCode); + outValue.AckVersion.Should().Be(s_writablePropertyResponse.AckVersion); + outValue.AckDescription.Should().Be(s_writablePropertyResponse.AckDescription); + } + + [TestMethod] + public void ClientPropertyCollectionNewtonsoft_CanAddWritablePropertyWithComponentAndGetBack() + { + var clientProperties = ClientPropertyCollection.FromTwinCollection(collectionWritablePropertyWithComponentToRoundTrip, DefaultPayloadConvention.Instance); + + clientProperties.TryGetValue(ComponentName, StringPropertyName, out NewtonsoftJsonWritablePropertyResponse outValue); + outValue.Value.Should().Be(StringPropertyValue); + outValue.AckCode.Should().Be(s_writablePropertyResponse.AckCode); + outValue.AckVersion.Should().Be(s_writablePropertyResponse.AckVersion); + outValue.AckDescription.Should().Be(s_writablePropertyResponse.AckDescription); + } + + [TestMethod] + public void ClientPropertyCollectionNewtonsoft_CanGetComponentIdentifier() + { + var clientProperties = ClientPropertyCollection.FromTwinCollection(collectionWithComponentToRoundTrip, DefaultPayloadConvention.Instance); + + clientProperties.TryGetValue(ComponentName, StringPropertyName, out string outValue); + clientProperties.TryGetValue(ComponentName, ConventionBasedConstants.ComponentIdentifierKey, out string componentOut); + + outValue.Should().Be(StringPropertyValue); + componentOut.Should().Be(ConventionBasedConstants.ComponentIdentifierValue); + } + } + + internal class RootLevelProperties + { + [JsonProperty(ClientPropertyCollectionTestsNewtonsoft.BoolPropertyName)] + public bool BooleanProperty { get; set; } + + [JsonProperty(ClientPropertyCollectionTestsNewtonsoft.DoublePropertyName)] + public double DoubleProperty { get; set; } + + [JsonProperty(ClientPropertyCollectionTestsNewtonsoft.FloatPropertyName)] + public float FloatProperty { get; set; } + + [JsonProperty(ClientPropertyCollectionTestsNewtonsoft.IntPropertyName)] + public int IntProperty { get; set; } + + [JsonProperty(ClientPropertyCollectionTestsNewtonsoft.ShortPropertyName)] + public short ShortProperty { get; set; } + + [JsonProperty(ClientPropertyCollectionTestsNewtonsoft.StringPropertyName)] + public string StringProperty { get; set; } + + [JsonProperty(ClientPropertyCollectionTestsNewtonsoft.ObjectPropertyName)] + public object ObjectProperty { get; set; } + + [JsonProperty(ClientPropertyCollectionTestsNewtonsoft.ArrayPropertyName)] + public IList ArrayProperty { get; set; } + + [JsonProperty(ClientPropertyCollectionTestsNewtonsoft.MapPropertyName)] + public IDictionary MapProperty { get; set; } + + [JsonProperty(ClientPropertyCollectionTestsNewtonsoft.DateTimePropertyName)] + public DateTimeOffset DateTimeProperty { get; set; } + } + + internal class ComponentProperties : RootLevelProperties + { + [JsonProperty(ConventionBasedConstants.ComponentIdentifierKey)] + public string ComponentIdentifier { get; } = ConventionBasedConstants.ComponentIdentifierValue; + } + + internal class ComponentLevelProperties + { + [JsonProperty(ClientPropertyCollectionTestsNewtonsoft.ComponentName)] + public ComponentProperties Properties { get; set; } + } +} diff --git a/iothub/device/tests/NumericHelpersTests.cs b/iothub/device/tests/NumericHelpersTests.cs new file mode 100644 index 0000000000..d1f4e72684 --- /dev/null +++ b/iothub/device/tests/NumericHelpersTests.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Text; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Azure.Devices.Client.Tests +{ + [TestClass] + [TestCategory("Unit")] + public class NumericHelpersTests + { + [TestMethod] + public void CanConvertNumericTypes() + { + TestNumericConversion(1.001d, true, 1.001f); + TestNumericConversion(123, true, 123); + TestNumericConversion(123, true, 123); + TestNumericConversion(123, true, 123); + TestNumericConversion(123, true, 123); + TestNumericConversion("someString", false, 0); + TestNumericConversion(true, false, 0); + } + + private void TestNumericConversion(object input, bool canConvertExpected, T resultExpected) + { + bool canConvertActual = NumericHelpers.TryCastNumericTo(input, out T result); + + canConvertActual.Should().Be(canConvertExpected); + result.Should().Be(resultExpected); + } + } +} diff --git a/shared/src/NewtonsoftJsonPayloadSerializer.cs b/shared/src/NewtonsoftJsonPayloadSerializer.cs index 7d90b163a0..b38063178b 100644 --- a/shared/src/NewtonsoftJsonPayloadSerializer.cs +++ b/shared/src/NewtonsoftJsonPayloadSerializer.cs @@ -39,11 +39,11 @@ public override T DeserializeToType(string stringToDeserialize) /// public override T ConvertFromObject(object objectToConvert) { - if (objectToConvert == null) - { - return default; - } - return ((JToken)objectToConvert).ToObject(); + var token = JToken.FromObject(objectToConvert); + + return objectToConvert == null + ? default + : token.ToObject(); } /// diff --git a/shared/src/TwinCollection.cs b/shared/src/TwinCollection.cs index ff8fbb9f4f..8149e1f8fb 100644 --- a/shared/src/TwinCollection.cs +++ b/shared/src/TwinCollection.cs @@ -55,28 +55,28 @@ public TwinCollection(string twinJson, string metadataJson) } /// - /// Creates a using a JSON fragment as the body. + /// Creates a using the given JSON fragments for the body and metadata. /// /// JSON fragment containing the twin data. - internal TwinCollection(JObject twinJson) + /// JSON fragment containing the metadata. + public TwinCollection(JObject twinJson, JObject metadataJson) { JObject = twinJson ?? new JObject(); - - if (JObject.TryGetValue(MetadataName, out JToken metadataJToken)) - { - _metadata = metadataJToken as JObject; - } + _metadata = metadataJson; } /// - /// Creates a using the given JSON fragments for the body and metadata. + /// Creates a using a JSON fragment as the body. /// /// JSON fragment containing the twin data. - /// JSON fragment containing the metadata. - public TwinCollection(JObject twinJson, JObject metadataJson) + internal TwinCollection(JObject twinJson) { JObject = twinJson ?? new JObject(); - _metadata = metadataJson; + + if (JObject.TryGetValue(MetadataName, out JToken metadataJToken)) + { + _metadata = metadataJToken as JObject; + } } /// @@ -121,8 +121,6 @@ public int Count } } - internal JObject JObject { get; private set; } - /// /// Property Indexer /// @@ -227,6 +225,19 @@ public IEnumerator GetEnumerator() } } + /// + /// Clear metadata out of the collection + /// + public void ClearMetadata() + { + TryClearMetadata(MetadataName); + TryClearMetadata(LastUpdatedName); + TryClearMetadata(LastUpdatedVersionName); + TryClearMetadata(VersionName); + } + + internal JObject JObject { get; private set; } + private bool TryGetMemberInternal(string propertyName, out object result) { if (!JObject.TryGetValue(propertyName, out JToken value)) @@ -283,16 +294,5 @@ private void TryClearMetadata(string propertyName) JObject.Remove(propertyName); } } - - /// - /// Clear metadata out of the collection - /// - public void ClearMetadata() - { - TryClearMetadata(MetadataName); - TryClearMetadata(LastUpdatedName); - TryClearMetadata(LastUpdatedVersionName); - TryClearMetadata(VersionName); - } } } From ef1df7072f2090b1decadb339c13b02a4243ae1c Mon Sep 17 00:00:00 2001 From: jamdavi <73593426+jamdavi@users.noreply.github.com> Date: Fri, 4 Jun 2021 15:33:11 -0400 Subject: [PATCH 12/14] feat(e2e-tests): Add properties E2E tests Co-authored-by: Abhipsa Misra --- e2e/test/Helpers/TestDeviceCallbackHandler.cs | 201 +++++--- .../iothub/properties/PropertiesE2ETests.cs | 448 ++++++++++++++++ .../PropertiesWithComponentsE2ETests.cs | 480 ++++++++++++++++++ iothub/device/src/ClientPropertyCollection.cs | 69 +-- iothub/device/src/PayloadCollection.cs | 12 +- .../tests/ClientPropertyCollectionTests.cs | 38 +- ...ClientPropertyCollectionTestsNewtonsoft.cs | 30 +- 7 files changed, 1141 insertions(+), 137 deletions(-) create mode 100644 e2e/test/iothub/properties/PropertiesE2ETests.cs create mode 100644 e2e/test/iothub/properties/PropertiesWithComponentsE2ETests.cs diff --git a/e2e/test/Helpers/TestDeviceCallbackHandler.cs b/e2e/test/Helpers/TestDeviceCallbackHandler.cs index e2b075f91b..6f2025de76 100644 --- a/e2e/test/Helpers/TestDeviceCallbackHandler.cs +++ b/e2e/test/Helpers/TestDeviceCallbackHandler.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; -using System.Linq; using System.Runtime.ExceptionServices; using System.Text; using System.Threading; @@ -29,6 +28,10 @@ public class TestDeviceCallbackHandler : IDisposable private ExceptionDispatchInfo _receiveMessageExceptionDispatch; private Message _expectedMessageSentByService = null; + private readonly SemaphoreSlim _clientPropertyCallbackSemaphore = new SemaphoreSlim(0, 1); + private ExceptionDispatchInfo _clientPropertyExceptionDispatch; + private object _expectedClientPropertyValue = null; + public TestDeviceCallbackHandler(DeviceClient deviceClient, TestDevice testDevice, MsTestLogger logger) { _deviceClient = deviceClient; @@ -48,33 +51,42 @@ public Message ExpectedMessageSentByService set => Volatile.Write(ref _expectedMessageSentByService, value); } - public async Task SetDeviceReceiveMethodAsync(string methodName, string deviceResponseJson, string expectedServiceRequestJson) + public object ExpectedClientPropertyValue { - await _deviceClient.SetMethodHandlerAsync(methodName, - (request, context) => - { - try - { - _logger.Trace($"{nameof(SetDeviceReceiveMethodAsync)}: DeviceClient {_testDevice.Id} callback method: {request.Name} {request.ResponseTimeout}."); - request.Name.Should().Be(methodName, "The expected method name should match what was sent from service"); - request.DataAsJson.Should().Be(expectedServiceRequestJson, "The expected method data should match what was sent from service"); - - return Task.FromResult(new MethodResponse(Encoding.UTF8.GetBytes(deviceResponseJson), 200)); - } - catch (Exception ex) - { - _logger.Trace($"{nameof(SetDeviceReceiveMethodAsync)}: Error during DeviceClient callback method: {ex}."); + get => Volatile.Read(ref _expectedClientPropertyValue); + set => Volatile.Write(ref _expectedClientPropertyValue, value); + } - _methodExceptionDispatch = ExceptionDispatchInfo.Capture(ex); - return Task.FromResult(new MethodResponse(500)); - } - finally + public async Task SetDeviceReceiveMethodAsync(string methodName, string deviceResponseJson, string expectedServiceRequestJson) + { + await _deviceClient + .SetMethodHandlerAsync( + methodName, + (request, context) => { - // Always notify that we got the callback. - _methodCallbackSemaphore.Release(); - } - }, - null).ConfigureAwait(false); + try + { + _logger.Trace($"{nameof(SetDeviceReceiveMethodAsync)}: DeviceClient {_testDevice.Id} callback method: {request.Name} {request.ResponseTimeout}."); + request.Name.Should().Be(methodName, "The expected method name should match what was sent from service"); + request.DataAsJson.Should().Be(expectedServiceRequestJson, "The expected method data should match what was sent from service"); + + return Task.FromResult(new MethodResponse(Encoding.UTF8.GetBytes(deviceResponseJson), 200)); + } + catch (Exception ex) + { + _logger.Trace($"{nameof(SetDeviceReceiveMethodAsync)}: Error during DeviceClient callback method: {ex}."); + + _methodExceptionDispatch = ExceptionDispatchInfo.Capture(ex); + return Task.FromResult(new MethodResponse(500)); + } + finally + { + // Always notify that we got the callback. + _methodCallbackSemaphore.Release(); + } + }, + null) + .ConfigureAwait(false); } public async Task WaitForMethodCallbackAsync(CancellationToken ct) @@ -87,29 +99,32 @@ public async Task SetTwinPropertyUpdateCallbackHandlerAsync(string expectedPropN { string userContext = "myContext"; - await _deviceClient.SetDesiredPropertyUpdateCallbackAsync( - (patch, context) => - { - _logger.Trace($"{nameof(SetTwinPropertyUpdateCallbackHandlerAsync)}: DeviceClient {_testDevice.Id} callback twin: DesiredProperty: {patch}, {context}"); - - try - { - string propertyValue = patch[expectedPropName]; - propertyValue.Should().Be(ExpectedTwinPropertyValue, "The property value should match what was set by service"); - context.Should().Be(userContext, "The context should match what was set by service"); - } - catch (Exception ex) + await _deviceClient + .SetDesiredPropertyUpdateCallbackAsync( + (patch, context) => { - _twinExceptionDispatch = ExceptionDispatchInfo.Capture(ex); - } - finally - { - // Always notify that we got the callback. - _twinCallbackSemaphore.Release(); - } - - return Task.FromResult(true); - }, userContext).ConfigureAwait(false); + _logger.Trace($"{nameof(SetTwinPropertyUpdateCallbackHandlerAsync)}: DeviceClient {_testDevice.Id} callback twin: DesiredProperty: {patch}, {context}"); + + try + { + string propertyValue = patch[expectedPropName]; + propertyValue.Should().Be(ExpectedTwinPropertyValue, "The property value should match what was set by service"); + context.Should().Be(userContext, "The context should match what was set by service"); + } + catch (Exception ex) + { + _twinExceptionDispatch = ExceptionDispatchInfo.Capture(ex); + } + finally + { + // Always notify that we got the callback. + _twinCallbackSemaphore.Release(); + } + + return Task.FromResult(true); + }, + userContext) + .ConfigureAwait(false); } public async Task WaitForTwinCallbackAsync(CancellationToken ct) @@ -120,31 +135,33 @@ public async Task WaitForTwinCallbackAsync(CancellationToken ct) public async Task SetMessageReceiveCallbackHandlerAsync() { - await _deviceClient.SetReceiveMessageHandlerAsync( - async (receivedMessage, context) => - { - _logger.Trace($"{nameof(SetMessageReceiveCallbackHandlerAsync)}: DeviceClient {_testDevice.Id} received message with Id: {receivedMessage.MessageId}."); - - try + await _deviceClient + .SetReceiveMessageHandlerAsync( + async (receivedMessage, context) => { - receivedMessage.MessageId.Should().Be(ExpectedMessageSentByService.MessageId, "Received message Id should match what was sent by service"); - receivedMessage.UserId.Should().Be(ExpectedMessageSentByService.UserId, "Received user Id should match what was sent by service"); - - await CompleteMessageAsync(receivedMessage).ConfigureAwait(false); - _logger.Trace($"{nameof(SetMessageReceiveCallbackHandlerAsync)}: DeviceClient completed message with Id: {receivedMessage.MessageId}."); - } - catch (Exception ex) - { - _logger.Trace($"{nameof(SetMessageReceiveCallbackHandlerAsync)}: Error during DeviceClient receive message callback: {ex}."); - _receiveMessageExceptionDispatch = ExceptionDispatchInfo.Capture(ex); - } - finally - { - // Always notify that we got the callback. - _receivedMessageCallbackSemaphore.Release(); - } - }, - null).ConfigureAwait(false); + _logger.Trace($"{nameof(SetMessageReceiveCallbackHandlerAsync)}: DeviceClient {_testDevice.Id} received message with Id: {receivedMessage.MessageId}."); + + try + { + receivedMessage.MessageId.Should().Be(ExpectedMessageSentByService.MessageId, "Received message Id should match what was sent by service"); + receivedMessage.UserId.Should().Be(ExpectedMessageSentByService.UserId, "Received user Id should match what was sent by service"); + + await CompleteMessageAsync(receivedMessage).ConfigureAwait(false); + _logger.Trace($"{nameof(SetMessageReceiveCallbackHandlerAsync)}: DeviceClient completed message with Id: {receivedMessage.MessageId}."); + } + catch (Exception ex) + { + _logger.Trace($"{nameof(SetMessageReceiveCallbackHandlerAsync)}: Error during DeviceClient receive message callback: {ex}."); + _receiveMessageExceptionDispatch = ExceptionDispatchInfo.Capture(ex); + } + finally + { + // Always notify that we got the callback. + _receivedMessageCallbackSemaphore.Release(); + } + }, + null) + .ConfigureAwait(false); } private async Task CompleteMessageAsync(Client.Message message) @@ -158,6 +175,48 @@ public async Task WaitForReceiveMessageCallbackAsync(CancellationToken ct) _receiveMessageExceptionDispatch?.Throw(); } + public async Task SetClientPropertyUpdateCallbackHandlerAsync(string expectedPropName, string componentName = default) + { + string userContext = "myContext"; + + await _deviceClient + .SubscribeToWritablePropertiesEventAsync( + (patch, context) => + { + _logger.Trace($"{nameof(SetClientPropertyUpdateCallbackHandlerAsync)}: DeviceClient {_testDevice.Id} callback property: WritableProperty: {patch}, {context}"); + + try + { + bool isPropertyPresent = componentName == null + ? patch.TryGetValue(expectedPropName, out T propertyFromCollection) + : patch.TryGetValue(componentName, expectedPropName, out propertyFromCollection); + + isPropertyPresent.Should().BeTrue(); + propertyFromCollection.Should().BeEquivalentTo((T)ExpectedClientPropertyValue); + context.Should().Be(userContext); + } + catch (Exception ex) + { + _clientPropertyExceptionDispatch = ExceptionDispatchInfo.Capture(ex); + } + finally + { + // Always notify that we got the callback. + _clientPropertyCallbackSemaphore.Release(); + } + + return Task.FromResult(true); + }, + userContext) + .ConfigureAwait(false); + } + + public async Task WaitForClientPropertyUpdateCallbcakAsync(CancellationToken ct) + { + await _clientPropertyCallbackSemaphore.WaitAsync(ct).ConfigureAwait(false); + _clientPropertyExceptionDispatch?.Throw(); + } + public void Dispose() { _methodCallbackSemaphore?.Dispose(); diff --git a/e2e/test/iothub/properties/PropertiesE2ETests.cs b/e2e/test/iothub/properties/PropertiesE2ETests.cs new file mode 100644 index 0000000000..0a2da2c3c7 --- /dev/null +++ b/e2e/test/iothub/properties/PropertiesE2ETests.cs @@ -0,0 +1,448 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Azure.Devices.Client; +using Microsoft.Azure.Devices.Client.Exceptions; +using Microsoft.Azure.Devices.E2ETests.Helpers; +using Microsoft.Azure.Devices.Shared; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; + +namespace Microsoft.Azure.Devices.E2ETests.Properties +{ + [TestClass] + [TestCategory("E2E")] + [TestCategory("IoTHub")] + public class PropertiesE2ETests : E2EMsTestBase + { + private readonly string _devicePrefix = $"E2E_{nameof(PropertiesE2ETests)}_"; + + private static readonly RegistryManager s_registryManager = RegistryManager.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + private static readonly TimeSpan s_maxWaitTimeForCallback = TimeSpan.FromSeconds(30); + + private static readonly Dictionary s_mapOfPropertyValues = new Dictionary + { + { "key1", 123 }, + { "key2", "someString" }, + { "key3", true } + }; + + [LoggedTestMethod] + public async Task Properties_DeviceSetsPropertyAndGetsItBack_Mqtt() + { + await Properties_DeviceSetsPropertyAndGetsItBackSingleDeviceAsync( + Client.TransportType.Mqtt_Tcp_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceSetsPropertyAndGetsItBack_MqttWs() + { + await Properties_DeviceSetsPropertyAndGetsItBackSingleDeviceAsync( + Client.TransportType.Mqtt_WebSocket_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceSetsPropertyMapAndGetsItBack_Mqtt() + { + await Properties_DeviceSetsPropertyMapAndGetsItBackSingleDeviceAsync( + Client.TransportType.Mqtt_Tcp_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceSetsPropertyMapAndGetsItBack_MqttWs() + { + await Properties_DeviceSetsPropertyMapAndGetsItBackSingleDeviceAsync( + Client.TransportType.Mqtt_WebSocket_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_ServiceSetsWritablePropertyAndDeviceUnsubscribes_Mqtt() + { + await Properties_ServiceSetsWritablePropertyAndDeviceUnsubscribes( + Client.TransportType.Mqtt_Tcp_Only, + Guid.NewGuid().ToString()) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_ServiceSetsWritablePropertyAndDeviceUnsubscribes_MqttWs() + { + await Properties_ServiceSetsWritablePropertyAndDeviceUnsubscribes( + Client.TransportType.Mqtt_WebSocket_Only, + Guid.NewGuid().ToString()) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_ServiceSetsWritablePropertyAndDeviceReceivesEvent_Mqtt() + { + await Properties_ServiceSetsWritablePropertyAndDeviceReceivesEventAsync( + Client.TransportType.Mqtt_Tcp_Only, + Guid.NewGuid().ToString()) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_ServiceSetsWritablePropertyAndDeviceReceivesEvent_MqttWs() + { + await Properties_ServiceSetsWritablePropertyAndDeviceReceivesEventAsync( + Client.TransportType.Mqtt_WebSocket_Only, + Guid.NewGuid().ToString()) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_ServiceSetsWritablePropertyMapAndDeviceReceivesEvent_Mqtt() + { + await Properties_ServiceSetsWritablePropertyAndDeviceReceivesEventAsync( + Client.TransportType.Mqtt_Tcp_Only, + s_mapOfPropertyValues) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_ServiceSetsWritablePropertyMapAndDeviceReceivesEvent_MqttWs() + { + await Properties_ServiceSetsWritablePropertyAndDeviceReceivesEventAsync( + Client.TransportType.Mqtt_WebSocket_Only, + s_mapOfPropertyValues) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_ServiceSetsWritablePropertyAndDeviceReceivesItOnNextGet_Mqtt() + { + await Properties_ServiceSetsWritablePropertyAndDeviceReceivesItOnNextGetAsync( + Client.TransportType.Mqtt_Tcp_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_ServiceSetsWritablePropertyAndDeviceReceivesItOnNextGet_MqttWs() + { + await Properties_ServiceSetsWritablePropertyAndDeviceReceivesItOnNextGetAsync( + Client.TransportType.Mqtt_WebSocket_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceSetsPropertyAndServiceReceivesIt_Mqtt() + { + await Properties_DeviceSetsPropertyAndServiceReceivesItAsync( + Client.TransportType.Mqtt_Tcp_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceSetsPropertyAndServiceReceivesIt_MqttWs() + { + await Properties_DeviceSetsPropertyAndServiceReceivesItAsync( + Client.TransportType.Mqtt_WebSocket_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceSendsNullValueForPropertyResultsServiceRemovingIt_Mqtt() + { + await Properties_DeviceSendsNullValueForPropertyResultsServiceRemovingItAsync( + Client.TransportType.Mqtt_Tcp_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceSendsNullValueForPropertyResultsServiceRemovingIt_MqttWs() + { + await Properties_DeviceSendsNullValueForPropertyResultsServiceRemovingItAsync( + Client.TransportType.Mqtt_WebSocket_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_ClientHandlesRejectionInvalidPropertyName_Mqtt() + { + await Properties_ClientHandlesRejectionInvalidPropertyNameAsync( + Client.TransportType.Mqtt_Tcp_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_ClientHandlesRejectionInvalidPropertyName_MqttWs() + { + await Properties_ClientHandlesRejectionInvalidPropertyNameAsync( + Client.TransportType.Mqtt_WebSocket_Only) + .ConfigureAwait(false); + } + + private async Task Properties_DeviceSetsPropertyAndGetsItBackSingleDeviceAsync(Client.TransportType transport) + { + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + await Properties_DeviceSetsPropertyAndGetsItBackAsync(deviceClient, testDevice.Id, Guid.NewGuid().ToString(), Logger).ConfigureAwait(false); + } + + private async Task Properties_DeviceSetsPropertyMapAndGetsItBackSingleDeviceAsync(Client.TransportType transport) + { + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + await Properties_DeviceSetsPropertyAndGetsItBackAsync(deviceClient, testDevice.Id, s_mapOfPropertyValues, Logger).ConfigureAwait(false); + } + + public static async Task Properties_DeviceSetsPropertyAndGetsItBackAsync(DeviceClient deviceClient, string deviceId, T propValue, MsTestLogger logger) + { + string propName = Guid.NewGuid().ToString(); + + logger.Trace($"{nameof(Properties_DeviceSetsPropertyAndGetsItBackAsync)}: name={propName}, value={propValue}"); + + var props = new ClientPropertyCollection(); + props.AddRootProperty(propName, propValue); + await deviceClient.UpdateClientPropertiesAsync(props).ConfigureAwait(false); + + // Validate the updated properties from the device-client + ClientProperties clientProperties = await deviceClient.GetClientPropertiesAsync().ConfigureAwait(false); + bool isPropertyPresent = clientProperties.TryGetValue(propName, out T propFromCollection); + isPropertyPresent.Should().BeTrue(); + propFromCollection.Should().BeEquivalentTo(propValue); + + // Validate the updated twin from the service-client + Twin completeTwin = await s_registryManager.GetTwinAsync(deviceId).ConfigureAwait(false); + dynamic actualProp = completeTwin.Properties.Reported[propName]; + + // The value will be retrieved as a TwinCollection, so we'll serialize the value and then compare. + string serializedActualPropertyValue = JsonConvert.SerializeObject(actualProp); + serializedActualPropertyValue.Should().Be(JsonConvert.SerializeObject(propValue)); + } + + public static async Task RegistryManagerUpdateWritablePropertyAsync(string deviceId, string propName, T propValue) + { + using var registryManager = RegistryManager.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + + var twinPatch = new Twin(); + twinPatch.Properties.Desired[propName] = propValue; + + await registryManager.UpdateTwinAsync(deviceId, twinPatch, "*").ConfigureAwait(false); + await registryManager.CloseAsync().ConfigureAwait(false); + } + + private async Task Properties_ServiceSetsWritablePropertyAndDeviceUnsubscribes(Client.TransportType transport, object propValue) + { + string propName = Guid.NewGuid().ToString(); + + Logger.Trace($"{nameof(Properties_ServiceSetsWritablePropertyAndDeviceReceivesEventAsync)}: name={propName}, value={propValue}"); + + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + // Set a callback + await deviceClient. + SubscribeToWritablePropertiesEventAsync( + (patch, context) => + { + Assert.Fail("After having unsubscribed from receiving client property update notifications " + + "this callback should not have been invoked."); + + return Task.FromResult(true); + }, + null) + .ConfigureAwait(false); + + // Unsubscribe + await deviceClient + .SubscribeToWritablePropertiesEventAsync(null, null) + .ConfigureAwait(false); + + await RegistryManagerUpdateWritablePropertyAsync(testDevice.Id, propName, propValue) + .ConfigureAwait(false); + + await deviceClient.CloseAsync().ConfigureAwait(false); + } + + private async Task Properties_ServiceSetsWritablePropertyAndDeviceReceivesEventAsync(Client.TransportType transport, T propValue) + { + using var cts = new CancellationTokenSource(s_maxWaitTimeForCallback); + string propName = Guid.NewGuid().ToString(); + + Logger.Trace($"{nameof(Properties_ServiceSetsWritablePropertyAndDeviceReceivesEventAsync)}: name={propName}, value={propValue}"); + + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + using var testDeviceCallbackHandler = new TestDeviceCallbackHandler(deviceClient, testDevice, Logger); + + await testDeviceCallbackHandler.SetClientPropertyUpdateCallbackHandlerAsync(propName).ConfigureAwait(false); + testDeviceCallbackHandler.ExpectedClientPropertyValue = propValue; + + await Task + .WhenAll( + RegistryManagerUpdateWritablePropertyAsync(testDevice.Id, propName, propValue), + testDeviceCallbackHandler.WaitForClientPropertyUpdateCallbcakAsync(cts.Token)) + .ConfigureAwait(false); + + // Validate the updated properties from the device-client + ClientProperties clientProperties = await deviceClient.GetClientPropertiesAsync().ConfigureAwait(false); + bool isPropertyPresent = clientProperties.Writable.TryGetValue(propName, out T propValueFromCollection); + isPropertyPresent.Should().BeTrue(); + propValueFromCollection.Should().BeEquivalentTo(propValue); + + // Validate the updated twin from the service-client + Twin completeTwin = await s_registryManager.GetTwinAsync(testDevice.Id).ConfigureAwait(false); + dynamic actualProp = completeTwin.Properties.Desired[propName]; + + // The value will be retrieved as a TwinCollection, so we'll serialize the value and then compare. + string serializedActualPropertyValue = JsonConvert.SerializeObject(actualProp); + serializedActualPropertyValue.Should().Be(JsonConvert.SerializeObject(propValue)); + + await deviceClient.SubscribeToWritablePropertiesEventAsync(null, null).ConfigureAwait(false); + await deviceClient.CloseAsync().ConfigureAwait(false); + } + + private async Task Properties_ServiceSetsWritablePropertyAndDeviceReceivesItOnNextGetAsync(Client.TransportType transport) + { + string propName = Guid.NewGuid().ToString(); + string propValue = Guid.NewGuid().ToString(); + + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var registryManager = RegistryManager.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + var twinPatch = new Twin(); + twinPatch.Properties.Desired[propName] = propValue; + await registryManager.UpdateTwinAsync(testDevice.Id, twinPatch, "*").ConfigureAwait(false); + + ClientProperties clientProperties = await deviceClient.GetClientPropertiesAsync().ConfigureAwait(false); + bool isPropertyPresent = clientProperties.Writable.TryGetValue(propName, out string propFromCollection); + isPropertyPresent.Should().BeTrue(); + propFromCollection.Should().Be(propValue); + + await deviceClient.CloseAsync().ConfigureAwait(false); + await registryManager.CloseAsync().ConfigureAwait(false); + } + + private async Task Properties_DeviceSetsPropertyAndServiceReceivesItAsync(Client.TransportType transport) + { + string propName = Guid.NewGuid().ToString(); + string propValue = Guid.NewGuid().ToString(); + + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var registryManager = RegistryManager.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + var patch = new ClientPropertyCollection(); + patch.AddRootProperty(propName, propValue); + await deviceClient.UpdateClientPropertiesAsync(patch).ConfigureAwait(false); + await deviceClient.CloseAsync().ConfigureAwait(false); + + Twin serviceTwin = await registryManager.GetTwinAsync(testDevice.Id).ConfigureAwait(false); + dynamic actualProp = serviceTwin.Properties.Reported[propName]; + + // The value will be retrieved as a TwinCollection, so we'll serialize the value and then compare. + string serializedActualPropertyValue = JsonConvert.SerializeObject(actualProp); + serializedActualPropertyValue.Should().Be(JsonConvert.SerializeObject(propValue)); + } + + private async Task Properties_DeviceSendsNullValueForPropertyResultsServiceRemovingItAsync(Client.TransportType transport) + { + string propName1 = Guid.NewGuid().ToString(); + string propName2 = Guid.NewGuid().ToString(); + string propValue = Guid.NewGuid().ToString(); + string propEmptyValue = "{}"; + + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var registryManager = RegistryManager.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + // First send a property patch with valid values for both prop1 and prop2. + await deviceClient + .UpdateClientPropertiesAsync( + new ClientPropertyCollection + { + [propName1] = new Dictionary + { + [propName2] = propValue + } + }) + .ConfigureAwait(false); + Twin serviceTwin = await registryManager.GetTwinAsync(testDevice.Id).ConfigureAwait(false); + serviceTwin.Properties.Reported.Contains(propName1).Should().BeTrue(); + + TwinCollection prop1Value = serviceTwin.Properties.Reported[propName1]; + prop1Value.Contains(propName2).Should().BeTrue(); + + string prop2Value = prop1Value[propName2]; + prop2Value.Should().Be(propValue); + + // Sending a null value for a property will result in service removing the property from the client's twin representation. + // For the property patch sent here will result in propName2 being removed. + await deviceClient + .UpdateClientPropertiesAsync( + new ClientPropertyCollection + { + [propName1] = new Dictionary + { + [propName2] = null + } + }) + .ConfigureAwait(false); + serviceTwin = await registryManager.GetTwinAsync(testDevice.Id).ConfigureAwait(false); + serviceTwin.Properties.Reported.Contains(propName1).Should().BeTrue(); + + string serializedActualProperty = JsonConvert.SerializeObject(serviceTwin.Properties.Reported[propName1]); + serializedActualProperty.Should().Be(propEmptyValue); + + // For the property patch sent here will result in propName1 being removed. + await deviceClient + .UpdateClientPropertiesAsync( + new ClientPropertyCollection + { + [propName1] = null + }) + .ConfigureAwait(false); + serviceTwin = await registryManager.GetTwinAsync(testDevice.Id).ConfigureAwait(false); + serviceTwin.Properties.Reported.Contains(propName1).Should().BeFalse(); + } + + private async Task Properties_ClientHandlesRejectionInvalidPropertyNameAsync(Client.TransportType transport) + { + string propName1 = "$" + Guid.NewGuid().ToString(); + string propName2 = Guid.NewGuid().ToString(); + + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var registryManager = RegistryManager.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + Func func = async () => + { + await deviceClient + .UpdateClientPropertiesAsync( + new ClientPropertyCollection + { + [propName1] = 123, + [propName2] = "abcd" + }) + .ConfigureAwait(false); + }; + await func.Should().ThrowAsync(); + + Twin serviceTwin = await registryManager.GetTwinAsync(testDevice.Id).ConfigureAwait(false); + serviceTwin.Properties.Reported.Contains(propName1).Should().BeFalse(); + serviceTwin.Properties.Reported.Contains(propName2).Should().BeFalse(); + } + } + + internal class CustomClientProperty + { + // The properties in here need to be public otherwise NewtonSoft.Json cannot serialize and deserialize them properly. + public int Id { get; set; } + + public string Name { get; set; } + } +} diff --git a/e2e/test/iothub/properties/PropertiesWithComponentsE2ETests.cs b/e2e/test/iothub/properties/PropertiesWithComponentsE2ETests.cs new file mode 100644 index 0000000000..156c67f9fd --- /dev/null +++ b/e2e/test/iothub/properties/PropertiesWithComponentsE2ETests.cs @@ -0,0 +1,480 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Azure.Devices.Client; +using Microsoft.Azure.Devices.Client.Exceptions; +using Microsoft.Azure.Devices.E2ETests.Helpers; +using Microsoft.Azure.Devices.Shared; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; + +namespace Microsoft.Azure.Devices.E2ETests.Properties +{ + [TestClass] + [TestCategory("E2E")] + [TestCategory("IoTHub")] + public class PropertiesWithComponentsE2ETests : E2EMsTestBase + { + public const string ComponentName = "testableComponent"; + + private readonly string _devicePrefix = $"E2E_{nameof(PropertiesWithComponentsE2ETests)}_"; + + private static readonly RegistryManager s_registryManager = RegistryManager.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + private static readonly TimeSpan s_maxWaitTimeForCallback = TimeSpan.FromSeconds(30); + + private static readonly Dictionary s_mapOfPropertyValues = new Dictionary + { + { "key1", 123 }, + { "key2", "someString" }, + { "key3", true } + }; + + [LoggedTestMethod] + public async Task PropertiesWithComponents_DeviceSetsPropertyAndGetsItBack_Mqtt() + { + await PropertiesWithComponents_DeviceSetsPropertyAndGetsItBackSingleDeviceAsync( + Client.TransportType.Mqtt_Tcp_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_DeviceSetsPropertyAndGetsItBack_MqttWs() + { + await PropertiesWithComponents_DeviceSetsPropertyAndGetsItBackSingleDeviceAsync( + Client.TransportType.Mqtt_WebSocket_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_DeviceSetsPropertyMapAndGetsItBack_Mqtt() + { + await PropertiesWithComponents_DeviceSetsPropertyMapAndGetsItBackSingleDeviceAsync( + Client.TransportType.Mqtt_Tcp_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_DeviceSetsPropertyMapAndGetsItBack_MqttWs() + { + await PropertiesWithComponents_DeviceSetsPropertyMapAndGetsItBackSingleDeviceAsync( + Client.TransportType.Mqtt_WebSocket_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceUnsubscribes_Mqtt() + { + await PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceUnsubscribes( + Client.TransportType.Mqtt_Tcp_Only, + Guid.NewGuid()) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceUnsubscribes_MqttWs() + { + await PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceUnsubscribes( + Client.TransportType.Mqtt_WebSocket_Only, + Guid.NewGuid()) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceReceivesEvent_Mqtt() + { + await PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceReceivesEventAsync( + Client.TransportType.Mqtt_Tcp_Only, + Guid.NewGuid()) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceReceivesEvent_MqttWs() + { + await PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceReceivesEventAsync( + Client.TransportType.Mqtt_WebSocket_Only, + Guid.NewGuid()) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_ServiceSetsWritablePropertyMapAndDeviceReceivesEvent_Mqtt() + { + await PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceReceivesEventAsync( + Client.TransportType.Mqtt_Tcp_Only, + s_mapOfPropertyValues) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_ServiceSetsWritablePropertyMapAndDeviceReceivesEvent_MqttWs() + { + await PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceReceivesEventAsync( + Client.TransportType.Mqtt_WebSocket_Only, + s_mapOfPropertyValues) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceReceivesItOnNextGet_Mqtt() + { + await PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceReceivesItOnNextGetAsync( + Client.TransportType.Mqtt_Tcp_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceReceivesItOnNextGet_MqttWs() + { + await PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceReceivesItOnNextGetAsync( + Client.TransportType.Mqtt_WebSocket_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_DeviceSetsPropertyAndServiceReceivesIt_Mqtt() + { + await PropertiesWithComponents_DeviceSetsPropertyAndServiceReceivesItAsync( + Client.TransportType.Mqtt_Tcp_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_DeviceSetsPropertyAndServiceReceivesIt_MqttWs() + { + await PropertiesWithComponents_DeviceSetsPropertyAndServiceReceivesItAsync( + Client.TransportType.Mqtt_WebSocket_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceSendsNullValueForPropertyResultsServiceRemovingItAsync_Mqtt() + { + await Properties_DeviceSendsNullValueForPropertyResultsServiceRemovingItAsync( + Client.TransportType.Mqtt_Tcp_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceSendsNullValueForPropertyResultsServiceRemovingItAsync_MqttWs() + { + await Properties_DeviceSendsNullValueForPropertyResultsServiceRemovingItAsync( + Client.TransportType.Mqtt_WebSocket_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_ClientHandlesRejectionInvalidPropertyName_Mqtt() + { + await PropertiesWithComponents_ClientHandlesRejectionInvalidPropertyNameAsync( + Client.TransportType.Mqtt_Tcp_Only) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task PropertiesWithComponents_ClientHandlesRejectionInvalidPropertyName_MqttWs() + { + await PropertiesWithComponents_ClientHandlesRejectionInvalidPropertyNameAsync( + Client.TransportType.Mqtt_WebSocket_Only) + .ConfigureAwait(false); + } + + private async Task PropertiesWithComponents_DeviceSetsPropertyAndGetsItBackSingleDeviceAsync(Client.TransportType transport) + { + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + await PropertiesWithComponents_DeviceSetsPropertyAndGetsItBackAsync(deviceClient, testDevice.Id, Guid.NewGuid().ToString(), Logger).ConfigureAwait(false); + } + + private async Task PropertiesWithComponents_DeviceSetsPropertyMapAndGetsItBackSingleDeviceAsync(Client.TransportType transport) + { + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + await PropertiesWithComponents_DeviceSetsPropertyAndGetsItBackAsync(deviceClient, testDevice.Id, s_mapOfPropertyValues, Logger).ConfigureAwait(false); + } + + public static async Task PropertiesWithComponents_DeviceSetsPropertyAndGetsItBackAsync(DeviceClient deviceClient, string deviceId, T propValue, MsTestLogger logger) + { + string propName = Guid.NewGuid().ToString(); + + logger.Trace($"{nameof(PropertiesWithComponents_DeviceSetsPropertyAndGetsItBackAsync)}: name={propName}, value={propValue}"); + + var props = new ClientPropertyCollection(); + props.AddComponentProperty(ComponentName, propName, propValue); + await deviceClient.UpdateClientPropertiesAsync(props).ConfigureAwait(false); + + // Validate the updated properties from the device-client + ClientProperties clientProperties = await deviceClient.GetClientPropertiesAsync().ConfigureAwait(false); + bool isPropertyPresent = clientProperties.TryGetValue(ComponentName, propName, out T propFromCollection); + isPropertyPresent.Should().BeTrue(); + propFromCollection.Should().BeEquivalentTo(propValue); + + // Validate the updated twin from the service-client + Twin completeTwin = await s_registryManager.GetTwinAsync(deviceId).ConfigureAwait(false); + dynamic actualProp = completeTwin.Properties.Reported[ComponentName][propName]; + + // The value will be retrieved as a TwinCollection, so we'll serialize the value and then compare. + string serializedActualPropertyValue = JsonConvert.SerializeObject(actualProp); + serializedActualPropertyValue.Should().Be(JsonConvert.SerializeObject(propValue)); + } + + public static async Task RegistryManagerUpdateWritablePropertyAsync(string deviceId, string componentName, string propName, T propValue) + { + using var registryManager = RegistryManager.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + + var twinPatch = new Twin(); + var componentProperties = new TwinCollection + { + [ConventionBasedConstants.ComponentIdentifierKey] = ConventionBasedConstants.ComponentIdentifierValue, + [propName] = propValue + }; + twinPatch.Properties.Desired[componentName] = componentProperties; + + await registryManager.UpdateTwinAsync(deviceId, twinPatch, "*").ConfigureAwait(false); + await registryManager.CloseAsync().ConfigureAwait(false); + } + + private async Task PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceUnsubscribes(Client.TransportType transport, T propValue) + { + string propName = Guid.NewGuid().ToString(); + + Logger.Trace($"{nameof(PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceReceivesEventAsync)}: name={propName}, value={propValue}"); + + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + // Set a callback + await deviceClient. + SubscribeToWritablePropertiesEventAsync( + (patch, context) => + { + Assert.Fail("After having unsubscribed from receiving client property update notifications " + + "this callback should not have been invoked."); + + return Task.FromResult(true); + }, + null) + .ConfigureAwait(false); + + // Unsubscribe + await deviceClient + .SubscribeToWritablePropertiesEventAsync(null, null) + .ConfigureAwait(false); + + await RegistryManagerUpdateWritablePropertyAsync(testDevice.Id, ComponentName, propName, propValue) + .ConfigureAwait(false); + + await deviceClient.CloseAsync().ConfigureAwait(false); + } + + private async Task PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceReceivesEventAsync(Client.TransportType transport, T propValue) + { + using var cts = new CancellationTokenSource(s_maxWaitTimeForCallback); + string propName = Guid.NewGuid().ToString(); + + Logger.Trace($"{nameof(PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceReceivesEventAsync)}: name={propName}, value={propValue}"); + + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + using var testDeviceCallbackHandler = new TestDeviceCallbackHandler(deviceClient, testDevice, Logger); + + await testDeviceCallbackHandler.SetClientPropertyUpdateCallbackHandlerAsync(propName, ComponentName).ConfigureAwait(false); + testDeviceCallbackHandler.ExpectedClientPropertyValue = propValue; + + await Task + .WhenAll( + RegistryManagerUpdateWritablePropertyAsync(testDevice.Id, ComponentName, propName, propValue), + testDeviceCallbackHandler.WaitForClientPropertyUpdateCallbcakAsync(cts.Token)) + .ConfigureAwait(false); + + // Validate the updated properties from the device-client + ClientProperties clientProperties = await deviceClient.GetClientPropertiesAsync().ConfigureAwait(false); + bool isPropertyPresent = clientProperties.Writable.TryGetValue(ComponentName, propName, out T propFromCollection); + isPropertyPresent.Should().BeTrue(); + propFromCollection.Should().BeEquivalentTo(propValue); + + // Validate the updated twin from the service-client + Twin completeTwin = await s_registryManager.GetTwinAsync(testDevice.Id).ConfigureAwait(false); + dynamic actualProp = completeTwin.Properties.Desired[ComponentName][propName]; + + // The value will be retrieved as a TwinCollection, so we'll serialize the value and then compare. + string serializedActualPropertyValue = JsonConvert.SerializeObject(actualProp); + serializedActualPropertyValue.Should().Be(JsonConvert.SerializeObject(propValue)); + + await deviceClient.SubscribeToWritablePropertiesEventAsync(null, null).ConfigureAwait(false); + await deviceClient.CloseAsync().ConfigureAwait(false); + } + + private async Task PropertiesWithComponents_ServiceSetsWritablePropertyAndDeviceReceivesItOnNextGetAsync(Client.TransportType transport) + { + string propName = Guid.NewGuid().ToString(); + string propValue = Guid.NewGuid().ToString(); + + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var registryManager = RegistryManager.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + var twinPatch = new Twin(); + var componentProperties = new TwinCollection + { + [ConventionBasedConstants.ComponentIdentifierKey] = ConventionBasedConstants.ComponentIdentifierValue, + [propName] = propValue + }; + twinPatch.Properties.Desired[ComponentName] = componentProperties; + await registryManager.UpdateTwinAsync(testDevice.Id, twinPatch, "*").ConfigureAwait(false); + + ClientProperties clientProperties = await deviceClient.GetClientPropertiesAsync().ConfigureAwait(false); + bool isPropertyPresent = clientProperties.Writable.TryGetValue(ComponentName, propName, out string propFromCollection); + isPropertyPresent.Should().BeTrue(); + propFromCollection.Should().Be(propValue); + + await deviceClient.CloseAsync().ConfigureAwait(false); + await registryManager.CloseAsync().ConfigureAwait(false); + } + + private async Task PropertiesWithComponents_DeviceSetsPropertyAndServiceReceivesItAsync(Client.TransportType transport) + { + string propName = Guid.NewGuid().ToString(); + string propValue = Guid.NewGuid().ToString(); + + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var registryManager = RegistryManager.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + var patch = new ClientPropertyCollection(); + patch.AddComponentProperty(ComponentName, propName, propValue); + await deviceClient.UpdateClientPropertiesAsync(patch).ConfigureAwait(false); + await deviceClient.CloseAsync().ConfigureAwait(false); + + Twin serviceTwin = await registryManager.GetTwinAsync(testDevice.Id).ConfigureAwait(false); + dynamic actualProp = serviceTwin.Properties.Reported[ComponentName][propName]; + + // The value will be retrieved as a TwinCollection, so we'll serialize the value and then compare. + string serializedActualPropertyValue = JsonConvert.SerializeObject(actualProp); + serializedActualPropertyValue.Should().Be(JsonConvert.SerializeObject(propValue)); + } + + private async Task Properties_DeviceSendsNullValueForPropertyResultsServiceRemovingItAsync(Client.TransportType transport) + { + string propName1 = Guid.NewGuid().ToString(); + string propName2 = Guid.NewGuid().ToString(); + string propValue = Guid.NewGuid().ToString(); + string propEmptyValue = "{}"; + + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var registryManager = RegistryManager.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + // First send a property patch with valid values for both prop1 and prop2. + var propertyPatch1 = new ClientPropertyCollection(); + propertyPatch1.AddComponentProperty( + ComponentName, + propName1, + new Dictionary + { + [propName2] = propValue + }); + await deviceClient.UpdateClientPropertiesAsync(propertyPatch1).ConfigureAwait(false); + + Twin serviceTwin = await registryManager.GetTwinAsync(testDevice.Id).ConfigureAwait(false); + serviceTwin.Properties.Reported.Contains(ComponentName).Should().BeTrue(); + + TwinCollection componentPatch1 = serviceTwin.Properties.Reported[ComponentName]; + componentPatch1.Contains(propName1).Should().BeTrue(); + + TwinCollection property1Value = componentPatch1[propName1]; + property1Value.Contains(propName2).Should().BeTrue(); + + string property2Value = property1Value[propName2]; + property2Value.Should().Be(propValue); + + // Sending a null value for a property will result in service removing the property from the client's twin representation. + // For the property patch sent here will result in propName2 being removed. + var propertyPatch2 = new ClientPropertyCollection(); + propertyPatch2.AddComponentProperty( + ComponentName, + propName1, + new Dictionary + { + [propName2] = null + }); + await deviceClient.UpdateClientPropertiesAsync(propertyPatch2).ConfigureAwait(false); + + serviceTwin = await registryManager.GetTwinAsync(testDevice.Id).ConfigureAwait(false); + serviceTwin.Properties.Reported.Contains(ComponentName).Should().BeTrue(); + + TwinCollection componentPatch2 = serviceTwin.Properties.Reported[ComponentName]; + componentPatch2.Contains(propName1).Should().BeTrue(); + + string serializedActualProperty = JsonConvert.SerializeObject(componentPatch2[propName1]); + serializedActualProperty.Should().Be(propEmptyValue); + + // For the property patch sent here will result in propName1 being removed. + var propertyPatch3 = new ClientPropertyCollection(); + propertyPatch3.AddComponentProperty(ComponentName, propName1, null); + await deviceClient.UpdateClientPropertiesAsync(propertyPatch3).ConfigureAwait(false); + + serviceTwin = await registryManager.GetTwinAsync(testDevice.Id).ConfigureAwait(false); + serviceTwin.Properties.Reported.Contains(ComponentName).Should().BeTrue(); + + // The only elements within the component should be the component identifiers. + TwinCollection componentPatch3 = serviceTwin.Properties.Reported[ComponentName]; + componentPatch3.Count.Should().Be(1); + componentPatch3.Contains(ConventionBasedConstants.ComponentIdentifierKey).Should().BeTrue(); + + // For the property patch sent here will result in the component being removed. + var propertyPatch4 = new ClientPropertyCollection(); + propertyPatch4.AddComponentProperties(ComponentName, null); + await deviceClient.UpdateClientPropertiesAsync(propertyPatch4).ConfigureAwait(false); + + serviceTwin = await registryManager.GetTwinAsync(testDevice.Id).ConfigureAwait(false); + serviceTwin.Properties.Reported.Contains(ComponentName).Should().BeFalse(); + } + + private async Task PropertiesWithComponents_ClientHandlesRejectionInvalidPropertyNameAsync(Client.TransportType transport) + { + string propName1 = "$" + Guid.NewGuid().ToString(); + string propName2 = Guid.NewGuid().ToString(); + + TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, _devicePrefix).ConfigureAwait(false); + using var registryManager = RegistryManager.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + using var deviceClient = DeviceClient.CreateFromConnectionString(testDevice.ConnectionString, transport); + + Func func = async () => + { + await deviceClient + .UpdateClientPropertiesAsync( + new ClientPropertyCollection + { + { + ComponentName, + new Dictionary { + { propName1, 123 }, + { propName2, "abcd" } + } + } + }) + .ConfigureAwait(false); + }; + await func.Should().ThrowAsync(); + + Twin serviceTwin = await registryManager.GetTwinAsync(testDevice.Id).ConfigureAwait(false); + serviceTwin.Properties.Reported.Contains(ComponentName).Should().BeFalse(); + } + } + + internal class CustomClientPropertyWithComponent + { + // The properties in here need to be public otherwise NewtonSoft.Json cannot serialize and deserialize them properly. + public int Id { get; set; } + + public string Name { get; set; } + } +} diff --git a/iothub/device/src/ClientPropertyCollection.cs b/iothub/device/src/ClientPropertyCollection.cs index 45aef54502..1930f0bb98 100644 --- a/iothub/device/src/ClientPropertyCollection.cs +++ b/iothub/device/src/ClientPropertyCollection.cs @@ -28,7 +28,7 @@ public class ClientPropertyCollection : PayloadCollection /// /// The name of the property to add. /// The value of the property to add. - /// is null. + /// is null. /// already exists in the collection. public void AddRootProperty(string propertyName, object propertyValue) => AddInternal(new Dictionary { { propertyName, propertyValue } }, null, false); @@ -48,7 +48,6 @@ public void AddComponentProperty(string componentName, string propertyName, obje /// /// - /// /// /// /// Adds the value to the collection. @@ -59,7 +58,6 @@ public void AddComponentProperties(string componentName, IDictionary AddInternal(properties, componentName, true); /// - /// /// /// /// Adds the values to the collection. @@ -79,6 +77,7 @@ public void AddComponentProperties(string componentName, IDictionary /// /// A collection of properties to add. + /// is null. public void Add(IDictionary properties) { if (properties == null) @@ -112,7 +111,6 @@ public void AddOrUpdateComponentProperty(string componentName, string propertyNa /// /// - /// /// /// If the collection has a key that matches this will overwrite the current value. Otherwise it will attempt to add this to the collection. /// @@ -127,8 +125,9 @@ public void AddOrUpdateComponentProperties(string componentName, IDictionary AddInternal(properties, componentName, true); /// - /// /// + /// + /// /// /// If the collection has a key that matches this will overwrite the current value. Otherwise it will attempt to add this to the collection. /// @@ -232,7 +231,7 @@ public virtual bool TryGetValue(string componentName, string propertyName, ou /// /// Converts a collection to a properties collection. /// - /// This internal class is aware of the implementation of the TwinCollection ad will + /// This internal class is aware of the implementation of the TwinCollection. /// The TwinCollection object to convert. /// A convention handler that defines the content encoding and serializer to use for the payload. /// A new instance of the class from an existing using an optional . @@ -297,18 +296,20 @@ internal static ClientPropertyCollection FromClientTwinDictionary(IDictionaryThe component with the properties to add or update. /// Forces the collection to use the Add or Update behavior. /// Setting to true will simply overwrite the value. Setting to false will use - /// is null. + /// is null for a root-level property operation. private void AddInternal(IDictionary properties, string componentName = default, bool forceUpdate = false) { - if (properties == null) - { - throw new ArgumentNullException(nameof(properties)); - } - // If the componentName is null then simply add the key-value pair to Collection dictionary. // This will either insert a property or overwrite it if it already exists. if (componentName == null) { + // If both the component name and properties collection are null then throw a ArgumentNullException. + // This is not a valid use-case. + if (properties == null) + { + throw new ArgumentNullException(nameof(properties)); + } + foreach (KeyValuePair entry in properties) { if (forceUpdate) @@ -323,30 +324,38 @@ private void AddInternal(IDictionary properties, string componen } else { - // If the component name already exists within the dictionary, then the value is a dictionary containing the component level property key and values. - // Append this property dictionary to the existing property value dictionary (overwrite entries if they already exist, if forceUpdate is true). - // Otherwise, if the component name does not exist in the dictionary, then add this as a new entry. - var componentProperties = new Dictionary(); - if (Collection.ContainsKey(componentName)) - { - componentProperties = (Dictionary)Collection[componentName]; - } - foreach (KeyValuePair entry in properties) + Dictionary componentProperties = null; + + // If the supplied properties are non-null, then add or update the supplied property dictionary to the collection. + // If the supplied properties are null, then this operation is to remove a component from the client's twin representation. + // It is added to the collection as-is. + if (properties != null) { - if (forceUpdate) + // If the component name already exists within the dictionary, then the value is a dictionary containing the component level property key and values. + // Otherwise, it is added as a new entry. + componentProperties = new Dictionary(); + if (Collection.ContainsKey(componentName)) { - componentProperties[entry.Key] = entry.Value; + componentProperties = (Dictionary)Collection[componentName]; } - else + + foreach (KeyValuePair entry in properties) { - componentProperties.Add(entry.Key, entry.Value); + if (forceUpdate) + { + componentProperties[entry.Key] = entry.Value; + } + else + { + componentProperties.Add(entry.Key, entry.Value); + } } - } - // For a component level property, the property patch needs to contain the {"__t": "c"} component identifier. - if (!componentProperties.ContainsKey(ConventionBasedConstants.ComponentIdentifierKey)) - { - componentProperties[ConventionBasedConstants.ComponentIdentifierKey] = ConventionBasedConstants.ComponentIdentifierValue; + // For a component level property, the property patch needs to contain the {"__t": "c"} component identifier. + if (!componentProperties.ContainsKey(ConventionBasedConstants.ComponentIdentifierKey)) + { + componentProperties[ConventionBasedConstants.ComponentIdentifierKey] = ConventionBasedConstants.ComponentIdentifierValue; + } } Collection[componentName] = componentProperties; diff --git a/iothub/device/src/PayloadCollection.cs b/iothub/device/src/PayloadCollection.cs index 2faaca58c0..b4b7ea03ae 100644 --- a/iothub/device/src/PayloadCollection.cs +++ b/iothub/device/src/PayloadCollection.cs @@ -33,6 +33,14 @@ public abstract class PayloadCollection : IEnumerable /// /// This accessor is best used to access and cast to simple types. /// It is recommended to use to deserialize to a complex type. + /// + /// + /// For setting component-level property values see + /// and instead. + /// These convenience methods ensure that component-level properties include the component identifier markers { "__t": "c" }. + /// For more information see . + /// + /// /// /// Key of value. /// The specified property. @@ -47,7 +55,7 @@ public virtual object this[string key] /// /// /// For property operations see - /// and instead. + /// and instead. /// /// /// @@ -63,7 +71,7 @@ public virtual void Add(string key, object value) /// /// /// For property operations see - /// and instead. + /// and instead. /// /// The name of the telemetry. /// The value of the telemetry. diff --git a/iothub/device/tests/ClientPropertyCollectionTests.cs b/iothub/device/tests/ClientPropertyCollectionTests.cs index 71c58426c2..d5f3b4ec5f 100644 --- a/iothub/device/tests/ClientPropertyCollectionTests.cs +++ b/iothub/device/tests/ClientPropertyCollectionTests.cs @@ -13,11 +13,11 @@ namespace Microsoft.Azure.Devices.Client.Tests [TestCategory("Unit")] public class ClientPropertyCollectionTests { - private const string BoolPropertyName = "boolProperty"; - private const string DoublePropertyName = "doubleProperty"; - private const string FloatPropertyName = "floatProperty"; - private const string IntPropertyName = "intProperty"; - private const string ShortPropertyName = "shortProperty"; + private const string BoolPropertyName = "boolPropertyName"; + private const string DoublePropertyName = "doublePropertyName"; + private const string FloatPropertyName = "floatPropertyName"; + private const string IntPropertyName = "intPropertyName"; + private const string ShortPropertyName = "shortPropertyName"; private const string StringPropertyName = "stringPropertyName"; private const string ObjectPropertyName = "objectPropertyName"; private const string ArrayPropertyName = "arrayPropertyName"; @@ -38,7 +38,7 @@ public class ClientPropertyCollectionTests private static readonly DateTimeOffset s_dateTimePropertyValue = DateTimeOffset.Now; private static readonly CustomClientProperty s_objectPropertyValue = new CustomClientProperty { Id = 123, Name = "testName" }; - private static readonly List s_arrayPropertyValues = new List + private static readonly List s_arrayPropertyValue = new List { 1, "someString", @@ -46,7 +46,7 @@ public class ClientPropertyCollectionTests s_objectPropertyValue }; - private static readonly Dictionary s_mapPropertyValues = new Dictionary + private static readonly Dictionary s_mapPropertyValue = new Dictionary { { "key1", "value1" }, { "key2", 123 }, @@ -65,8 +65,8 @@ public void ClientPropertyCollection_CanAddSimpleObjectsAndGetBackWithoutDeviceC { IntPropertyName, IntPropertyValue }, { ShortPropertyName, ShortPropertyValue }, { ObjectPropertyName, s_objectPropertyValue }, - { ArrayPropertyName, s_arrayPropertyValues }, - { MapPropertyName, s_mapPropertyValues }, + { ArrayPropertyName, s_arrayPropertyValue }, + { MapPropertyName, s_mapPropertyValue }, { DateTimePropertyName, s_dateTimePropertyValue } }; @@ -93,12 +93,12 @@ public void ClientPropertyCollection_CanAddSimpleObjectsAndGetBackWithoutDeviceC objectOutValue.Name.Should().Be(s_objectPropertyValue.Name); clientProperties.TryGetValue(ArrayPropertyName, out List arrayOutValue); - arrayOutValue.Should().HaveSameCount(s_arrayPropertyValues); - arrayOutValue.Should().BeEquivalentTo(s_arrayPropertyValues); + arrayOutValue.Should().HaveSameCount(s_arrayPropertyValue); + arrayOutValue.Should().BeEquivalentTo(s_arrayPropertyValue); clientProperties.TryGetValue(MapPropertyName, out Dictionary mapOutValue); - mapOutValue.Should().HaveSameCount(s_mapPropertyValues); - mapOutValue.Should().BeEquivalentTo(s_mapPropertyValues); + mapOutValue.Should().HaveSameCount(s_mapPropertyValue); + mapOutValue.Should().BeEquivalentTo(s_mapPropertyValue); clientProperties.TryGetValue(DateTimePropertyName, out DateTimeOffset dateTimeOutValue); dateTimeOutValue.Should().Be(s_dateTimePropertyValue); @@ -173,8 +173,8 @@ public void ClientPropertyCollection_CanAddSimpleObjectWithComponentAndGetBackWi { IntPropertyName, IntPropertyValue }, { ShortPropertyName, ShortPropertyValue }, { ObjectPropertyName, s_objectPropertyValue }, - { ArrayPropertyName, s_arrayPropertyValues }, - { MapPropertyName, s_mapPropertyValues }, + { ArrayPropertyName, s_arrayPropertyValue }, + { MapPropertyName, s_mapPropertyValue }, { DateTimePropertyName, s_dateTimePropertyValue } } } }; @@ -202,12 +202,12 @@ public void ClientPropertyCollection_CanAddSimpleObjectWithComponentAndGetBackWi objectOutValue.Name.Should().Be(s_objectPropertyValue.Name); clientProperties.TryGetValue(ComponentName, ArrayPropertyName, out List arrayOutValue); - arrayOutValue.Should().HaveSameCount(s_arrayPropertyValues); - arrayOutValue.Should().BeEquivalentTo(s_arrayPropertyValues); + arrayOutValue.Should().HaveSameCount(s_arrayPropertyValue); + arrayOutValue.Should().BeEquivalentTo(s_arrayPropertyValue); clientProperties.TryGetValue(ComponentName, MapPropertyName, out Dictionary mapOutValue); - mapOutValue.Should().HaveSameCount(s_mapPropertyValues); - mapOutValue.Should().BeEquivalentTo(s_mapPropertyValues); + mapOutValue.Should().HaveSameCount(s_mapPropertyValue); + mapOutValue.Should().BeEquivalentTo(s_mapPropertyValue); clientProperties.TryGetValue(ComponentName, DateTimePropertyName, out DateTimeOffset dateTimeOutValue); dateTimeOutValue.Should().Be(s_dateTimePropertyValue); diff --git a/iothub/device/tests/ClientPropertyCollectionTestsNewtonsoft.cs b/iothub/device/tests/ClientPropertyCollectionTestsNewtonsoft.cs index fb7c35bf9d..f212c14107 100644 --- a/iothub/device/tests/ClientPropertyCollectionTestsNewtonsoft.cs +++ b/iothub/device/tests/ClientPropertyCollectionTestsNewtonsoft.cs @@ -19,11 +19,11 @@ namespace Microsoft.Azure.Devices.Client.Tests // For the purpose of these tests we will create an instance of a Twin class to simulate the service response. public class ClientPropertyCollectionTestsNewtonsoft { - internal const string BoolPropertyName = "boolProperty"; - internal const string DoublePropertyName = "doubleProperty"; - internal const string FloatPropertyName = "floatProperty"; - internal const string IntPropertyName = "intProperty"; - internal const string ShortPropertyName = "shortProperty"; + internal const string BoolPropertyName = "boolPropertyName"; + internal const string DoublePropertyName = "doublePropertyName"; + internal const string FloatPropertyName = "floatPropertyName"; + internal const string IntPropertyName = "intPropertyName"; + internal const string ShortPropertyName = "shortPropertyName"; internal const string StringPropertyName = "stringPropertyName"; internal const string ObjectPropertyName = "objectPropertyName"; internal const string ArrayPropertyName = "arrayPropertyName"; @@ -43,7 +43,7 @@ public class ClientPropertyCollectionTestsNewtonsoft private static readonly DateTimeOffset s_dateTimePropertyValue = DateTimeOffset.Now; private static readonly CustomClientProperty s_objectPropertyValue = new CustomClientProperty { Id = 123, Name = "testName" }; - private static readonly List s_arrayPropertyValues = new List + private static readonly List s_arrayPropertyValue = new List { 1, "someString", @@ -51,7 +51,7 @@ public class ClientPropertyCollectionTestsNewtonsoft s_objectPropertyValue }; - private static readonly Dictionary s_mapPropertyValues = new Dictionary + private static readonly Dictionary s_mapPropertyValue = new Dictionary { { "key1", "value1" }, { "key2", 123 }, @@ -68,8 +68,8 @@ public class ClientPropertyCollectionTestsNewtonsoft ShortProperty = ShortPropertyValue, StringProperty = StringPropertyValue, ObjectProperty = s_objectPropertyValue, - ArrayProperty = s_arrayPropertyValues, - MapProperty = s_mapPropertyValues, + ArrayProperty = s_arrayPropertyValue, + MapProperty = s_mapPropertyValue, DateTimeProperty = s_dateTimePropertyValue }; @@ -86,8 +86,8 @@ public class ClientPropertyCollectionTestsNewtonsoft ShortProperty = ShortPropertyValue, StringProperty = StringPropertyValue, ObjectProperty = s_objectPropertyValue, - ArrayProperty = s_arrayPropertyValues, - MapProperty = s_mapPropertyValues, + ArrayProperty = s_arrayPropertyValue, + MapProperty = s_mapPropertyValue, DateTimeProperty = s_dateTimePropertyValue }, }; @@ -148,12 +148,12 @@ public void ClientPropertyCollectionNewtonsoft_CanGetValue() // The two lists won't be exactly equal since TryGetValue doesn't implement nested deserialization // => the complex object inside the list is deserialized to a JObject. clientProperties.TryGetValue(ArrayPropertyName, out List arrayOutValue); - arrayOutValue.Should().HaveSameCount(s_arrayPropertyValues); + arrayOutValue.Should().HaveSameCount(s_arrayPropertyValue); // The two dictionaries won't be exactly equal since TryGetValue doesn't implement nested deserialization // => the complex object inside the dictionary is deserialized to a JObject. clientProperties.TryGetValue(MapPropertyName, out Dictionary mapOutValue); - mapOutValue.Should().HaveSameCount(s_mapPropertyValues); + mapOutValue.Should().HaveSameCount(s_mapPropertyValue); clientProperties.TryGetValue(DateTimePropertyName, out DateTimeOffset dateTimeOutValue); dateTimeOutValue.Should().Be(s_dateTimePropertyValue); @@ -189,12 +189,12 @@ public void ClientPropertyCollectionNewtonsoft_CanGetValueWithComponent() // The two lists won't be exactly equal since TryGetValue doesn't implement nested deserialization // => the complex object inside the list is deserialized to a JObject. clientProperties.TryGetValue(ComponentName, ArrayPropertyName, out List arrayOutValue); - arrayOutValue.Should().HaveSameCount(s_arrayPropertyValues); + arrayOutValue.Should().HaveSameCount(s_arrayPropertyValue); // The two dictionaries won't be exactly equal since TryGetValue doesn't implement nested deserialization // => the complex object inside the dictionary is deserialized to a JObject. clientProperties.TryGetValue(ComponentName, MapPropertyName, out Dictionary mapOutValue); - mapOutValue.Should().HaveSameCount(s_mapPropertyValues); + mapOutValue.Should().HaveSameCount(s_mapPropertyValue); clientProperties.TryGetValue(ComponentName, DateTimePropertyName, out DateTimeOffset dateTimeOutValue); dateTimeOutValue.Should().Be(s_dateTimePropertyValue); From 08dfff33f365088d9843e410d2354774f61e4947 Mon Sep 17 00:00:00 2001 From: Abhipsa Misra Date: Fri, 4 Jun 2021 17:13:59 -0700 Subject: [PATCH 13/14] feat(e2e-tests): Add fault injection tests for properties operations (#2001) --- .../PropertiesFaultInjectionTests.cs | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 e2e/test/iothub/properties/PropertiesFaultInjectionTests.cs diff --git a/e2e/test/iothub/properties/PropertiesFaultInjectionTests.cs b/e2e/test/iothub/properties/PropertiesFaultInjectionTests.cs new file mode 100644 index 0000000000..aa32c93a20 --- /dev/null +++ b/e2e/test/iothub/properties/PropertiesFaultInjectionTests.cs @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Azure.Devices.Client; +using Microsoft.Azure.Devices.E2ETests.Helpers; +using Microsoft.Azure.Devices.E2ETests.Helpers.Templates; +using Microsoft.Azure.Devices.Shared; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Azure.Devices.E2ETests.Properties +{ + [TestClass] + [TestCategory("E2E")] + [TestCategory("IoTHub")] + [TestCategory("FaultInjection")] + public class PropertiesFaultInjectionTests : E2EMsTestBase + { + private static readonly string s_devicePrefix = $"E2E_{nameof(PropertiesFaultInjectionTests)}_"; + + [LoggedTestMethod] + public async Task Properties_DeviceUpdateClientPropertiesTcpConnRecovery_Mqtt() + { + await Properties_DeviceUpdateClientPropertiesRecoveryAsync( + Client.TransportType.Mqtt_Tcp_Only, + FaultInjection.FaultType_Tcp, + FaultInjection.FaultCloseReason_Boom, + FaultInjection.DefaultFaultDelay) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceUpdateClientPropertiesTcpConnRecovery_MqttWs() + { + await Properties_DeviceUpdateClientPropertiesRecoveryAsync( + Client.TransportType.Mqtt_WebSocket_Only, + FaultInjection.FaultType_Tcp, + FaultInjection.FaultCloseReason_Boom, + FaultInjection.DefaultFaultDelay) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceUpdateClientPropertiesGracefulShutdownRecovery_Mqtt() + { + await Properties_DeviceUpdateClientPropertiesRecoveryAsync( + Client.TransportType.Mqtt_Tcp_Only, + FaultInjection.FaultType_GracefulShutdownMqtt, + FaultInjection.FaultCloseReason_Bye, + FaultInjection.DefaultFaultDelay) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceUpdateClientPropertiesGracefulShutdownRecovery_MqttWs() + { + await Properties_DeviceUpdateClientPropertiesRecoveryAsync( + Client.TransportType.Mqtt_WebSocket_Only, + FaultInjection.FaultType_GracefulShutdownMqtt, + FaultInjection.FaultCloseReason_Bye, + FaultInjection.DefaultFaultDelay) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceReceivePropertyUpdateTcpConnRecovery_Mqtt() + { + await Properties_DeviceReceivePropertyUpdateRecoveryAsync( + Client.TransportType.Mqtt_Tcp_Only, + FaultInjection.FaultType_Tcp, + FaultInjection.FaultCloseReason_Boom, + FaultInjection.DefaultFaultDelay) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceReceivePropertyUpdateTcpConnRecovery_MqttWs() + { + await Properties_DeviceReceivePropertyUpdateRecoveryAsync( + Client.TransportType.Mqtt_WebSocket_Only, + FaultInjection.FaultType_Tcp, + FaultInjection.FaultCloseReason_Boom, + FaultInjection.DefaultFaultDelay) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceReceivePropertyUpdateGracefulShutdownRecovery_Mqtt() + { + await Properties_DeviceReceivePropertyUpdateRecoveryAsync( + Client.TransportType.Mqtt_Tcp_Only, + FaultInjection.FaultType_GracefulShutdownMqtt, + FaultInjection.FaultCloseReason_Bye, + FaultInjection.DefaultFaultDelay) + .ConfigureAwait(false); + } + + [LoggedTestMethod] + public async Task Properties_DeviceReceivePropertyUpdateGracefulShutdownRecovery_MqttWs() + { + await Properties_DeviceReceivePropertyUpdateRecoveryAsync( + Client.TransportType.Mqtt_WebSocket_Only, + FaultInjection.FaultType_GracefulShutdownMqtt, + FaultInjection.FaultCloseReason_Bye, + FaultInjection.DefaultFaultDelay) + .ConfigureAwait(false); + } + + private async Task Properties_DeviceUpdateClientPropertiesRecoveryAsync( + Client.TransportType transport, + string faultType, + string reason, + TimeSpan delayInSec, + string proxyAddress = null) + { + static async Task TestOperationAsync(DeviceClient deviceClient, TestDevice testDevice) + { + string propName = Guid.NewGuid().ToString(); + string propValue = Guid.NewGuid().ToString(); + + var properties = new ClientPropertyCollection(); + properties.AddRootProperty(propName, propValue); + + await deviceClient.UpdateClientPropertiesAsync(properties).ConfigureAwait(false); + + ClientProperties clientProperties = await deviceClient.GetClientPropertiesAsync().ConfigureAwait(false); + clientProperties.Should().NotBeNull(); + + bool isPropertyPresent = clientProperties.TryGetValue(propName, out string propFromCollection); + isPropertyPresent.Should().BeTrue(); + propFromCollection.Should().Be(propValue); + } + + await FaultInjection + .TestErrorInjectionAsync( + s_devicePrefix, + TestDeviceType.Sasl, + transport, + proxyAddress, + faultType, + reason, + delayInSec, + FaultInjection.DefaultFaultDuration, + (d, t) => { return Task.FromResult(false); }, + TestOperationAsync, + () => { return Task.FromResult(false); }, + Logger) + .ConfigureAwait(false); + } + + private async Task RegistryManagerUpdateDesiredPropertyAsync(string deviceId, string propName, string propValue) + { + using var registryManager = RegistryManager.CreateFromConnectionString(Configuration.IoTHub.ConnectionString); + + var twinPatch = new Twin(); + twinPatch.Properties.Desired[propName] = propValue; + + await registryManager.UpdateTwinAsync(deviceId, twinPatch, "*").ConfigureAwait(false); + await registryManager.CloseAsync().ConfigureAwait(false); + } + + private async Task Properties_DeviceReceivePropertyUpdateRecoveryAsync( + Client.TransportType transport, + string faultType, + string reason, + TimeSpan delayInSec, + string proxyAddress = null) + { + TestDeviceCallbackHandler testDeviceCallbackHandler = null; + using var cts = new CancellationTokenSource(FaultInjection.RecoveryTime); + + string propName = Guid.NewGuid().ToString(); + + // Configure the callback and start accepting property update notifications. + async Task InitOperationAsync(DeviceClient deviceClient, TestDevice testDevice) + { + testDeviceCallbackHandler = new TestDeviceCallbackHandler(deviceClient, testDevice, Logger); + await testDeviceCallbackHandler.SetClientPropertyUpdateCallbackHandlerAsync(propName).ConfigureAwait(false); + } + + // Change the properties from the service side and verify the device received it. + async Task TestOperationAsync(DeviceClient deviceClient, TestDevice testDevice) + { + string propValue = Guid.NewGuid().ToString(); + testDeviceCallbackHandler.ExpectedClientPropertyValue = propValue; + + Logger.Trace($"{nameof(Properties_DeviceReceivePropertyUpdateRecoveryAsync)}: name={propName}, value={propValue}"); + + await Task + .WhenAll( + RegistryManagerUpdateDesiredPropertyAsync(testDevice.Id, propName, propValue), + testDeviceCallbackHandler.WaitForClientPropertyUpdateCallbcakAsync(cts.Token)) + .ConfigureAwait(false); + } + + // Cleanup references. + Task CleanupOperationAsync() + { + testDeviceCallbackHandler?.Dispose(); + return Task.FromResult(false); + } + + await FaultInjection + .TestErrorInjectionAsync( + s_devicePrefix, + TestDeviceType.Sasl, + transport, + proxyAddress, + faultType, + reason, + delayInSec, + FaultInjection.DefaultFaultDuration, + InitOperationAsync, + TestOperationAsync, + CleanupOperationAsync, + Logger) + .ConfigureAwait(false); + } + } +} From 50ac3b8ee8b0f8cb43685adaa5720d0a7c4c263d Mon Sep 17 00:00:00 2001 From: Abhipsa Misra Date: Fri, 4 Jun 2021 17:46:42 -0700 Subject: [PATCH 14/14] fix(iot-device, shared, samples): Rename StatusCodes to CommonClientResponseCodes and add a comment to highlight ClientOptions behavior --- .../devdoc/Convention-based operations.md | 20 ++++++--- .../TemperatureControllerSample.cs | 22 +++++----- .../Thermostat/ThermostatSample.cs | 14 +++--- iothub/device/src/ClientOptions.cs | 1 + .../tests/ClientPropertyCollectionTests.cs | 4 +- ...ClientPropertyCollectionTestsNewtonsoft.cs | 2 +- shared/src/CommonClientResponseCodes.cs | 43 +++++++++++++++++++ shared/src/StatusCodes.cs | 33 -------------- 8 files changed, 79 insertions(+), 60 deletions(-) create mode 100644 shared/src/CommonClientResponseCodes.cs delete mode 100644 shared/src/StatusCodes.cs diff --git a/iothub/device/devdoc/Convention-based operations.md b/iothub/device/devdoc/Convention-based operations.md index 0e8d97735a..f917b531bc 100644 --- a/iothub/device/devdoc/Convention-based operations.md +++ b/iothub/device/devdoc/Convention-based operations.md @@ -6,6 +6,14 @@ public class ClientOptions { + public PayloadConvention PayloadConvention { get; set; } } + +public class DeviceClient : IDisposable { ++ public PayloadConvention PayloadConvention { get; } +} + +public class ModuleClient : IDisposable { ++ public PayloadConvention PayloadConvention { get; } +} ``` ```csharp @@ -85,12 +93,12 @@ public static class ConventionBasedConstants { public const string ValuePropertyName = "value"; } -public class StatusCodes { - public StatusCodes(); - public static int Accepted { get; } - public static int BadRequest { get; } - public static int NotFound { get; } - public static int OK { get; } +public class CommonClientResponseCodes { + public const int Accepted = 202; + public const int BadRequest = 400; + public const int NotFound = 404; + public const int OK = 200; + public CommonClientResponseCodes(); } ``` diff --git a/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureControllerSample.cs b/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureControllerSample.cs index a4d3ee42be..0e69f43731 100644 --- a/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureControllerSample.cs +++ b/iothub/device/samples/convention-based-samples/TemperatureController/TemperatureControllerSample.cs @@ -132,14 +132,14 @@ private async Task HandleTargetTemperatureUpdateRequestAsync(string componentNam IWritablePropertyResponse writableResponse = _deviceClient .PayloadConvention .PayloadSerializer - .CreateWritablePropertyResponse(_temperature[componentName], StatusCodes.OK, version, "Successfully updated target temperature."); + .CreateWritablePropertyResponse(_temperature[componentName], CommonClientResponseCodes.OK, version, "Successfully updated target temperature."); var reportedProperty = new ClientPropertyCollection(); reportedProperty.AddComponentProperty(componentName, targetTemperatureProperty, writableResponse); ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(reportedProperty); - _logger.LogDebug($"Property: Update - component=\"{componentName}\", {reportedProperty.GetSerializedString()} is {nameof(StatusCodes.OK)} " + + _logger.LogDebug($"Property: Update - component=\"{componentName}\", {reportedProperty.GetSerializedString()} is {nameof(CommonClientResponseCodes.OK)} " + $"with a version of {updateResponse.Version}."); } @@ -166,7 +166,7 @@ private Task HandleCommandsAsync(CommandRequest commandRequest, _logger.LogWarning($"Received a command request that isn't" + $" implemented - component name = {commandRequest.ComponentName}, command name = {commandRequest.CommandName}"); - return Task.FromResult(new CommandResponse(StatusCodes.NotFound)); + return Task.FromResult(new CommandResponse(CommonClientResponseCodes.NotFound)); } // For the default case, first check if CommandRequest.ComponentName is null. @@ -184,7 +184,7 @@ private Task HandleCommandsAsync(CommandRequest commandRequest, _logger.LogWarning($"Received a command request that isn't" + $" implemented - command name = {commandRequest.CommandName}"); - return Task.FromResult(new CommandResponse(StatusCodes.NotFound)); + return Task.FromResult(new CommandResponse(CommonClientResponseCodes.NotFound)); } } else @@ -192,7 +192,7 @@ private Task HandleCommandsAsync(CommandRequest commandRequest, _logger.LogWarning($"Received a command request that isn't" + $" implemented - component name = {commandRequest.ComponentName}, command name = {commandRequest.CommandName}"); - return Task.FromResult(new CommandResponse(StatusCodes.NotFound)); + return Task.FromResult(new CommandResponse(CommonClientResponseCodes.NotFound)); } } } @@ -214,12 +214,12 @@ private async Task HandleRebootCommandAsync(CommandRequest comm _temperatureReadingsDateTimeOffset.Clear(); _logger.LogDebug($"Command: Reboot completed."); - return new CommandResponse(StatusCodes.OK); + return new CommandResponse(CommonClientResponseCodes.OK); } catch (JsonReaderException ex) { _logger.LogDebug($"Command input for {commandRequest.CommandName} is invalid: {ex.Message}."); - return new CommandResponse(StatusCodes.BadRequest); + return new CommandResponse(CommonClientResponseCodes.BadRequest); } } @@ -254,25 +254,25 @@ private Task HandleMaxMinReportCommandAsync(CommandRequest comm $" maxTemp={report.MaximumTemperature}, minTemp={report.MinimumTemperature}, avgTemp={report.AverageTemperature}, " + $"startTime={report.StartTime.LocalDateTime}, endTime={report.EndTime.LocalDateTime}"); - return Task.FromResult(new CommandResponse(report, StatusCodes.OK)); + return Task.FromResult(new CommandResponse(report, CommonClientResponseCodes.OK)); } _logger.LogDebug($"Command: component=\"{commandRequest.ComponentName}\"," + $" no relevant readings found since {sinceInUtc.LocalDateTime}, cannot generate any report."); - return Task.FromResult(new CommandResponse(StatusCodes.NotFound)); + return Task.FromResult(new CommandResponse(CommonClientResponseCodes.NotFound)); } _logger.LogDebug($"Command: component=\"{commandRequest.ComponentName}\", no temperature readings sent yet," + $" cannot generate any report."); - return Task.FromResult(new CommandResponse(StatusCodes.NotFound)); + return Task.FromResult(new CommandResponse(CommonClientResponseCodes.NotFound)); } catch (JsonReaderException ex) { _logger.LogError($"Command input for {commandRequest.CommandName} is invalid: {ex.Message}."); - return Task.FromResult(new CommandResponse(StatusCodes.BadRequest)); + return Task.FromResult(new CommandResponse(CommonClientResponseCodes.BadRequest)); } } diff --git a/iothub/device/samples/convention-based-samples/Thermostat/ThermostatSample.cs b/iothub/device/samples/convention-based-samples/Thermostat/ThermostatSample.cs index d59d4bd3ab..f60610b5d9 100644 --- a/iothub/device/samples/convention-based-samples/Thermostat/ThermostatSample.cs +++ b/iothub/device/samples/convention-based-samples/Thermostat/ThermostatSample.cs @@ -82,14 +82,14 @@ private async Task HandlePropertyUpdatesAsync(ClientPropertyCollection writableP IWritablePropertyResponse writableResponse = _deviceClient .PayloadConvention .PayloadSerializer - .CreateWritablePropertyResponse(_temperature, StatusCodes.OK, writableProperties.Version, "Successfully updated target temperature"); + .CreateWritablePropertyResponse(_temperature, CommonClientResponseCodes.OK, writableProperties.Version, "Successfully updated target temperature"); var reportedProperty = new ClientPropertyCollection(); reportedProperty.AddRootProperty(targetTemperatureProperty, writableResponse); ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(reportedProperty); - _logger.LogDebug($"Property: Update - {reportedProperty.GetSerializedString()} is {nameof(StatusCodes.OK)} " + + _logger.LogDebug($"Property: Update - {reportedProperty.GetSerializedString()} is {nameof(CommonClientResponseCodes.OK)} " + $"with a version of {updateResponse.Version}."); break; @@ -133,25 +133,25 @@ private Task HandleCommandsAsync(CommandRequest commandRequest, $" maxTemp={report.MaximumTemperature}, minTemp={report.MinimumTemperature}, avgTemp={report.AverageTemperature}, " + $"startTime={report.StartTime.LocalDateTime}, endTime={report.EndTime.LocalDateTime}"); - return Task.FromResult(new CommandResponse(report, StatusCodes.OK)); + return Task.FromResult(new CommandResponse(report, CommonClientResponseCodes.OK)); } _logger.LogDebug($"Command: No relevant readings found since {sinceInUtc.LocalDateTime}, cannot generate any report."); - return Task.FromResult(new CommandResponse(StatusCodes.NotFound)); + return Task.FromResult(new CommandResponse(CommonClientResponseCodes.NotFound)); } catch (JsonReaderException ex) { _logger.LogError($"Command input for {commandRequest.CommandName} is invalid: {ex.Message}."); - return Task.FromResult(new CommandResponse(StatusCodes.BadRequest)); + return Task.FromResult(new CommandResponse(CommonClientResponseCodes.BadRequest)); } default: _logger.LogWarning($"Received a command request that isn't" + $" implemented - command name = {commandRequest.CommandName}"); - return Task.FromResult(new CommandResponse(StatusCodes.NotFound)); + return Task.FromResult(new CommandResponse(CommonClientResponseCodes.NotFound)); } } @@ -193,7 +193,7 @@ private async Task UpdateMaxTemperatureSinceLastRebootPropertyAsync() ClientPropertiesUpdateResponse updateResponse = await _deviceClient.UpdateClientPropertiesAsync(reportedProperties); - _logger.LogDebug($"Property: Update - {reportedProperties.GetSerializedString()} is {nameof(StatusCodes.OK)} " + + _logger.LogDebug($"Property: Update - {reportedProperties.GetSerializedString()} is {nameof(CommonClientResponseCodes.OK)} " + $"with a version of {updateResponse.Version}."); } diff --git a/iothub/device/src/ClientOptions.cs b/iothub/device/src/ClientOptions.cs index d1ea0c7546..0e4fe18039 100644 --- a/iothub/device/src/ClientOptions.cs +++ b/iothub/device/src/ClientOptions.cs @@ -8,6 +8,7 @@ namespace Microsoft.Azure.Devices.Client { /// /// Options that allow configuration of the device or module client instance during initialization. + /// Updating these options after the client has been initialized will not change the behavior of the client. /// public class ClientOptions { diff --git a/iothub/device/tests/ClientPropertyCollectionTests.cs b/iothub/device/tests/ClientPropertyCollectionTests.cs index d5f3b4ec5f..f9ea6caa2e 100644 --- a/iothub/device/tests/ClientPropertyCollectionTests.cs +++ b/iothub/device/tests/ClientPropertyCollectionTests.cs @@ -271,7 +271,7 @@ public void ClientPropertyCollection_CanAddSimpleWritablePropertyAndGetBackWitho { var clientProperties = new ClientPropertyCollection(); - var writableResponse = new NewtonsoftJsonWritablePropertyResponse(StringPropertyValue, StatusCodes.OK, 2, WritablePropertyDescription); + var writableResponse = new NewtonsoftJsonWritablePropertyResponse(StringPropertyValue, CommonClientResponseCodes.OK, 2, WritablePropertyDescription); clientProperties.AddRootProperty(StringPropertyName, writableResponse); clientProperties.TryGetValue(StringPropertyName, out NewtonsoftJsonWritablePropertyResponse outValue); @@ -286,7 +286,7 @@ public void ClientPropertyCollection_CanAddWritablePropertyWithComponentAndGetBa { var clientProperties = new ClientPropertyCollection(); - var writableResponse = new NewtonsoftJsonWritablePropertyResponse(StringPropertyValue, StatusCodes.OK, 2, WritablePropertyDescription); + var writableResponse = new NewtonsoftJsonWritablePropertyResponse(StringPropertyValue, CommonClientResponseCodes.OK, 2, WritablePropertyDescription); clientProperties.AddComponentProperty(ComponentName, StringPropertyName, writableResponse); clientProperties.TryGetValue(ComponentName, StringPropertyName, out NewtonsoftJsonWritablePropertyResponse outValue); diff --git a/iothub/device/tests/ClientPropertyCollectionTestsNewtonsoft.cs b/iothub/device/tests/ClientPropertyCollectionTestsNewtonsoft.cs index f212c14107..c7589789a6 100644 --- a/iothub/device/tests/ClientPropertyCollectionTestsNewtonsoft.cs +++ b/iothub/device/tests/ClientPropertyCollectionTestsNewtonsoft.cs @@ -95,7 +95,7 @@ public class ClientPropertyCollectionTestsNewtonsoft // Create a writable property response with the expected values. private static readonly IWritablePropertyResponse s_writablePropertyResponse = new NewtonsoftJsonWritablePropertyResponse( propertyValue: StringPropertyValue, - ackCode: StatusCodes.OK, + ackCode: CommonClientResponseCodes.OK, ackVersion: 2, ackDescription: "testableWritablePropertyDescription"); diff --git a/shared/src/CommonClientResponseCodes.cs b/shared/src/CommonClientResponseCodes.cs new file mode 100644 index 0000000000..ba09b2ec15 --- /dev/null +++ b/shared/src/CommonClientResponseCodes.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Azure.Devices.Shared +{ + /// + /// A list of common status codes to represent the response from the client. + /// + /// + /// These status codes are based on the HTTP status codes listed here . + /// + [SuppressMessage("Design", "CA1052:Static holder types should be Static or NotInheritable", + Justification = "To allow customers to extend this class we need to not mark it static.")] + public class CommonClientResponseCodes + { + /// + /// As per HTTP semantics this code indicates that the request has succeeded. + /// + public const int OK = 200; + + /// + /// As per HTTP semantics this code indicates that the request has been + /// accepted for processing, but the processing has not been completed. + /// + public const int Accepted = 202; + + /// + /// As per HTTP semantics this code indicates that the server cannot or + /// will not process the request due to something that is perceived to be a client error + /// (e.g., malformed request syntax, invalid request message framing, or deceptive request routing). + /// + public const int BadRequest = 400; + + /// + /// As per HTTP semantics this code indicates that the origin server did + /// not find a current representation for the target resource or is not + /// willing to disclose that one exists. + /// + public const int NotFound = 404; + } +} diff --git a/shared/src/StatusCodes.cs b/shared/src/StatusCodes.cs deleted file mode 100644 index a07bba5d10..0000000000 --- a/shared/src/StatusCodes.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - - -namespace Microsoft.Azure.Devices.Shared -{ - /// - /// A list of common status codes to represent the response from the client. - /// - /// - /// These status codes are based on the HTTP status codes listed here - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1052:Static holder types should be Static or NotInheritable", Justification = "To allow customers to extend this class we need to not mark it static.")] - public class StatusCodes - { - /// - /// Status code 200. - /// - public static int OK => 200; - /// - /// Status code 202. - /// - public static int Accepted => 202; - /// - /// Status code 400. - /// - public static int BadRequest => 400; - /// - /// Status code 404. - /// - public static int NotFound => 404; - } -}