diff --git a/src/ThingSet.Client/Schema/ThingSetNode.cs b/src/ThingSet.Client/Schema/ThingSetNode.cs index df1945b..7df9d63 100644 --- a/src/ThingSet.Client/Schema/ThingSetNode.cs +++ b/src/ThingSet.Client/Schema/ThingSetNode.cs @@ -5,6 +5,7 @@ */ using System; using System.Collections.ObjectModel; +using ThingSet.Common; using ThingSet.Common.Protocols; namespace ThingSet.Client.Schema; diff --git a/src/ThingSet.Client/Schema/ThingSetSchema.cs b/src/ThingSet.Client/Schema/ThingSetSchema.cs index 6243454..2b24d55 100644 --- a/src/ThingSet.Client/Schema/ThingSetSchema.cs +++ b/src/ThingSet.Client/Schema/ThingSetSchema.cs @@ -7,6 +7,7 @@ using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; +using ThingSet.Common; using ThingSet.Common.Protocols; namespace ThingSet.Client.Schema; diff --git a/src/ThingSet.Client/ThingSetClient.cs b/src/ThingSet.Client/ThingSetClient.cs index d045b2b..e3525ac 100644 --- a/src/ThingSet.Client/ThingSetClient.cs +++ b/src/ThingSet.Client/ThingSetClient.cs @@ -191,11 +191,11 @@ public IEnumerable GetNodes(ThingSetNodeEnumerationOptions options { throw new IOException("Could not connect to ThingSet endpoint."); } - _transport.Read(buffer); + int read = _transport.Read(buffer); ThingSetResponse response = (ThingSetStatus)buffer[0]; if (response.Success) { - CborReader reader = new CborReader(buffer.AsMemory().Slice(1), CborConformanceMode.Lax, allowMultipleRootLevelValues: true); + CborReader reader = new CborReader(buffer.AsMemory().Slice(1, read - 1), CborConformanceMode.Lax, allowMultipleRootLevelValues: true); reader.ReadNull(); return CborDeserialiser.Read(reader); } diff --git a/src/ThingSet.Common.Transports.Can/CanServerTransport.cs b/src/ThingSet.Common.Transports.Can/CanServerTransport.cs index 6f56ed2..95f2ca1 100644 --- a/src/ThingSet.Common.Transports.Can/CanServerTransport.cs +++ b/src/ThingSet.Common.Transports.Can/CanServerTransport.cs @@ -5,6 +5,7 @@ */ using System; using System.Collections.Concurrent; +using System.IO; using System.Threading; using System.Threading.Tasks; using SocketCANSharp; @@ -27,7 +28,7 @@ public class CanServerTransport : CanTransportBase, IServerTransport private bool _runPeerSocketHandlers = true; - private Func? _messageCallback; + private Func, Memory>? _messageCallback; public CanServerTransport(ThingSetCanInterface canInterface) : base(canInterface, leaveOpen: false) { @@ -40,7 +41,9 @@ public CanServerTransport(ThingSetCanInterface canInterface) : base(canInterface _addressClaimListener.AddressClaimed += OnAddressClaimed; } - public ValueTask ListenAsync(Func callback) + public event EventHandler? Error; + + public ValueTask ListenAsync(Func, Memory> callback) { _addressClaimListener.Listen(); @@ -120,6 +123,7 @@ private int WriteFrame(uint canId, byte[] buffer) private int WriteFdFrame(uint canId, byte[] buffer) { + CanFdFrame frame = new CanFdFrame { CanId = canId, @@ -154,8 +158,16 @@ private void RunPeerSocketHandler(object? state) int read = socket.Read(buffer); if (read > 0 && _messageCallback is not null) { - byte[] response = _messageCallback(peerId, buffer); - socket.Write(response); + try + { + Memory memory = buffer; + Memory response = _messageCallback(peerId, memory.Slice(0, read)); + socket.Write(response.ToArray()); + } + catch (Exception ex) + { + Error?.Invoke(this, new ErrorEventArgs(ex)); + } } } } diff --git a/src/ThingSet.Common.Transports.Can/ThingSet.Common.Transports.Can.csproj b/src/ThingSet.Common.Transports.Can/ThingSet.Common.Transports.Can.csproj index 0214125..c74c4e5 100644 --- a/src/ThingSet.Common.Transports.Can/ThingSet.Common.Transports.Can.csproj +++ b/src/ThingSet.Common.Transports.Can/ThingSet.Common.Transports.Can.csproj @@ -9,7 +9,7 @@ https://github.com/Brill-Power/ThingSet.Net https://github.com/Brill-Power/ThingSet.Net.git git - CAN transport for .NET ThingSet client + CAN transport for .NET ThingSet client and server ThingSet.Common.Transports.Can Apache-2.0 README.md @@ -17,7 +17,7 @@ - + diff --git a/src/ThingSet.Common.Transports.Ip/IpClientTransport.cs b/src/ThingSet.Common.Transports.Ip/IpClientTransport.cs index 63e205e..32e1dd9 100644 --- a/src/ThingSet.Common.Transports.Ip/IpClientTransport.cs +++ b/src/ThingSet.Common.Transports.Ip/IpClientTransport.cs @@ -11,14 +11,15 @@ using System.Threading; using System.Threading.Tasks; using ThingSet.Common.Protocols.Binary; +using static ThingSet.Common.Transports.Ip.Protocol; namespace ThingSet.Common.Transports.Ip; +/// +/// ThingSet client transport for IP (TCP/UDP). +/// public class IpClientTransport : IClientTransport { - private const int MessageSize = 512; - private const int MessageTypePosition = 4; - private readonly string _hostname; private readonly int _port; @@ -31,6 +32,10 @@ public class IpClientTransport : IClientTransport private Action>? _callback; + public IpClientTransport(string hostname) : this(hostname, Protocol.RequestResponsePort) + { + } + public IpClientTransport(string hostname, int port) { _hostname = hostname; @@ -70,7 +75,7 @@ public ValueTask SubscribeAsync(Action> callback) { _callback = callback; _udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - _udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, 9002)); + _udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, Protocol.PublishSubscribePort)); _subscriptionThread.Start(); return ValueTask.CompletedTask; } @@ -157,12 +162,4 @@ public void Append(byte[] buffer) Position += buffer.Length - 2; } } - - private enum MessageType - { - First = 0x0 << MessageTypePosition, - Consecutive = 0x1 << MessageTypePosition, - Last = 0x2 << MessageTypePosition, - Single = 0x3 << MessageTypePosition, - } } diff --git a/src/ThingSet.Common.Transports.Ip/IpServerTransport.cs b/src/ThingSet.Common.Transports.Ip/IpServerTransport.cs new file mode 100644 index 0000000..e1e8ebb --- /dev/null +++ b/src/ThingSet.Common.Transports.Ip/IpServerTransport.cs @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using ThingSet.Common.Protocols; +using static ThingSet.Common.Transports.Ip.Protocol; + +namespace ThingSet.Common.Transports.Ip; + +/// +/// ThingSet server transport for IP (TCP/UDP). +/// +public class IpServerTransport : IServerTransport +{ + private const int MessageSize = 512; + private const int HeaderSize = 2; + + private static readonly IPEndPoint Broadcast = new IPEndPoint(IPAddress.Broadcast, Protocol.PublishSubscribePort); + + private readonly TcpListener _listener; + private readonly UdpClient _udpClient; + + private readonly CancellationTokenSource _listenerCanceller = new CancellationTokenSource(); + private readonly Thread _listenThread; + + private byte _messageNumber; + + private Func, Memory>? _callback; + + public IpServerTransport() : this(IPAddress.Any) + { + } + + public event EventHandler? Error; + + public IpServerTransport(IPAddress listenAddress) + { + _listener = new TcpListener(listenAddress, Protocol.RequestResponsePort); + _listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + + _udpClient = new UdpClient(Protocol.PublishSubscribePort, AddressFamily.InterNetwork); + _udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + + _listenThread = new Thread(RunListener) + { + IsBackground = true, + }; + } + + public void Dispose() + { + _listenerCanceller.Cancel(); + _listener.Stop(); + _listener.Dispose(); + _udpClient.Dispose(); + } + + public ValueTask ListenAsync(Func, Memory> callback) + { + _callback = callback; + _listenThread.Start(); + return ValueTask.CompletedTask; + } + + public void PublishControl(ushort id, byte[] buffer) + { + throw new NotImplementedException(); + } + + public void PublishReport(byte[] buffer) + { + int written = 0; + MessageType messageType; + Span source = buffer; + Span frame = stackalloc byte[MessageSize + HeaderSize]; + byte sequenceNumber = 0; + while (written < buffer.Length) + { + int size = Math.Min(MessageSize, buffer.Length - written); + messageType = written == 0 ? + (buffer.Length < MessageSize ? + MessageType.Single : MessageType.First) : + (buffer.Length - written <= MessageSize) ? + MessageType.Last : MessageType.Consecutive; + frame[0] = (byte)((byte)messageType | (sequenceNumber++ & 0x0F)); + unchecked + { + frame[1] = _messageNumber++; + } + + Span slice = source.Slice(written, size); + slice.CopyTo(frame.Slice(HeaderSize)); + _udpClient.Send(frame.Slice(0, HeaderSize + size), Broadcast); + written += size; + } + } + + private async void RunListener() + { + _listener.Start(); + + while (!_listenerCanceller.IsCancellationRequested) + { + try + { + TcpClient client = await _listener.AcceptTcpClientAsync(_listenerCanceller.Token); + Task.Run(() => HandleRequest(client, _listenerCanceller.Token)).GetAwaiter(); + } + catch (OperationCanceledException) + { + } + } + } + + private async Task HandleRequest(TcpClient client, CancellationToken cancellationToken) + { + Memory buffer = new byte[8192]; + using (client) + { + try + { + await using NetworkStream stream = client.GetStream(); + int read; + while ((read = await stream.ReadAsync(buffer, cancellationToken)) != 0) + { + Memory response; + try + { + response = _callback!(client.Client.RemoteEndPoint!, buffer.Slice(0, read)); + } + catch (Exception ex) + { + Error?.Invoke(this, new ErrorEventArgs(ex)); + response = new byte[] { (byte)ThingSetStatus.InternalServerError }; + } + await stream.WriteAsync(response, cancellationToken); + } + } + catch (Exception ex) + { + Error?.Invoke(this, new ErrorEventArgs(ex)); + } + } + } +} diff --git a/src/ThingSet.Common.Transports.Ip/Protocol.cs b/src/ThingSet.Common.Transports.Ip/Protocol.cs new file mode 100644 index 0000000..fe81be3 --- /dev/null +++ b/src/ThingSet.Common.Transports.Ip/Protocol.cs @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +namespace ThingSet.Common.Transports.Ip; + +public class Protocol +{ + public const int RequestResponsePort = 9001; + public const int PublishSubscribePort = 9002; + + private const int MessageTypePosition = 4; + + internal enum MessageType + { + First = 0x0 << MessageTypePosition, + Consecutive = 0x1 << MessageTypePosition, + Last = 0x2 << MessageTypePosition, + Single = 0x3 << MessageTypePosition, + } +} \ No newline at end of file diff --git a/src/ThingSet.Common.Transports.Ip/ThingSet.Common.Transports.Ip.csproj b/src/ThingSet.Common.Transports.Ip/ThingSet.Common.Transports.Ip.csproj index 14159f2..04070df 100644 --- a/src/ThingSet.Common.Transports.Ip/ThingSet.Common.Transports.Ip.csproj +++ b/src/ThingSet.Common.Transports.Ip/ThingSet.Common.Transports.Ip.csproj @@ -13,7 +13,7 @@ https://github.com/Brill-Power/ThingSet.Net https://github.com/Brill-Power/ThingSet.Net.git git - IP transport for .NET ThingSet client + IP transport for .NET ThingSet client and server ThingSet.Common.Transports.Ip Apache-2.0 README.md diff --git a/src/ThingSet.Common/Protocols/Binary/CborDeserialiser.cs b/src/ThingSet.Common/Protocols/Binary/CborDeserialiser.cs index 32729c4..bf0b662 100644 --- a/src/ThingSet.Common/Protocols/Binary/CborDeserialiser.cs +++ b/src/ThingSet.Common/Protocols/Binary/CborDeserialiser.cs @@ -21,7 +21,7 @@ public static class CborDeserialiser case CborReaderState.UnsignedInteger: return ReadInteger(reader, state); case CborReaderState.HalfPrecisionFloat: - return reader.ReadHalf(); + return (float)reader.ReadHalf(); case CborReaderState.SinglePrecisionFloat: return reader.ReadSingle(); case CborReaderState.DoublePrecisionFloat: diff --git a/src/ThingSet.Common/Protocols/Binary/CborSerialiser.cs b/src/ThingSet.Common/Protocols/Binary/CborSerialiser.cs index 6e3d4fa..d2e5294 100644 --- a/src/ThingSet.Common/Protocols/Binary/CborSerialiser.cs +++ b/src/ThingSet.Common/Protocols/Binary/CborSerialiser.cs @@ -93,6 +93,9 @@ public static void Write(CborWriter writer, object? value) case Array a: WriteArray(writer, a); break; + case Enum e: + Write(writer, Convert.ChangeType(value, value.GetType().GetEnumUnderlyingType())); + break; default: if (value is null) { diff --git a/src/ThingSet.Common/ThingSet.Common.csproj b/src/ThingSet.Common/ThingSet.Common.csproj index 28bd65a..f7b356d 100644 --- a/src/ThingSet.Common/ThingSet.Common.csproj +++ b/src/ThingSet.Common/ThingSet.Common.csproj @@ -16,8 +16,7 @@ - - + diff --git a/src/ThingSet.Common/Protocols/ThingSetType.cs b/src/ThingSet.Common/ThingSetType.cs similarity index 50% rename from src/ThingSet.Common/Protocols/ThingSetType.cs rename to src/ThingSet.Common/ThingSetType.cs index c66da1f..cbb1e9f 100644 --- a/src/ThingSet.Common/Protocols/ThingSetType.cs +++ b/src/ThingSet.Common/ThingSetType.cs @@ -4,8 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ using System; +using System.Collections.Generic; +using System.Linq; -namespace ThingSet.Common.Protocols; +namespace ThingSet.Common; /// /// Represents the type of an item in ThingSet. @@ -22,6 +24,7 @@ public struct ThingSetType : IEquatable public static readonly ThingSetType UInt64 = "u64"; public static readonly ThingSetType Int64 = "i64"; public static readonly ThingSetType Float = "f32"; + public static readonly ThingSetType Double = "f64"; public static readonly ThingSetType Decimal = "decimal"; public static readonly ThingSetType String = "string"; public static readonly ThingSetType Buffer = "buffer"; @@ -59,6 +62,78 @@ private ThingSetType(string type) /// public ThingSetType? ElementType => IsArray ? Type.Substring(0, Type.Length - 2) : (ThingSetType?)null; + public static ThingSetType GetType(Type parameterType) + { + parameterType = Nullable.GetUnderlyingType(parameterType) ?? parameterType; + + switch (System.Type.GetTypeCode(parameterType)) + { + case TypeCode.Boolean: + return Boolean; + case TypeCode.Byte: + return UInt8; + case TypeCode.SByte: + return Int8; + case TypeCode.UInt16: + return UInt16; + case TypeCode.Int16: + return Int16; + case TypeCode.UInt32: + return UInt32; + case TypeCode.Int32: + return Int32; + case TypeCode.UInt64: + return UInt64; + case TypeCode.Int64: + return Int64; + case TypeCode.Single: + return Float; + case TypeCode.Double: + return Double; + case TypeCode.Decimal: + return Decimal; + case TypeCode.String: + return String; + default: + if (parameterType == typeof(void)) + { + return new ThingSetType(System.String.Empty); + } + if (parameterType.IsArray) + { + return $"{GetType(parameterType.GetElementType()!)}"; + } + if (parameterType.IsEnum) + { + return GetType(parameterType.GetEnumUnderlyingType()); + } + if (typeof(Delegate).IsAssignableFrom(parameterType)) + { + if (parameterType.IsGenericType) + { + Type[] types = parameterType.GetGenericArguments(); + Type delegateType = parameterType.GetGenericTypeDefinition(); + IEnumerable args = types; + Type returnType; + if (delegateType.Name.StartsWith("Func")) + { + // last arg is return type + args = types.SkipLast(1); + returnType = types[types.Length - 1]; + } + else + { + // returns void + returnType = typeof(void); + } + return $"({System.String.Join(",", args.Select(a => GetType(a)))})->({GetType(returnType)})"; + } + return "()->()"; // don't think we can reflectively get the type + } + return "unknown"; + } + } + public bool Equals(ThingSetType other) => Type == other.Type; public override bool Equals(object? other) => other is ThingSetType type ? Equals(type) : false; diff --git a/src/ThingSet.Common/Transports/IServerTransport.cs b/src/ThingSet.Common/Transports/IServerTransport.cs index 5333859..071590a 100644 --- a/src/ThingSet.Common/Transports/IServerTransport.cs +++ b/src/ThingSet.Common/Transports/IServerTransport.cs @@ -4,13 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ using System; +using System.IO; using System.Threading.Tasks; namespace ThingSet.Common.Transports; public interface IServerTransport : ITransport { - ValueTask ListenAsync(Func callback); + ValueTask ListenAsync(Func, Memory> callback); + + event EventHandler? Error; void PublishControl(ushort id, byte[] buffer); void PublishReport(byte[] buffer); diff --git a/src/ThingSet.Net.sln b/src/ThingSet.Net.sln index 13c7846..aa7c33e 100644 --- a/src/ThingSet.Net.sln +++ b/src/ThingSet.Net.sln @@ -15,6 +15,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test", "Test", "{CCD2CE5A-B EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ThingSet.Test", "..\test\ThingSet.Test\ThingSet.Test.csproj", "{85D124D2-F684-491C-9790-C6B7CBF9D4F8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ThingSet.Server", "ThingSet.Server\ThingSet.Server.csproj", "{E05B4B2F-6A55-4261-98ED-EC16B529B766}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ThingSet.Server.Common", "ThingSet.Server.Common\ThingSet.Server.Common.csproj", "{4390B592-24E5-40A2-BD91-593836F871E1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,6 +45,14 @@ Global {85D124D2-F684-491C-9790-C6B7CBF9D4F8}.Debug|Any CPU.Build.0 = Debug|Any CPU {85D124D2-F684-491C-9790-C6B7CBF9D4F8}.Release|Any CPU.ActiveCfg = Release|Any CPU {85D124D2-F684-491C-9790-C6B7CBF9D4F8}.Release|Any CPU.Build.0 = Release|Any CPU + {E05B4B2F-6A55-4261-98ED-EC16B529B766}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E05B4B2F-6A55-4261-98ED-EC16B529B766}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E05B4B2F-6A55-4261-98ED-EC16B529B766}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E05B4B2F-6A55-4261-98ED-EC16B529B766}.Release|Any CPU.Build.0 = Release|Any CPU + {4390B592-24E5-40A2-BD91-593836F871E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4390B592-24E5-40A2-BD91-593836F871E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4390B592-24E5-40A2-BD91-593836F871E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4390B592-24E5-40A2-BD91-593836F871E1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/ThingSet.Server.Common/Nodes/IThingSetFunction.cs b/src/ThingSet.Server.Common/Nodes/IThingSetFunction.cs new file mode 100644 index 0000000..e462517 --- /dev/null +++ b/src/ThingSet.Server.Common/Nodes/IThingSetFunction.cs @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +using System; + +namespace ThingSet.Server.Common.Nodes; + +public interface IThingSetFunction +{ + public Delegate Function { get; } +} diff --git a/src/ThingSet.Server.Common/Nodes/IThingSetParentNode.cs b/src/ThingSet.Server.Common/Nodes/IThingSetParentNode.cs new file mode 100644 index 0000000..bde98ea --- /dev/null +++ b/src/ThingSet.Server.Common/Nodes/IThingSetParentNode.cs @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +using System.Collections.Generic; + +namespace ThingSet.Server.Common.Nodes; + +public interface IThingSetParentNode +{ + IEnumerable Children { get; } +} diff --git a/src/ThingSet.Server.Common/Nodes/IThingSetValue.cs b/src/ThingSet.Server.Common/Nodes/IThingSetValue.cs new file mode 100644 index 0000000..cf1a4fe --- /dev/null +++ b/src/ThingSet.Server.Common/Nodes/IThingSetValue.cs @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +using System; + +namespace ThingSet.Server.Common.Nodes; + +public interface IThingSetValue +{ + Type ValueType { get; } + object? Value { get; set; } +} \ No newline at end of file diff --git a/src/ThingSet.Server.Common/Nodes/ThingSetFunction.cs b/src/ThingSet.Server.Common/Nodes/ThingSetFunction.cs new file mode 100644 index 0000000..51d21c3 --- /dev/null +++ b/src/ThingSet.Server.Common/Nodes/ThingSetFunction.cs @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +using System; +using System.Collections.Generic; +using System.Linq; +using ThingSet.Common; + +namespace ThingSet.Server.Common.Nodes; + +public class ThingSetFunction : ThingSetNode, IThingSetParentNode, IThingSetFunction + where TDelegate : Delegate +{ + private readonly TDelegate _function; + + public ThingSetFunction(ushort id, string name, ushort parentId, TDelegate function) : base(id, name, parentId) + { + _function = function; + Type = ThingSetType.GetType(typeof(TDelegate)); + Children = function.Method + .GetParameters() + .Select((p, i) => + new ThingSetFunctionParameter( + (ushort)(Id + 1 + i), + $"w{p.Name}" ?? $"wParam{i + 1}", // TODO: include type + Id, + ThingSetType.GetType(p.ParameterType))) + .ToList(); + } + + public Delegate Function => _function; + + public override ThingSetType Type { get; } + + public IEnumerable Children { get; } +} + +public static class ThingSetFunction +{ + public static ThingSetFunction Create(ushort id, string name, ushort parentId, TDelegate function) + where TDelegate : Delegate + { + return new ThingSetFunction(id, name, parentId, function); + } +} \ No newline at end of file diff --git a/src/ThingSet.Server.Common/Nodes/ThingSetFunctionParameter.cs b/src/ThingSet.Server.Common/Nodes/ThingSetFunctionParameter.cs new file mode 100644 index 0000000..1b4a732 --- /dev/null +++ b/src/ThingSet.Server.Common/Nodes/ThingSetFunctionParameter.cs @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +using ThingSet.Common; + +namespace ThingSet.Server.Common.Nodes; + +public class ThingSetFunctionParameter : ThingSetNode +{ + public ThingSetFunctionParameter(ushort id, string name, ushort parentId, ThingSetType type) : base(id, name, parentId) + { + Type = type; + } + + public override ThingSetType Type { get; } +} diff --git a/src/ThingSet.Server.Common/Nodes/ThingSetGroup.cs b/src/ThingSet.Server.Common/Nodes/ThingSetGroup.cs new file mode 100644 index 0000000..56b3185 --- /dev/null +++ b/src/ThingSet.Server.Common/Nodes/ThingSetGroup.cs @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +using ThingSet.Common; + +namespace ThingSet.Server.Common.Nodes; + +public class ThingSetGroup : ThingSetParentNode +{ + public ThingSetGroup(ushort id, string name, ushort parentId) : base(id, name, parentId) + { + } + + public override ThingSetType Type => ThingSetType.Group; +} diff --git a/src/ThingSet.Server.Common/Nodes/ThingSetNode.cs b/src/ThingSet.Server.Common/Nodes/ThingSetNode.cs new file mode 100644 index 0000000..4e6419a --- /dev/null +++ b/src/ThingSet.Server.Common/Nodes/ThingSetNode.cs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +using ThingSet.Common; + +namespace ThingSet.Server.Common.Nodes; + +public abstract class ThingSetNode +{ + protected ThingSetNode(ushort id, string name, ushort parentId) + { + Id = id; + ParentId = parentId; + Name = name; + ThingSetRegistry.Register(this); + } + + ~ThingSetNode() + { + ThingSetRegistry.Unregister(this); + } + + public ushort Id { get; } + public string Name { get; } + public ushort ParentId { get; } + public abstract ThingSetType Type { get; } +} diff --git a/src/ThingSet.Server.Common/Nodes/ThingSetParentNode.cs b/src/ThingSet.Server.Common/Nodes/ThingSetParentNode.cs new file mode 100644 index 0000000..2267942 --- /dev/null +++ b/src/ThingSet.Server.Common/Nodes/ThingSetParentNode.cs @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +using System.Collections.Generic; + +namespace ThingSet.Server.Common.Nodes; + +public abstract class ThingSetParentNode : ThingSetNode, IThingSetParentNode +{ + protected ThingSetParentNode(ushort id, string name, ushort parentId) : base(id, name, parentId) + { + } + + public IEnumerable Children => ThingSetRegistry.GetChildren(ParentId); +} diff --git a/src/ThingSet.Server.Common/Nodes/ThingSetProperty.cs b/src/ThingSet.Server.Common/Nodes/ThingSetProperty.cs new file mode 100644 index 0000000..c349971 --- /dev/null +++ b/src/ThingSet.Server.Common/Nodes/ThingSetProperty.cs @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +using System; +using ThingSet.Common; + +namespace ThingSet.Server.Common.Nodes; + +public class ThingSetProperty : ThingSetNode, IThingSetValue +{ + public ThingSetProperty(ushort id, string name, ushort parentId) : base(id, name, parentId) + { + } + + public Type ValueType => typeof(TValue); + + public TValue? Value { get; set; } + + public override ThingSetType Type => ThingSetType.GetType(typeof(TValue)); + + object? IThingSetValue.Value + { + get { return Value; } + set { Value = (TValue?)value; } + } +} diff --git a/src/ThingSet.Server.Common/Nodes/ThingSetRegistry.cs b/src/ThingSet.Server.Common/Nodes/ThingSetRegistry.cs new file mode 100644 index 0000000..b8a34bd --- /dev/null +++ b/src/ThingSet.Server.Common/Nodes/ThingSetRegistry.cs @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Json.Pointer; +using ThingSet.Common; +using ThingSet.Common.Protocols; + +namespace ThingSet.Server.Common.Nodes; + +public class ThingSetRegistry +{ + private static readonly ThingSetRegistry Instance = new ThingSetRegistry(); + + public static readonly ThingSetParentNode Root = new RootNode(); + public static readonly ThingSetNode Metadata = new OverlayNode(0x19, "_Metadata"); + + private readonly IDictionary _nodesById = new ConcurrentDictionary(); + + private ThingSetRegistry() + { + } + + public static bool TryGetNode(ushort id, [NotNullWhen(true)] out ThingSetNode? node) + { + return Instance._nodesById.TryGetValue(id, out node); + } + + public static bool TryGetNode(string path, [NotNullWhen(true)] out ThingSetNode? node, [NotNullWhen(false)] out ThingSetStatus? error) + { + if (String.IsNullOrEmpty(path)) + { + // root object + node = Root; + error = null; + return true; + } + + if (path[0] == '/') + { + // what if a gateway? + node = null; + error = ThingSetStatus.NotAGateway; + return false; + } + + if (!JsonPointer.TryParse("/" + path, out JsonPointer? pointer)) + { + node = null; + error = ThingSetStatus.BadRequest; + return false; + } + + node = Root; + foreach (string segment in pointer) + { + if (node is IThingSetParentNode parent) + { + foreach (ThingSetNode child in parent.Children) + { + if (child.Name == segment) + { + node = child; + break; + } + } + } + } + if (node == Root) + { + error = ThingSetStatus.NotFound; + return false; + } + + error = null; + return true; + } + + internal static void Register(ThingSetNode node) + { + Instance._nodesById.Add(node.Id, node); + } + + internal static void Unregister(ThingSetNode node) + { + Instance._nodesById.Remove(node.Id); + } + + internal static IEnumerable GetChildren(ushort parentId) + { + return Instance._nodesById.Values.Where(n => n.ParentId == parentId && n.Id != parentId); // double check for Root node + } + + private class RootNode : ThingSetParentNode + { + public RootNode() : base(0x0, String.Empty, 0x0) + { + } + + public override ThingSetType Type => ThingSetType.Group; + } + + private class OverlayNode : ThingSetNode + { + public OverlayNode(ushort id, string name) : base(id, name, 0x0) + { + } + + public override ThingSetType Type => "overlay"; + } +} \ No newline at end of file diff --git a/src/ThingSet.Server.Common/ThingSet.Server.Common.csproj b/src/ThingSet.Server.Common/ThingSet.Server.Common.csproj new file mode 100644 index 0000000..dfbf1c2 --- /dev/null +++ b/src/ThingSet.Server.Common/ThingSet.Server.Common.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + disable + enable + Brill Power, Gareth Potter + Copyright (c) Brill Power 2025 + https://github.com/Brill-Power/ThingSet.Net + https://github.com/Brill-Power/ThingSet.Net.git + git + ThingSet.Server.Common + Apache-2.0 + README.md + iot + + + + + + + + + + + + + + + diff --git a/src/ThingSet.Server/ThingSet.Server.csproj b/src/ThingSet.Server/ThingSet.Server.csproj new file mode 100644 index 0000000..13ae83c --- /dev/null +++ b/src/ThingSet.Server/ThingSet.Server.csproj @@ -0,0 +1,32 @@ + + + + + + + + + net8.0 + disable + enable + Brill Power, Gareth Potter + Copyright (c) Brill Power 2025 + https://github.com/Brill-Power/ThingSet.Net + https://github.com/Brill-Power/ThingSet.Net.git + git + .NET ThingSet server + ThingSet.Server + Apache-2.0 + README.md + iot + + + + + + + + + + + diff --git a/src/ThingSet.Server/ThingSetServer.cs b/src/ThingSet.Server/ThingSetServer.cs new file mode 100644 index 0000000..bf708ff --- /dev/null +++ b/src/ThingSet.Server/ThingSetServer.cs @@ -0,0 +1,377 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Formats.Cbor; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using ThingSet.Server.Common.Nodes; +using ThingSet.Common.Protocols; +using ThingSet.Common.Protocols.Binary; +using ThingSet.Common.Transports; + +namespace ThingSet.Server; + +public class ThingSetServer : IDisposable +{ + private readonly IServerTransport _transport; + + private const ushort MetadataNameId = 0x1a; + private const ushort MetadataTypeId = 0x1b; + private const ushort MetadataAccessId = 0x1c; + + public ThingSetServer(IServerTransport transport) + { + _transport = transport; + } + + public async ValueTask ListenAsync() + { + await _transport.ListenAsync(OnRequestReceived); + } + + private Memory OnRequestReceived(object identifier, Memory request) + { + byte[] response = new byte[8192]; + Memory responseMem = response; + ThingSetRequestContextBase context; + Span span = request.Span; + int length; + if (span[0] > 0x20) + { + // don't support text mode at the moment + return Encoding.UTF8.GetBytes($":{(byte)ThingSetStatus.NotImplemented:X2}"); + } + else + { + context = new ThingSetBinaryRequestContext(request); + } + if (context.HasValidEndpoint) + { + if (context.IsGet) + { + length = HandleGet(context, responseMem); + } + else if (context.IsFetch) + { + length = HandleFetch(context, responseMem); + } + else if (context.IsUpdate) + { + length = HandleUpdate(context, responseMem); + } + else if (context.IsExec) + { + length = HandleExec(context, responseMem); + } + else + { + response[0] = (byte)ThingSetStatus.BadRequest; + length = 1; + } + } + else + { + response[0] = (byte)ThingSetStatus.NotFound; + length = 1; + } + return responseMem.Slice(0, length); + } + + private int HandleExec(ThingSetRequestContextBase context, Memory response) + { + Span responseSpan = response.Span; + if ((context.Endpoint?.Type.IsFunction ?? false) && + context.Endpoint is IThingSetFunction function) + { + CborReader reader = new CborReader(context.RequestBody, CborConformanceMode.Lax, allowMultipleRootLevelValues: true); + if (CborDeserialiser.Read(reader) is object[] args) + { + ParameterInfo[] parameters = function.Function.Method.GetParameters(); + if (args.Length != parameters.Length) + { + responseSpan[0] = (byte)ThingSetStatus.BadRequest; + return 1; + } + + for (int i = 0; i < args.Length; i++) + { + args[i] = Convert.ChangeType(args[i], parameters[i].ParameterType); + } + responseSpan[0] = (byte)ThingSetStatus.Changed; + object? result = function.Function.DynamicInvoke(args); + CborWriter writer = new CborWriter(CborConformanceMode.Lax, allowMultipleRootLevelValues: true); + writer.WriteNull(); + CborSerialiser.Write(writer, result); + writer.Encode(responseSpan.Slice(1)); + return writer.BytesWritten + 1; + } + else + { + responseSpan[0] = (byte)ThingSetStatus.BadRequest; + return 1; + } + } + else + { + responseSpan[0] = (byte)ThingSetStatus.MethodNotAllowed; + return 1; + } + } + + private int HandleUpdate(ThingSetRequestContextBase context, Memory response) + { + Span responseSpan = response.Span; + if (context.Endpoint is IThingSetParentNode parent) + { + CborReader reader = new CborReader(context.RequestBody, CborConformanceMode.Lax, allowMultipleRootLevelValues: true); + if (CborDeserialiser.Read(reader) is Dictionary map) + { + foreach (object key in map.Keys) + { + if (key is string path) + { + ThingSetNode? found = null; + foreach (ThingSetNode child in parent.Children) + { + if (child.Name == path) + { + found = child; + break; + } + } + + if (found == null) + { + responseSpan[0] = (byte)ThingSetStatus.NotFound; + return 1; + } + + if (found is not IThingSetValue value) + { + responseSpan[0] = (byte)ThingSetStatus.BadRequest; + return 1; + } + + try + { + value.Value = Convert.ChangeType(map[key], value.ValueType); + responseSpan[0] = (byte)ThingSetStatus.Changed; + responseSpan[1] = 0xF6; // null + responseSpan[2] = 0xF6; + return 3; + } + catch (Exception) + { + break; + } + } + else + { + responseSpan[0] = (byte)ThingSetStatus.BadRequest; + return 1; + } + } + } + } + + responseSpan[0] = (byte)ThingSetStatus.BadRequest; + return 1; + } + + private int HandleFetch(ThingSetRequestContextBase context, Memory response) + { + Span responseSpan = response.Span; + responseSpan[0] = (byte)ThingSetStatus.Content; + CborWriter writer = new CborWriter(CborConformanceMode.Lax, allowMultipleRootLevelValues: true); + writer.WriteNull(); + CborReader reader = new CborReader(context.RequestBody, CborConformanceMode.Lax, allowMultipleRootLevelValues: true); + if (reader.PeekState() == CborReaderState.Null) + { + if (context.Endpoint is IThingSetParentNode parent) + { + if (context.UseIds) + { + CborSerialiser.Write(writer, parent.Children.Select(c => c.Id).ToArray()); + } + else + { + CborSerialiser.Write(writer, parent.Children.Select(c => c.Name).ToArray()); + } + writer.Encode(responseSpan.Slice(1)); + return writer.BytesWritten + 1; + } + else + { + responseSpan[0] = (byte)ThingSetStatus.BadRequest; + return 1; + } + } + else if (context.Endpoint == ThingSetRegistry.Metadata && + reader.PeekState() == CborReaderState.StartArray) + { + if (CborDeserialiser.Read(reader) is object?[] ids) + { + List> metadata = new List>(); + foreach (uint id in ids.OfType()) + { + if (ThingSetRegistry.TryGetNode((ushort)id, out ThingSetNode? node)) + { + metadata.Add(new Dictionary + { + { MetadataNameId, node.Name }, + { MetadataTypeId, node.Type.Type }, + { MetadataAccessId, ThingSetAccess.AnyReadWrite }, // TODO + }); + } + } + CborSerialiser.Write(writer, metadata.ToArray()); + writer.Encode(responseSpan.Slice(1)); + return writer.BytesWritten + 1; + } + else + { + responseSpan[0] = (byte)ThingSetStatus.BadRequest; + return 1; + } + } + else + { + responseSpan[0] = (byte)ThingSetStatus.NotImplemented; + return 1; + } + } + + private int HandleGet(ThingSetRequestContextBase context, Memory response) + { + Span responseSpan = response.Span; + responseSpan[0] = (byte)ThingSetStatus.Content; + CborWriter writer = new CborWriter(CborConformanceMode.Lax, allowMultipleRootLevelValues: true); + writer.WriteNull(); + if (context.Endpoint is IThingSetParentNode parent) + { + if (context.UseIds) + { + CborSerialiser.Write(writer, GetKeyValuePairs(n => n.Id, parent.Children)); + } + else + { + CborSerialiser.Write(writer, GetKeyValuePairs(n => n.Name, parent.Children)); + } + writer.Encode(responseSpan.Slice(1)); + return 1 + writer.BytesWritten; + } + else if (context.Endpoint is IThingSetValue value) + { + CborSerialiser.Write(writer, value.Value); + writer.Encode(responseSpan.Slice(1)); + return 1 + writer.BytesWritten; + } + else + { + responseSpan[0] = (byte)ThingSetStatus.UnsupportedFormat; + return 1; + } + } + + private Dictionary GetKeyValuePairs(Func keySelector, IEnumerable nodes) + where TKey : IEquatable + { + Dictionary keyValuePairs = new Dictionary(); + foreach (ThingSetNode node in nodes) + { + if (node is IThingSetValue value) + { + keyValuePairs.Add(keySelector(node), value.Value); + } + } + return keyValuePairs; + } + + public void Dispose() + { + _transport.Dispose(); + } +} + +internal abstract class ThingSetRequestContextBase +{ + protected readonly Memory _request; + + protected ThingSetRequestContextBase(Memory request) + { + _request = request; + } + + public string? Path { get; protected set; } + public ushort? Id { get; protected set; } + public ThingSetNode? Endpoint { get; protected set; } + + public bool UseIds => Id.HasValue; + [MemberNotNullWhen(true, nameof(Endpoint))] + public bool HasValidEndpoint => Endpoint is not null; + + public Memory RequestBody { get; protected set; } + + public abstract bool IsGet { get; } + public abstract bool IsFetch { get; } + public abstract bool IsUpdate { get; } + public abstract bool IsExec{ get; } +} + +internal class ThingSetBinaryRequestContext : ThingSetRequestContextBase +{ + private readonly ThingSetRequest _requestType; + private readonly CborReader _cborReader; + + internal ThingSetBinaryRequestContext(Memory request) : base(request) + { + _requestType = (ThingSetRequest)request.Span[0]; + _cborReader = new CborReader(request.Slice(1), CborConformanceMode.Lax, allowMultipleRootLevelValues: true); + + CborReaderState state = _cborReader.PeekState(); + switch (state) + { + case CborReaderState.TextString: + Path = _cborReader.ReadTextString(); + break; + case CborReaderState.UnsignedInteger: + Id = (ushort)_cborReader.ReadInt32(); + break; + case CborReaderState.NegativeInteger: // unlikey to be this, but let's be pragmatic + Id = (ushort)_cborReader.ReadInt32(); + break; + default: + throw new InvalidDataException($"Unexpected CBOR data of type {state} (expected string or integer)."); + } + + if (Id.HasValue && ThingSetRegistry.TryGetNode(Id.Value, out ThingSetNode? node)) + { + Endpoint = node; + } + else if (Path != null) // empty is valid + { + if (ThingSetRegistry.TryGetNode(Path, out node, out ThingSetStatus? error)) + { + Endpoint = node; + } + else + { + // how to signal error? + } + } + RequestBody = request.Slice(request.Length - _cborReader.BytesRemaining); + } + + public override bool IsGet => _requestType == ThingSetRequest.Get; + public override bool IsFetch => _requestType == ThingSetRequest.Fetch; + public override bool IsUpdate => _requestType == ThingSetRequest.Update; + public override bool IsExec => _requestType == ThingSetRequest.Exec; +} diff --git a/test/ThingSet.Test/TestClientServer.cs b/test/ThingSet.Test/TestClientServer.cs new file mode 100644 index 0000000..06dbc4a --- /dev/null +++ b/test/ThingSet.Test/TestClientServer.cs @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using ThingSet.Client; +using ThingSet.Client.Schema; +using ThingSet.Common; +using ThingSet.Server.Common.Nodes; +using ThingSet.Common.Transports.Ip; +using ThingSet.Server; + +namespace ThingSet.Test; + +public class TestClientServer +{ + [Test] + public async Task TestProperty() + { + ThingSetProperty voltage = new ThingSetProperty(0x200, "voltage", 0x0); + voltage.Value = 24.0f; + + using (IpClientTransport clientTransport = new IpClientTransport("127.0.0.1")) + using (IpServerTransport serverTransport = new IpServerTransport(IPAddress.Loopback)) + using (ThingSetServer server = new ThingSetServer(serverTransport)) + using (ThingSetClient client = new ThingSetClient(clientTransport)) + { + await server.ListenAsync(); + await Task.Delay(10); + await client.ConnectAsync(); + var nodes = client.GetNodes(ThingSetNodeEnumerationOptions.All).ToDictionary(n => n.Id); + Assert.That(nodes, Contains.Key(0x200U)); + var node = nodes[0x200]; + Assert.That(node.Name, Is.EqualTo("voltage")); + Assert.That(node.Type, Is.EqualTo(ThingSetType.Float)); + object? result = client.Get(0x200); + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.TypeOf()); + Assert.That(result, Is.EqualTo(24.0f)); + + client.Update("voltage", 25.0f); + Assert.That(voltage.Value, Is.EqualTo(25.0f)); + } + } + + [Test] + public async Task TestFunction() + { + var function = ThingSetFunction.Create(0x500, "xTest", 0x0, (int x, int y) => x + y); + + using (IpClientTransport clientTransport = new IpClientTransport("127.0.0.1")) + using (IpServerTransport serverTransport = new IpServerTransport(IPAddress.Loopback)) + using (ThingSetServer server = new ThingSetServer(serverTransport)) + using (ThingSetClient client = new ThingSetClient(clientTransport)) + { + await server.ListenAsync(); + await Task.Delay(10); + await client.ConnectAsync(); + object? result = client.Fetch(0x500); + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.TypeOf()); + if (result is object[] array) + { + Assert.That(array.Length, Is.EqualTo(2)); + Assert.That(array[0], Is.EqualTo(0x501)); + Assert.That(array[1], Is.EqualTo(0x502)); + } + + var nodes = client.GetNodes(ThingSetNodeEnumerationOptions.All).ToDictionary(n => n.Id); + Assert.That(nodes, Contains.Key(0x500U)); + var node = nodes[0x500]; + Assert.That(node.Name, Is.EqualTo("xTest")); + Assert.That(node.Type.IsFunction, Is.True); + Assert.That(node.Type.Type, Is.EqualTo("(i32,i32)->(i32)")); + Assert.That(node.Children.Count, Is.EqualTo(2)); + + result = client.Exec(0x500, 1, 2); + Assert.That(result, Is.Not.Null); + } + } +} \ No newline at end of file diff --git a/test/ThingSet.Test/TestIpClientTransport.cs b/test/ThingSet.Test/TestIpClientTransport.cs deleted file mode 100644 index 7b87c73..0000000 --- a/test/ThingSet.Test/TestIpClientTransport.cs +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025 Brill Power. - * - * SPDX-License-Identifier: Apache-2.0 - */ -using System.Net; -using System.Net.Sockets; -using System.Threading.Tasks; -using ThingSet.Common.Transports.Ip; - -namespace ThingSet.Test; - -public class TestIpClientTransport -{ - [Test] - public async Task TestDisposal() - { - using (TcpClient server = new TcpClient(new IPEndPoint(IPAddress.Loopback, 9001))) - { - server.Client.Listen(); - IpClientTransport transport = new IpClientTransport("127.0.0.1", 9001); - await transport.ConnectAsync(); - await transport.SubscribeAsync(delegate { }); - await Task.Delay(100); - Assert.DoesNotThrow(() => transport.Dispose()); - } - } -} diff --git a/test/ThingSet.Test/TestNodes.cs b/test/ThingSet.Test/TestNodes.cs new file mode 100644 index 0000000..ad39bc8 --- /dev/null +++ b/test/ThingSet.Test/TestNodes.cs @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Brill Power. + * + * SPDX-License-Identifier: Apache-2.0 + */ +using System; +using System.Linq; +using ThingSet.Server.Common.Nodes; + +namespace ThingSet.Test; + +public class TestNodes +{ + [Test] + public void TestFunctions() + { + Func add = (x, y) => x + y; + var xAdd = ThingSetFunction.Create(0x1000, "xAdd", 0x0, add); + Assert.That(xAdd.Type.Type, Is.EqualTo("(i32,i32)->(i32)")); + Assert.That(xAdd.Children.Count(), Is.EqualTo(2)); + } +} \ No newline at end of file diff --git a/test/ThingSet.Test/ThingSet.Test.csproj b/test/ThingSet.Test/ThingSet.Test.csproj index 1b9dae2..8238046 100644 --- a/test/ThingSet.Test/ThingSet.Test.csproj +++ b/test/ThingSet.Test/ThingSet.Test.csproj @@ -11,15 +11,18 @@ - - - - + + + + + + +