From 35dd806971d1200aad3698bcb6c895926d3bc0e5 Mon Sep 17 00:00:00 2001 From: Jan Wendland Date: Sat, 12 Dec 2020 18:04:13 +0100 Subject: [PATCH] Separate transformation from transport logic --- AllMyLights.Test/AllMyLights.Test.csproj | 4 + AllMyLights.Test/BrokerTest.cs | 10 +- AllMyLights.Test/Connectors/MqttSourceTest.cs | 70 +++++++--- .../Connectors/OpenRGBSinkTest.cs | 11 +- .../Extensions/ObservableExtensionsTest.cs | 9 ++ .../ColorTransformationTest.cs | 97 +++++++++++++ .../JsonPathTransformationTest.cs | 67 +++++++++ AllMyLights/AllMyLights.csproj | 4 + AllMyLights/Common/Ref.cs | 42 ++++++ AllMyLights/Connectors/ConnectorFactory.cs | 37 +++-- AllMyLights/Connectors/Sinks/ISink.cs | 7 +- AllMyLights/Connectors/Sinks/OpenRGBSink.cs | 32 +++-- AllMyLights/Connectors/Sinks/Sink.cs | 32 +++++ AllMyLights/Connectors/Sources/ISource.cs | 5 +- AllMyLights/Connectors/Sources/MqttSource.cs | 50 +++---- AllMyLights/Connectors/Sources/Source.cs | 23 ++++ AllMyLights/ExitCode.cs | 5 + AllMyLights/Extensions/Json.cs | 17 +++ AllMyLights/Extensions/Observables.cs | 21 +++ AllMyLights/Json/ConfigurationValidator.cs | 73 ++++++++++ AllMyLights/Json/InheritanceConverter.cs | 27 ++++ AllMyLights/Json/InheritanceValidator.cs | 130 ++++++++++++++++++ AllMyLights/Json/SchemaValidationError.cs | 16 +++ AllMyLights/Models/Configuration.cs | 7 +- AllMyLights/Models/ConnectorOptions.cs | 10 ++ ...ttSourceParams.cs => MqttSourceOptions.cs} | 3 +- AllMyLights/Models/Mqtt/Topic.cs | 17 --- AllMyLights/Models/Mqtt/Topics.cs | 2 +- ...RGBSinkParams.cs => OpenRGBSinkOptions.cs} | 4 +- AllMyLights/Models/SinkOptions.cs | 14 ++ AllMyLights/Models/Sinks.cs | 10 -- AllMyLights/Models/SourceOptions.cs | 13 ++ AllMyLights/Models/Sources.cs | 10 -- .../ColorTransformationOptions.cs | 7 + .../JsonPathTransformationOptions.cs | 10 ++ .../Transformations/TransformationOptions.cs | 15 ++ AllMyLights/Platforms/Windows/TrayIcon.cs | 7 +- AllMyLights/Program.cs | 54 ++++---- .../Transformations/ColorTransformation.cs | 35 +++++ .../Transformations/ITransformation.cs | 9 ++ .../Transformations/JsonPathTransformation.cs | 37 +++++ .../Transformations/TransformationFactory.cs | 18 +++ AllMyLights/allmylightsrc.json | 60 ++++---- CHANGELOG.md | 8 ++ README.md | 89 +++++++----- 45 files changed, 999 insertions(+), 229 deletions(-) create mode 100644 AllMyLights.Test/Extensions/ObservableExtensionsTest.cs create mode 100644 AllMyLights.Test/Transformations/ColorTransformationTest.cs create mode 100644 AllMyLights.Test/Transformations/JsonPathTransformationTest.cs create mode 100644 AllMyLights/Common/Ref.cs create mode 100644 AllMyLights/Connectors/Sinks/Sink.cs create mode 100644 AllMyLights/Connectors/Sources/Source.cs create mode 100644 AllMyLights/ExitCode.cs create mode 100644 AllMyLights/Extensions/Json.cs create mode 100644 AllMyLights/Extensions/Observables.cs create mode 100644 AllMyLights/Json/ConfigurationValidator.cs create mode 100644 AllMyLights/Json/InheritanceConverter.cs create mode 100644 AllMyLights/Json/InheritanceValidator.cs create mode 100644 AllMyLights/Json/SchemaValidationError.cs create mode 100644 AllMyLights/Models/ConnectorOptions.cs rename AllMyLights/Models/Mqtt/{MqttSourceParams.cs => MqttSourceOptions.cs} (81%) delete mode 100644 AllMyLights/Models/Mqtt/Topic.cs rename AllMyLights/Models/OpenRGB/{OpenRGBSinkParams.cs => OpenRGBSinkOptions.cs} (83%) create mode 100644 AllMyLights/Models/SinkOptions.cs delete mode 100644 AllMyLights/Models/Sinks.cs create mode 100644 AllMyLights/Models/SourceOptions.cs delete mode 100644 AllMyLights/Models/Sources.cs create mode 100644 AllMyLights/Models/Transformations/ColorTransformationOptions.cs create mode 100644 AllMyLights/Models/Transformations/JsonPathTransformationOptions.cs create mode 100644 AllMyLights/Models/Transformations/TransformationOptions.cs create mode 100644 AllMyLights/Transformations/ColorTransformation.cs create mode 100644 AllMyLights/Transformations/ITransformation.cs create mode 100644 AllMyLights/Transformations/JsonPathTransformation.cs create mode 100644 AllMyLights/Transformations/TransformationFactory.cs diff --git a/AllMyLights.Test/AllMyLights.Test.csproj b/AllMyLights.Test/AllMyLights.Test.csproj index 4a104a1..f4abf1f 100644 --- a/AllMyLights.Test/AllMyLights.Test.csproj +++ b/AllMyLights.Test/AllMyLights.Test.csproj @@ -25,4 +25,8 @@ + + + + diff --git a/AllMyLights.Test/BrokerTest.cs b/AllMyLights.Test/BrokerTest.cs index c22f235..9d75be5 100644 --- a/AllMyLights.Test/BrokerTest.cs +++ b/AllMyLights.Test/BrokerTest.cs @@ -1,12 +1,8 @@ using AllMyLights.Connectors.Sinks; using AllMyLights.Connectors.Sources; -using AllMyLights.Models; using Microsoft.Reactive.Testing; using Moq; using NUnit.Framework; -using OpenRGB.NET; -using OpenRGB.NET.Models; -using Unmockable; namespace AllMyLights.Test { @@ -21,9 +17,9 @@ public class BrokerTest : ReactiveTest [Test] public void Should_invoke_sinks_with_colors_emitted_from_sources() { - var red = System.Drawing.Color.FromName("red"); - var black = System.Drawing.Color.FromName("black"); - var green = System.Drawing.Color.FromName("green"); + var red = System.Drawing.Color.FromName("red") as object; + var black = System.Drawing.Color.FromName("black") as object; + var green = System.Drawing.Color.FromName("green") as object; var broker = new Broker() .RegisterSources(MqttSource.Object, SSESource.Object) diff --git a/AllMyLights.Test/Connectors/MqttSourceTest.cs b/AllMyLights.Test/Connectors/MqttSourceTest.cs index 25a5af3..fb22c78 100644 --- a/AllMyLights.Test/Connectors/MqttSourceTest.cs +++ b/AllMyLights.Test/Connectors/MqttSourceTest.cs @@ -1,7 +1,6 @@ using System; using System.Text; using System.Threading; -using AllMyLights.Models; using Moq; using MQTTnet; using MQTTnet.Client; @@ -19,12 +18,14 @@ using MQTTnet.Client.Disconnecting; using AllMyLights.Connectors.Sources; using AllMyLights.Models.Mqtt; +using AllMyLights.Models.Transformations; +using AllMyLights.Common; namespace AllMyLights.Test { - public class MqttSourceTest : ReactiveTest + public class MqttSourceTest: ReactiveTest { - MqttSourceParams Options; + MqttSourceOptions Options; public IMqttClientOptions MqttClientOptions { get; private set; } public MqttClientTcpOptions MqttClientTcpOptions { get; private set; } @@ -34,7 +35,7 @@ public class MqttSourceTest : ReactiveTest [SetUp] public void Setup() { - Options = new MqttSourceParams + Options = new MqttSourceOptions { Server = "wayne-foundation.com", Port = 1863, @@ -43,11 +44,7 @@ public void Setup() Topics = new Topics { Command = "cmnd/tasmota-dimmer/color", - Result = new Topic - { - Path = "stat/sonoff-1144-dimmer-5/RESULT", - ValuePath = "$.Color" - } + Result = "stat/sonoff-1144-dimmer-5/RESULT", } }; @@ -78,7 +75,7 @@ public void Should_initialize_MQTTClient() } [Test] - public void Should_request_color_via_command_topic() + public void Should_request_value_via_command_topic() { var args = new List(); MqttClientMock.Setup(it => it.PublishAsync(Capture.In(args), CancellationToken.None)); @@ -101,7 +98,7 @@ public void Should_subscribe_to_provided_topic() var filter = args.First().TopicFilters.First(); MqttClientMock.Verify(it => it.SubscribeAsync(It.IsAny(), CancellationToken.None)); - Assert.AreEqual(Options.Topics.Result.Path, filter.Topic); + Assert.AreEqual(Options.Topics.Result, filter.Topic); } [Test] @@ -133,12 +130,12 @@ public void Should_reconnect_after_disconnecting() } [Test] - public void Should_consume_message_and_emit_color() + public void Should_consume_message_and_emit_payload() { var black = "#000000"; - Color expectedColor = Color.FromArgb(255, 0, 0, 0); - var message = new MqttApplicationMessage { Payload = Encoding.UTF8.GetBytes($"{{ \"Color\": \"{black}\" }}") }; + string payload = $"{{ \"Color\": \"{black}\" }}"; + var message = new MqttApplicationMessage { Payload = Encoding.UTF8.GetBytes(payload) }; var eventArgs = new MqttApplicationMessageReceivedEventArgs("", message); var scheduler = new TestScheduler(); @@ -158,8 +155,49 @@ public void Should_consume_message_and_emit_color() disposed: 100 ); - var expected = new Recorded>[] { - OnNext(20, expectedColor) + var expected = new Recorded>[] { + OnNext(20, (object)payload) + }; + + ReactiveAssert.AreElementsEqual(expected, actual.Messages); + } + + + [Test] + public void Should_consume_message_and_apply_transformations() + { + var red = "#ff0000"; + Color expectedColor = Color.FromArgb(255, 0, 255, 0); + + string payload = $"{{ \"Color\": \"{red}\" }}"; + var message = new MqttApplicationMessage { Payload = Encoding.UTF8.GetBytes(payload) }; + var eventArgs = new MqttApplicationMessageReceivedEventArgs("", message); + var scheduler = new TestScheduler(); + + MqttClientMock.SetupAllProperties(); + + Options.Transformations = new List() + { + new JsonPathTransformationOptions() { Expression = "$.Color" }, + new ColorTransformationOptions() { ChannelLayout = "GRB" }, + }.ToArray(); + + var subject = new MqttSource(Options, MqttClientMock.Object); + + scheduler.Schedule(TimeSpan.FromTicks(20), () => + { + MqttClientMock.Object.ApplicationMessageReceivedHandler.HandleApplicationMessageReceivedAsync(eventArgs); + }); + + var actual = scheduler.Start( + () => subject.Get(), + created: 0, + subscribed: 10, + disposed: 100 + ); + + var expected = new Recorded>[] { + OnNext(20, (object)new Ref(expectedColor)) }; ReactiveAssert.AreElementsEqual(expected, actual.Messages); diff --git a/AllMyLights.Test/Connectors/OpenRGBSinkTest.cs b/AllMyLights.Test/Connectors/OpenRGBSinkTest.cs index 7b5bcf0..67caf01 100644 --- a/AllMyLights.Test/Connectors/OpenRGBSinkTest.cs +++ b/AllMyLights.Test/Connectors/OpenRGBSinkTest.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using AllMyLights.Common; using AllMyLights.Connectors.Sinks; using AllMyLights.Models.OpenRGB; using Moq; @@ -10,7 +11,7 @@ namespace AllMyLights.Test { public class OpenRGBSinkTest { - OpenRGBSinkParams Options = new OpenRGBSinkParams() + OpenRGBSinkOptions Options = new OpenRGBSinkOptions() { Overrides = new Dictionary() { @@ -54,7 +55,7 @@ public void Should_update_devices_with_color() var client = new OpenRGBSink(Options, openRGBClientMock.Object); - client.Consume(targetColor); + client.Consume(new Ref(targetColor)); openRGBClientMock.Verify(); } @@ -83,7 +84,7 @@ public void Should_update_devices_with_channel_layout_defined_in_override() var client = new OpenRGBSink(Options, openRGBClientMock.Object); - client.Consume(targetColor); + client.Consume(new Ref(targetColor)); openRGBClientMock.Verify(); } @@ -104,7 +105,7 @@ public void Should_not_update_devices_with_ignore_true() var client = new OpenRGBSink(Options, openRGBClientMock.Object); - client.Consume(targetColor); + client.Consume(new Ref(targetColor)); openRGBClientMock.Verify(); } @@ -144,7 +145,7 @@ public void Should_update_zones_with_channel_layout_defined_in_override() var client = new OpenRGBSink(Options, openRGBClientMock.Object); - client.Consume(targetColor); + client.Consume(new Ref(targetColor)); openRGBClientMock.Verify(); } diff --git a/AllMyLights.Test/Extensions/ObservableExtensionsTest.cs b/AllMyLights.Test/Extensions/ObservableExtensionsTest.cs new file mode 100644 index 0000000..883aed8 --- /dev/null +++ b/AllMyLights.Test/Extensions/ObservableExtensionsTest.cs @@ -0,0 +1,9 @@ +using Microsoft.Reactive.Testing; + +namespace AllMyLights.Test +{ + public class ObservableExtensionsTest : ReactiveTest + { + + } +} \ No newline at end of file diff --git a/AllMyLights.Test/Transformations/ColorTransformationTest.cs b/AllMyLights.Test/Transformations/ColorTransformationTest.cs new file mode 100644 index 0000000..9c5429c --- /dev/null +++ b/AllMyLights.Test/Transformations/ColorTransformationTest.cs @@ -0,0 +1,97 @@ +using System; +using System.Drawing; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using AllMyLights.Common; +using AllMyLights.Models.Transformations; +using AllMyLights.Transformations; +using Microsoft.Reactive.Testing; +using NUnit.Framework; + +namespace AllMyLights.Test +{ + public class ColorTransformationTest : ReactiveTest + { + + [Test] + public void Should_transform_color() + { + var black = "#000000"; + Ref expectedColor = new Ref(Color.FromArgb(255, 0, 0, 0)); + var source = Observable.Return(black); + + var transformation = new ColorTransformation(new ColorTransformationOptions()); + + var scheduler = new TestScheduler(); + + + var actual = scheduler.Start( + () => transformation.GetOperator()(source), + created: 0, + subscribed: 10, + disposed: 100 + ); + + var expected = new Recorded>>[] { + OnNext(10, expectedColor), + OnCompleted>(10) + }; + + ReactiveAssert.AreElementsEqual(expected, actual.Messages); + } + + [Test] + public void Should_transform_color_using_given_channel_layout() + { + var black = "#FF0000"; + var expectedColor = new Ref(Color.FromArgb(255, 0, 255, 0)); + var source = Observable.Return(black); + + var transformation = new ColorTransformation(new ColorTransformationOptions() + { + ChannelLayout = "GRB" + }); + + var scheduler = new TestScheduler(); + + + var actual = scheduler.Start( + () => transformation.GetOperator()(source), + created: 0, + subscribed: 10, + disposed: 100 + ); + + var expected = new Recorded>>[] { + OnNext(10, expectedColor), + OnCompleted>(10) + }; + + ReactiveAssert.AreElementsEqual(expected, actual.Messages); + } + + [Test] + public void Should_throw_argument_exception_for_invalid_input() + { + var source = Observable.Return(new object()); + + var transformation = new ColorTransformation(new ColorTransformationOptions()); + + var scheduler = new TestScheduler(); + + + var actual = scheduler.Start( + () => transformation.GetOperator()(source), + created: 0, + subscribed: 10, + disposed: 100 + ); + + + Recorded>> first = actual.Messages.First(); + Equals(NotificationKind.OnError, first.Value.Kind); + Equals(typeof(ArgumentException), first.Value.Exception.GetType()); + } + } +} \ No newline at end of file diff --git a/AllMyLights.Test/Transformations/JsonPathTransformationTest.cs b/AllMyLights.Test/Transformations/JsonPathTransformationTest.cs new file mode 100644 index 0000000..4952900 --- /dev/null +++ b/AllMyLights.Test/Transformations/JsonPathTransformationTest.cs @@ -0,0 +1,67 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using AllMyLights.Models.Transformations; +using AllMyLights.Transformations; +using Microsoft.Reactive.Testing; +using NUnit.Framework; + +namespace AllMyLights.Test +{ + public class JsonPathTransformationTest : ReactiveTest + { + + [Test] + public void Should_extract_deeply_nested_value() + { + var json = "{\"data\":{\"livingRoom\":{\"color\": \"red\"}}}"; + var source = Observable.Return(json); + + var options = new JsonPathTransformationOptions() { Expression = "$.data.livingRoom.color" }; + var transformation = new JsonPathTransformation(options); + + var scheduler = new TestScheduler(); + + + var actual = scheduler.Start( + () => transformation.GetOperator()(source), + created: 0, + subscribed: 10, + disposed: 100 + ); + + var expected = new Recorded>[] { + OnNext(10, "red"), + OnCompleted(10) + }; + + ReactiveAssert.AreElementsEqual(expected, actual.Messages); + } + + [Test] + public void Should_throw_argument_exception_for_invalid_input() + { + var source = Observable.Return(new object()); + + var options = new JsonPathTransformationOptions() { Expression = "$.data.livingRoom.color" }; + var transformation = new JsonPathTransformation(options); + + var scheduler = new TestScheduler(); + + + var actual = scheduler.Start( + () => transformation.GetOperator()(source), + created: 0, + subscribed: 0, + disposed: 100 + ); + + + + Recorded> first = actual.Messages.First(); + Equals(NotificationKind.OnError, first.Value.Kind); + Equals(typeof(ArgumentException), first.Value.Exception.GetType()); + } + } +} \ No newline at end of file diff --git a/AllMyLights/AllMyLights.csproj b/AllMyLights/AllMyLights.csproj index 2b4e1f6..f5b8272 100644 --- a/AllMyLights/AllMyLights.csproj +++ b/AllMyLights/AllMyLights.csproj @@ -12,10 +12,12 @@ 1701;1702;NU5105;1591;1572;1571;1573;1587;1570 + latestmajor 1701;1702;NU5105;1591;1572;1571;1573;1587;1570 + latestmajor @@ -51,5 +53,7 @@ + + \ No newline at end of file diff --git a/AllMyLights/Common/Ref.cs b/AllMyLights/Common/Ref.cs new file mode 100644 index 0000000..99c5105 --- /dev/null +++ b/AllMyLights/Common/Ref.cs @@ -0,0 +1,42 @@ +namespace AllMyLights.Common +{ + public class Ref where T : struct + { + public T Value { get; set; } + + public Ref(T value) + { + Value = value; + } + + + public override string ToString() + { + return Value.ToString(); + } + + public override bool Equals(object obj) + { + return obj switch + { + Ref r => Value.Equals(r.Value), + _ => false + }; + } + + public override int GetHashCode() + { + return Value.GetHashCode(); + } + + public static implicit operator T(Ref wrapper) + { + return wrapper.Value; + } + + public static implicit operator Ref(T value) + { + return new Ref(value); + } + } +} diff --git a/AllMyLights/Connectors/ConnectorFactory.cs b/AllMyLights/Connectors/ConnectorFactory.cs index 125cd5a..2427102 100644 --- a/AllMyLights/Connectors/ConnectorFactory.cs +++ b/AllMyLights/Connectors/ConnectorFactory.cs @@ -1,35 +1,48 @@ -using System.Linq; +using System; +using System.Linq; using AllMyLights.Connectors.Sinks; using AllMyLights.Models; +using AllMyLights.Models.Mqtt; +using AllMyLights.Models.OpenRGB; using MQTTnet; +using NLog; namespace AllMyLights.Connectors.Sources { public class ConnectorFactory { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + private Configuration Configuration { get; } public ConnectorFactory(Configuration configuration) { Configuration = configuration; } + private OpenRGBSink GetOpenRGBSinkInstance(OpenRGBSinkOptions options) => + new OpenRGBSink(options, client: new OpenRGB.NET.OpenRGBClient( + ip: options?.Server ?? "127.0.0.1", + port: options?.Port ?? 6742, + autoconnect: false)); + public ISource[] GetSources() { - return Configuration.Sources?.Mqtt - ?.Select((it) => new MqttSource( - options: it, - mqttClient: new MqttFactory().CreateMqttClient())) - .ToArray(); + Logger.Info($"Configuring {Configuration.Sources.Count()} sources"); + return Configuration.Sources.Select(sourceOptions => sourceOptions switch + { + MqttSourceOptions options => new MqttSource(options, new MqttFactory().CreateMqttClient()), + _ => throw new NotImplementedException($"Source for type {sourceOptions.Type} not implemented") + }).ToArray(); } public ISink[] GetSinks() { - return Configuration.Sinks?.OpenRgb - ?.Select((options) => new OpenRGBSink(options, new OpenRGB.NET.OpenRGBClient( - ip: options?.Server ?? "127.0.0.1", - port: options?.Port ?? 6742, - autoconnect: false))) - .ToArray(); + Logger.Info($"Configuring {Configuration.Sinks.Count()} sinks"); + return Configuration.Sinks.Select(sinkOptions => sinkOptions switch + { + OpenRGBSinkOptions options => GetOpenRGBSinkInstance(options), + _ => throw new NotImplementedException($"Sinks for type {sinkOptions.Type} not implemented") + }).ToArray(); } } } diff --git a/AllMyLights/Connectors/Sinks/ISink.cs b/AllMyLights/Connectors/Sinks/ISink.cs index 8b03f8a..9228cf6 100644 --- a/AllMyLights/Connectors/Sinks/ISink.cs +++ b/AllMyLights/Connectors/Sinks/ISink.cs @@ -1,13 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Text; +using System.Collections.Generic; namespace AllMyLights.Connectors.Sinks { public interface ISink { - void Consume(Color color); + void Consume(object value); IEnumerable GetConsumers() => new List(); } } diff --git a/AllMyLights/Connectors/Sinks/OpenRGBSink.cs b/AllMyLights/Connectors/Sinks/OpenRGBSink.cs index e78b933..8ae960f 100644 --- a/AllMyLights/Connectors/Sinks/OpenRGBSink.cs +++ b/AllMyLights/Connectors/Sinks/OpenRGBSink.cs @@ -4,6 +4,7 @@ using System.Net.Sockets; using System.Reactive; using System.Reflection; +using AllMyLights.Common; using AllMyLights.Models.OpenRGB; using NLog; using OpenRGB.NET; @@ -11,31 +12,40 @@ namespace AllMyLights.Connectors.Sinks { - public class OpenRGBSink: ISink + public class OpenRGBSink: Sink { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private IOpenRGBClient Client { get; } - private OpenRGBSinkParams Options { get; } + private OpenRGBSinkOptions Options { get; } public OpenRGBSink( - OpenRGBSinkParams options, - IOpenRGBClient client) + OpenRGBSinkOptions options, + IOpenRGBClient client): base(options) { Client = client; Options = options; - } + Next.Subscribe((color) => + { + switch (color) + { + case Ref it: + UpdateAll(it); + break; + default: + Logger.Error($"Sink {nameof(OpenRGBSink)} received type {color.GetType()} it cannot handle. Please provide a {typeof(System.Drawing.Color)}"); + break; + } - public void Consume(System.Drawing.Color color) - { - UpdateAll(color); + }); } - public IEnumerable GetConsumers() => RequestCatching(() => { + public override IEnumerable GetConsumers() => RequestCatching(() => { return Client.GetAllControllerData(); }); - private Unit UpdateAll(System.Drawing.Color color) => RequestCatching(() => + private Unit UpdateAll(Ref colorRef) => RequestCatching(() => { + var color = colorRef.Value; Logger.Info($"Changing color to {color}"); var count = Client.GetControllerCount(); @@ -112,5 +122,7 @@ private T Reconnect(Func onSuccess) return default; } + + public override string ToString() =>$"OpenRGBSink({Options.Server}:{Options.Port})"; } } diff --git a/AllMyLights/Connectors/Sinks/Sink.cs b/AllMyLights/Connectors/Sinks/Sink.cs new file mode 100644 index 0000000..7cc5efe --- /dev/null +++ b/AllMyLights/Connectors/Sinks/Sink.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using AllMyLights.Extensions; +using AllMyLights.Models; +using AllMyLights.Transformations; + +namespace AllMyLights.Connectors.Sinks +{ + public abstract class Sink: ISink + { + public IEnumerable> Transformations { get; } + + private Subject Subject = new Subject(); + + protected IObservable Next { get; } + + public Sink(SinkOptions options) { + Transformations = options.Transformations?.Select((it) => TransformationFactory.GetInstance(it)) ?? new List>(); + Next = Subject.AsObservable().Pipe(Transformations.Select(it => it.GetOperator()).ToArray()); + } + + public void Consume(object value) + { + Subject.OnNext(value); + } + + public virtual IEnumerable GetConsumers() => new List(); + } +} diff --git a/AllMyLights/Connectors/Sources/ISource.cs b/AllMyLights/Connectors/Sources/ISource.cs index b1f695a..9995175 100644 --- a/AllMyLights/Connectors/Sources/ISource.cs +++ b/AllMyLights/Connectors/Sources/ISource.cs @@ -1,12 +1,9 @@ using System; -using System.Collections.Generic; -using System.Drawing; -using System.Text; namespace AllMyLights.Connectors.Sources { public interface ISource { - IObservable Get(); + IObservable Get(); } } diff --git a/AllMyLights/Connectors/Sources/MqttSource.cs b/AllMyLights/Connectors/Sources/MqttSource.cs index c070430..83ab943 100644 --- a/AllMyLights/Connectors/Sources/MqttSource.cs +++ b/AllMyLights/Connectors/Sources/MqttSource.cs @@ -1,33 +1,35 @@ -using AllMyLights.Models; +using System; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Text; +using System.Threading; +using System.Threading.Tasks; using AllMyLights.Models.Mqtt; using MQTTnet; using MQTTnet.Client; using MQTTnet.Client.Connecting; using MQTTnet.Client.Disconnecting; using MQTTnet.Client.Options; -using Newtonsoft.Json.Linq; using NLog; -using System; -using System.Drawing; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Text; -using System.Threading; -using System.Threading.Tasks; namespace AllMyLights.Connectors.Sources { - public class MqttSource : ISource + public class MqttSource : Source { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - private readonly ReplaySubject Subject = new ReplaySubject(1); + private readonly ReplaySubject Subject = new ReplaySubject(1); private IMqttClient MqttClient { get; } private IMqttClientOptions MqttClientOptions { get; } - private MqttSourceParams Options { get; } + private MqttSourceOptions Options { get; } - public MqttSource(MqttSourceParams options, IMqttClient mqttClient) + protected override IObservable Value { get; } + + + public MqttSource(MqttSourceOptions options, IMqttClient mqttClient): base(options) { + Value = Subject.AsObservable(); + Options = options; MqttClient = mqttClient; var builder = new MqttClientOptionsBuilder() @@ -41,6 +43,7 @@ public MqttSource(MqttSourceParams options, IMqttClient mqttClient) MqttClientOptions = builder.Build(); + Initialize(); } @@ -67,26 +70,18 @@ private void HandleMessage(MqttApplicationMessageReceivedEventArgs args) var payload = Encoding.UTF8.GetString(args.ApplicationMessage.Payload); Logger.Debug($"Received payload {payload}"); - Logger.Debug($"Extracting color with JsonPath expression {Options.Topics.Result.ValuePath}"); - - JObject o = JObject.Parse(payload); - var color = o.SelectToken(Options.Topics.Result.ValuePath); - - if (color != null) - { - Subject.OnNext(ColorConverter.Decode(color.ToString(), Options.Topics.Result.ChannelLayout)); - } + Subject.OnNext(payload); } private async Task HandleConnected(MqttClientConnectedEventArgs e) { Logger.Info($"Connection to mqtt server {Options.Server} established"); - Logger.Info($"Attempting to subscribe to {Options.Topics.Result.Path}"); + Logger.Info($"Attempting to subscribe to {Options.Topics.Result}"); - MqttTopicFilter topicFilter = new MqttTopicFilterBuilder().WithTopic(Options.Topics.Result.Path).Build(); + MqttTopicFilter topicFilter = new MqttTopicFilterBuilder().WithTopic(Options.Topics.Result).Build(); await MqttClient.SubscribeAsync(topicFilter); - Logger.Info($"Succesfully subscribed to {Options.Topics.Result.Path}"); + Logger.Info($"Succesfully subscribed to {Options.Topics.Result}"); } private async Task HandleDisconnected(MqttClientDisconnectedEventArgs args) @@ -105,9 +100,6 @@ private async Task HandleDisconnected(MqttClientDisconnectedEventArgs args) } } - public IObservable Get() - { - return Subject.AsObservable(); - } + public override string ToString() => $"{nameof(MqttSource)}({Options.Server}:{Options.Port})"; } } diff --git a/AllMyLights/Connectors/Sources/Source.cs b/AllMyLights/Connectors/Sources/Source.cs new file mode 100644 index 0000000..4d3333d --- /dev/null +++ b/AllMyLights/Connectors/Sources/Source.cs @@ -0,0 +1,23 @@ +using System; +using System.Linq; +using AllMyLights.Models; +using AllMyLights.Transformations; +using AllMyLights.Extensions; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace AllMyLights.Connectors.Sources +{ + public abstract class Source: ISource + { + public IEnumerable> Transformations { get; } + + protected virtual IObservable Value { get; } + + public Source(SourceOptions options) { + Transformations = options.Transformations?.Select((it) => TransformationFactory.GetInstance(it)) ?? new List>(); + } + + public IObservable Get() => Value.Pipe(Transformations.Select(it => it.GetOperator()).ToArray()); + } +} diff --git a/AllMyLights/ExitCode.cs b/AllMyLights/ExitCode.cs new file mode 100644 index 0000000..6c44ddf --- /dev/null +++ b/AllMyLights/ExitCode.cs @@ -0,0 +1,5 @@ +enum ExitCode +{ + InvalidConfig = -1, + InvalidArgument = -2, +} diff --git a/AllMyLights/Extensions/Json.cs b/AllMyLights/Extensions/Json.cs new file mode 100644 index 0000000..59f67a1 --- /dev/null +++ b/AllMyLights/Extensions/Json.cs @@ -0,0 +1,17 @@ +using NJsonSchema.Validation; + +namespace AllMyLights.Extensions +{ + public static class Json + { + public static string Message(this ValidationError error) + { + return error.Kind switch + { + ValidationErrorKind.PropertyRequired => $"The required property {error.Property} is missing.", + ValidationErrorKind.NoAdditionalPropertiesAllowed => $"Unknown property {error.Property} used. Maybe a typo?", + _ => error.Kind.ToString() + }; + } + } +} diff --git a/AllMyLights/Extensions/Observables.cs b/AllMyLights/Extensions/Observables.cs new file mode 100644 index 0000000..b0d007b --- /dev/null +++ b/AllMyLights/Extensions/Observables.cs @@ -0,0 +1,21 @@ +using System; +using System.Linq; +using System.Reactive.Linq; + +namespace AllMyLights.Extensions +{ + public static class Observables + { + public static IObservable Pipe( + this IObservable source, + params Func, IObservable>[] transformations) + { + if(transformations.Count() == 0) + { + return source; + } + + return transformations.Aggregate(source, (current , transform) => transform(current)); + } + } +} diff --git a/AllMyLights/Json/ConfigurationValidator.cs b/AllMyLights/Json/ConfigurationValidator.cs new file mode 100644 index 0000000..94007f8 --- /dev/null +++ b/AllMyLights/Json/ConfigurationValidator.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AllMyLights.Extensions; +using AllMyLights.Models; +using AllMyLights.Models.Transformations; +using Newtonsoft.Json.Linq; +using NJsonSchema; +using NJsonSchema.Generation; +using NLog; + +namespace AllMyLights.Json +{ + + public partial class ConfigurationValidator + { + + + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + private JsonSchemaGeneratorSettings Settings { get; } + + public ConfigurationValidator(JsonSchemaGeneratorSettings settings) + { + Settings = settings; + } + + /// + /// NJsonSchema only supports validating JSON Schema draft 4 type schemas. + /// Open API concepts like discriminator and discrimantor mappings are disregarded + /// and hence we're left to take the json apart and manually validate. + /// + /// + + public void Validate(string config) + { + var rootSchema = JsonSchema.FromType(); + + var o = JObject.Parse(config); + + JArray sources = o.SelectToken($"$.{nameof(Configuration.Sources)}") as JArray; + JArray sinks = o.SelectToken($"$.{nameof(Configuration.Sinks)}") as JArray; + + o.Remove("Sources"); + o.Remove("Sinks"); + + var errors = rootSchema.Validate(o).Select(it => new SchemaValidationError(it.Path, it.Message())).ToList(); + + new InheritanceValidator(sources, Settings) + .WithPath($"/{nameof(Configuration.Sources)}") + .OnError(errors.Add) + .ValidateIsolated(nameof(SourceOptions.Transformations)) + .Validate(); + + new InheritanceValidator(sinks, Settings) + .WithPath($"/{nameof(Configuration.Sinks)}") + .OnError(errors.Add) + .ValidateIsolated(nameof(SinkOptions.Transformations)) + .Validate(); + + if(errors.Count() > 0) + { + Logger.Error("Validation of config file failed. We found the following issues with your file:"); + errors.ForEach(Logger.Error); + + Environment.Exit((int)ExitCode.InvalidConfig); + } + + } + + } + +} diff --git a/AllMyLights/Json/InheritanceConverter.cs b/AllMyLights/Json/InheritanceConverter.cs new file mode 100644 index 0000000..448d793 --- /dev/null +++ b/AllMyLights/Json/InheritanceConverter.cs @@ -0,0 +1,27 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NJsonSchema.Converters; + +namespace AllMyLights.JsonConverters +{ + public class InheritanceConverter : JsonInheritanceConverter + { + private string TypeSuffix { get; } + + public InheritanceConverter(string name, string typeSuffix) : base(name) + { + TypeSuffix = typeSuffix; + } + + public override string GetDiscriminatorValue(Type type) + { + return type.Name.Replace(TypeSuffix, string.Empty); + } + + protected override Type GetDiscriminatorType(JObject jObject, Type objectType, string discriminatorValue) + { + return base.GetDiscriminatorType(jObject, objectType, discriminatorValue + TypeSuffix); + } + } +} diff --git a/AllMyLights/Json/InheritanceValidator.cs b/AllMyLights/Json/InheritanceValidator.cs new file mode 100644 index 0000000..6fa9cc6 --- /dev/null +++ b/AllMyLights/Json/InheritanceValidator.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using AllMyLights.Extensions; +using Newtonsoft.Json.Linq; +using NJsonSchema; +using NJsonSchema.Generation; +using NJsonSchema.Validation; +using NLog; + +namespace AllMyLights.Json +{ + + public class InheritanceValidator + { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + private const string Dicriminator = "Type"; + private Dictionary Schemas = new Dictionary(); + private JArray Objects { get; set; } + private string Path { set; get; } + private ICollection> ChildValidators { get; set; } = new List>(); + private JsonSchemaGeneratorSettings Settings { get; } + private Action ErrorHandler { get; set; } = Logger.Error; + + public InheritanceValidator( + JArray objects, + JsonSchemaGeneratorSettings settings) + { + Settings = settings; + Objects = objects; + + SetupSchemas(); + } + + public InheritanceValidator ValidateIsolated(string property) + { + ChildValidators.Add((obj, index) => + { + var children = obj.SelectToken($"$.{property}"); + obj.Remove(property); + + + return new InheritanceValidator(children as JArray, Settings) + .WithPath($"{Path}[{index}].{property}") + .Validate(); + }); + return this; + } + + public InheritanceValidator WithPath(string path) + { + Path = path; + return this; + } + + public InheritanceValidator OnError(Action errorHandler) + { + ErrorHandler = errorHandler; + return this; + } + + private void SignalError(SchemaValidationError error) + { + ErrorHandler.Invoke(error); + } + + private void SignalErrors(ICollection errors, string path) + { + foreach (ValidationError error in errors) + { + SignalError(new SchemaValidationError(path ?? error.Path, error.Message())); + } + } + + public bool Validate() + { + if (Objects == null) + { + return true; + } + + var count = Objects.Count(); + var isValid = true; + for (int i = 0; i < count; i++) + { + var obj = (JObject)Objects[i]; + + + isValid = ChildValidators.Select(validate => validate(obj, 0)).Aggregate(true, (a, b) => a && b) && isValid; + + try + { + var schema = Schemas[obj.SelectToken($"$.{Dicriminator}").ToString()]; + var errors = schema.Validate(obj); + isValid = isValid && errors.Count() == 0; + + SignalErrors(errors, Path); + } + catch (KeyNotFoundException) + { + SignalError(new SchemaValidationError( + path: $"{Path}[{i}].{Dicriminator}", + message: $"Property {Dicriminator} can only be one of the following {string.Join(", ", Schemas.Keys)}" + + )); + continue; + } + } + return isValid; + } + + private void SetupSchemas() + { + var type = typeof(T); + var typeAttributes = type + .GetCustomAttributes(typeof(KnownTypeAttribute), true) + as KnownTypeAttribute[]; + + typeAttributes.ToList().ForEach(attribute => + { + Schemas.Add(attribute.Type.Name.Replace( + typeof(T).Name, + string.Empty), JsonSchema.FromType(attribute.Type, Settings)); + }); + } + } + +} diff --git a/AllMyLights/Json/SchemaValidationError.cs b/AllMyLights/Json/SchemaValidationError.cs new file mode 100644 index 0000000..dbaefd9 --- /dev/null +++ b/AllMyLights/Json/SchemaValidationError.cs @@ -0,0 +1,16 @@ +namespace AllMyLights.Json +{ + public class SchemaValidationError + { + public string Path { get; } + public string Message { get; } + + public SchemaValidationError(string path, string message) + { + Path = path; + Message = message; + } + + public override string ToString() => $"{Path}: {Message}"; + } +} diff --git a/AllMyLights/Models/Configuration.cs b/AllMyLights/Models/Configuration.cs index 416b232..0ab4c63 100644 --- a/AllMyLights/Models/Configuration.cs +++ b/AllMyLights/Models/Configuration.cs @@ -1,12 +1,11 @@ -using NJsonSchema.Annotations; -using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; namespace AllMyLights.Models { public class Configuration { - public Sources Sources { get; set; } - public Sinks Sinks { get; set; } + public IEnumerable Sources { get; set; } + public IEnumerable Sinks { get; set; } } } diff --git a/AllMyLights/Models/ConnectorOptions.cs b/AllMyLights/Models/ConnectorOptions.cs new file mode 100644 index 0000000..2b17d0b --- /dev/null +++ b/AllMyLights/Models/ConnectorOptions.cs @@ -0,0 +1,10 @@ +using AllMyLights.Models.Transformations; + +namespace AllMyLights.Models +{ + public abstract class ConnectorOptions + { + public string Type { get; set; } + public TransformationOptions[] Transformations { get; set; } + } +} diff --git a/AllMyLights/Models/Mqtt/MqttSourceParams.cs b/AllMyLights/Models/Mqtt/MqttSourceOptions.cs similarity index 81% rename from AllMyLights/Models/Mqtt/MqttSourceParams.cs rename to AllMyLights/Models/Mqtt/MqttSourceOptions.cs index c992fdc..021fe7a 100644 --- a/AllMyLights/Models/Mqtt/MqttSourceParams.cs +++ b/AllMyLights/Models/Mqtt/MqttSourceOptions.cs @@ -1,8 +1,9 @@ using System.ComponentModel.DataAnnotations; +using NJsonSchema.Annotations; namespace AllMyLights.Models.Mqtt { - public class MqttSourceParams + public class MqttSourceOptions: SourceOptions { [Required] public string Server { get; set; } diff --git a/AllMyLights/Models/Mqtt/Topic.cs b/AllMyLights/Models/Mqtt/Topic.cs deleted file mode 100644 index b372b4c..0000000 --- a/AllMyLights/Models/Mqtt/Topic.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace AllMyLights.Models.Mqtt -{ - public class Topic - { - [Required] - public string Path { get; set; } - - [Required] - public string ValuePath { get; set; } - - public string ChannelLayout { get; set; } - } -} - - diff --git a/AllMyLights/Models/Mqtt/Topics.cs b/AllMyLights/Models/Mqtt/Topics.cs index e9aa962..d82db8e 100644 --- a/AllMyLights/Models/Mqtt/Topics.cs +++ b/AllMyLights/Models/Mqtt/Topics.cs @@ -7,7 +7,7 @@ public class Topics public string Command { get; set; } [Required] - public Topic Result { get; set; } + public string Result { get; set; } } } diff --git a/AllMyLights/Models/OpenRGB/OpenRGBSinkParams.cs b/AllMyLights/Models/OpenRGB/OpenRGBSinkOptions.cs similarity index 83% rename from AllMyLights/Models/OpenRGB/OpenRGBSinkParams.cs rename to AllMyLights/Models/OpenRGB/OpenRGBSinkOptions.cs index 50b1b2b..f4e58c4 100644 --- a/AllMyLights/Models/OpenRGB/OpenRGBSinkParams.cs +++ b/AllMyLights/Models/OpenRGB/OpenRGBSinkOptions.cs @@ -2,10 +2,10 @@ namespace AllMyLights.Models.OpenRGB { - public class OpenRGBSinkParams + public class OpenRGBSinkOptions: SinkOptions { - public int? Port { get; set; } public string Server { get; set; } + public int? Port { get; set; } public Dictionary Overrides { get; set; } } diff --git a/AllMyLights/Models/SinkOptions.cs b/AllMyLights/Models/SinkOptions.cs new file mode 100644 index 0000000..6bfd149 --- /dev/null +++ b/AllMyLights/Models/SinkOptions.cs @@ -0,0 +1,14 @@ +using System.Runtime.Serialization; +using AllMyLights.JsonConverters; +using AllMyLights.Models.OpenRGB; +using Newtonsoft.Json; + +namespace AllMyLights.Models +{ + [JsonConverter(typeof(InheritanceConverter), "Type", nameof(SinkOptions))] + [KnownType(typeof(OpenRGBSinkOptions))] + public abstract class SinkOptions : ConnectorOptions + { + public string Foo { get; set; } + } +} diff --git a/AllMyLights/Models/Sinks.cs b/AllMyLights/Models/Sinks.cs deleted file mode 100644 index 148abfa..0000000 --- a/AllMyLights/Models/Sinks.cs +++ /dev/null @@ -1,10 +0,0 @@ -using AllMyLights.Models.OpenRGB; -using System.Collections.Generic; - -namespace AllMyLights.Models -{ - public class Sinks - { - public IEnumerable OpenRgb { get; set; } - } -} diff --git a/AllMyLights/Models/SourceOptions.cs b/AllMyLights/Models/SourceOptions.cs new file mode 100644 index 0000000..a76fe59 --- /dev/null +++ b/AllMyLights/Models/SourceOptions.cs @@ -0,0 +1,13 @@ +using System.Runtime.Serialization; +using AllMyLights.JsonConverters; +using AllMyLights.Models.Mqtt; +using Newtonsoft.Json; + +namespace AllMyLights.Models +{ + [JsonConverter(typeof(InheritanceConverter), "Type", nameof(SourceOptions))] + [KnownType(typeof(MqttSourceOptions))] + public abstract class SourceOptions: ConnectorOptions + { + } +} diff --git a/AllMyLights/Models/Sources.cs b/AllMyLights/Models/Sources.cs deleted file mode 100644 index 11f445a..0000000 --- a/AllMyLights/Models/Sources.cs +++ /dev/null @@ -1,10 +0,0 @@ -using AllMyLights.Models.Mqtt; -using System.Collections.Generic; - -namespace AllMyLights.Models -{ - public class Sources - { - public IEnumerable Mqtt { get; set; } - } -} diff --git a/AllMyLights/Models/Transformations/ColorTransformationOptions.cs b/AllMyLights/Models/Transformations/ColorTransformationOptions.cs new file mode 100644 index 0000000..669356e --- /dev/null +++ b/AllMyLights/Models/Transformations/ColorTransformationOptions.cs @@ -0,0 +1,7 @@ +namespace AllMyLights.Models.Transformations +{ + public class ColorTransformationOptions: TransformationOptions + { + public string ChannelLayout { get; set; } + } +} diff --git a/AllMyLights/Models/Transformations/JsonPathTransformationOptions.cs b/AllMyLights/Models/Transformations/JsonPathTransformationOptions.cs new file mode 100644 index 0000000..0ae38bb --- /dev/null +++ b/AllMyLights/Models/Transformations/JsonPathTransformationOptions.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace AllMyLights.Models.Transformations +{ + public class JsonPathTransformationOptions: TransformationOptions + { + [Required] + public string Expression { get; set; } + } +} \ No newline at end of file diff --git a/AllMyLights/Models/Transformations/TransformationOptions.cs b/AllMyLights/Models/Transformations/TransformationOptions.cs new file mode 100644 index 0000000..7eab82c --- /dev/null +++ b/AllMyLights/Models/Transformations/TransformationOptions.cs @@ -0,0 +1,15 @@ +using System.Runtime.Serialization; +using AllMyLights.JsonConverters; +using Newtonsoft.Json; +using NJsonSchema.Annotations; + +namespace AllMyLights.Models.Transformations +{ + [JsonSchemaFlatten] + [JsonConverter(typeof(InheritanceConverter), "Type", nameof(TransformationOptions))] + [KnownType(typeof(ColorTransformationOptions)), KnownType(typeof(JsonPathTransformationOptions))] + public class TransformationOptions + { + public string Type { get; set; } + } +} \ No newline at end of file diff --git a/AllMyLights/Platforms/Windows/TrayIcon.cs b/AllMyLights/Platforms/Windows/TrayIcon.cs index 7e41f00..310d4f0 100644 --- a/AllMyLights/Platforms/Windows/TrayIcon.cs +++ b/AllMyLights/Platforms/Windows/TrayIcon.cs @@ -3,7 +3,6 @@ using System.Windows.Forms; using System.Drawing; using AllMyLights.Connectors.Sinks; -using AllMyLights.Models; namespace AllMyLights.Platforms.Windows { @@ -26,15 +25,15 @@ private TrayIcon(bool minimized) Init(); } - public void Consume(Color color) + public void Consume(object message) { if (Menu.InvokeRequired) { - Menu.Invoke((MethodInvoker)delegate () { TrayColorLabel.Text = color.ToString(); }); + Menu.Invoke((MethodInvoker)delegate () { TrayColorLabel.Text = message.ToString(); }); return; } - TrayColorLabel.Text = color.ToString(); + TrayColorLabel.Text = message.ToString(); } private void ShowMinimizeHint() diff --git a/AllMyLights/Program.cs b/AllMyLights/Program.cs index 69d2ebf..39c2bfb 100644 --- a/AllMyLights/Program.cs +++ b/AllMyLights/Program.cs @@ -2,25 +2,21 @@ using System.IO; using System.Linq; using System.Threading; +using AllMyLights.Connectors.Sinks; using AllMyLights.Connectors.Sources; +using AllMyLights.Json; using AllMyLights.Models; using Newtonsoft.Json; -using NJsonSchema; +using NJsonSchema.Generation; using NLog; using NLog.Conditions; using NLog.Targets; #if Windows -using AllMyLights.Platforms.Windows; using System.Windows.Forms; +using AllMyLights.Platforms.Windows; #endif -enum ExitCode -{ - InvalidConfig = -1, - InvalidArgument = -2, -} - namespace AllMyLights { class Program @@ -36,19 +32,27 @@ class Program /// If provided, log output will additionally be captured in the provided file. /// Minimize to tray after startup /// List all available OpenRGB devices and their zones. Returns right away + /// Fails if an unknown property is encountered in the provided config file. Can be disabled. static void Main( FileInfo config, string logLevel = "warn", string logFile = null, bool minimized = false, - bool listDevices = false + bool listDevices = false, + bool failOnUnknownProperty = true ) { using StreamReader file = File.OpenText(config.FullName); var content = file.ReadToEnd(); ConfigureLogging(logLevel, logFile); - ValidateConfig(config.Name, content); + + new ConfigurationValidator(new JsonSchemaGeneratorSettings + { + AllowReferencesWithProperties = true, + FlattenInheritanceHierarchy = true, + AlwaysAllowAdditionalObjectProperties = !failOnUnknownProperty + }).Validate(content); var configuration = JsonConvert.DeserializeObject(content); var factory = new ConnectorFactory(configuration); @@ -57,7 +61,7 @@ static void Main( if (listDevices) { - Console.Write(JsonConvert.SerializeObject(sinks.SelectMany((it) => it.GetConsumers()), Formatting.Indented)); + ListDevices(sinks); Environment.Exit(0); } @@ -77,22 +81,20 @@ static void Main( #endif } - - - private static void ValidateConfig(string fileName, string content) + private static void ListDevices(ISink[] sinks) { - JsonSchema schema = JsonSchema.FromType(); - - - var errors = schema.Validate(content); - - if (errors.Count > 0) + foreach (ISink sink in sinks) { - Logger.Error($"Validation issues encountered in config file {fileName}:"); - foreach (var error in errors) - Logger.Error($"{error.Path}: {error.Kind}"); - - Environment.Exit((int)ExitCode.InvalidConfig); + var devices = sink.GetConsumers(); + if (devices.Count() > 0) + { + Console.WriteLine(sink); + Console.WriteLine(JsonConvert.SerializeObject(devices, Formatting.Indented)); + } + else + { + Console.WriteLine($"{sink} does not expose any device information."); + } } } @@ -128,7 +130,7 @@ private static void ConfigureLogging(string logLevel, string logFile) )); config.AddRule(minLevel, LogLevel.Fatal, logconsole); - if(logFile != null) + if (logFile != null) { var logfile = new FileTarget("logfile") { diff --git a/AllMyLights/Transformations/ColorTransformation.cs b/AllMyLights/Transformations/ColorTransformation.cs new file mode 100644 index 0000000..9f519df --- /dev/null +++ b/AllMyLights/Transformations/ColorTransformation.cs @@ -0,0 +1,35 @@ +using System; +using System.Drawing; +using System.Reactive.Linq; +using AllMyLights.Common; +using AllMyLights.Models.Transformations; + +namespace AllMyLights.Transformations +{ + public class ColorTransformation: ITransformation> + { + public const string Type = "Color"; + + private string ChannelLayout { get; } + + public ColorTransformation(ColorTransformationOptions options) + { + ChannelLayout = options.ChannelLayout; + } + + public Func, IObservable>> GetOperator() + { + return (source) => + { + return source.Select((input) => + { + if (!(input is string)) { + throw new ArgumentException($"{nameof(ColorTransformation)} requires input to be of type string"); + } + + return new Ref(ColorConverter.Decode(input as string, ChannelLayout)); + }); + }; + } + } +} diff --git a/AllMyLights/Transformations/ITransformation.cs b/AllMyLights/Transformations/ITransformation.cs new file mode 100644 index 0000000..d07088e --- /dev/null +++ b/AllMyLights/Transformations/ITransformation.cs @@ -0,0 +1,9 @@ +using System; +using System.Linq; +namespace AllMyLights.Transformations +{ + public interface ITransformation + { + public Func, IObservable> GetOperator(); + } +} diff --git a/AllMyLights/Transformations/JsonPathTransformation.cs b/AllMyLights/Transformations/JsonPathTransformation.cs new file mode 100644 index 0000000..c8dd69e --- /dev/null +++ b/AllMyLights/Transformations/JsonPathTransformation.cs @@ -0,0 +1,37 @@ +using System; +using System.Reactive.Linq; +using AllMyLights.Models.Transformations; +using Newtonsoft.Json.Linq; + +namespace AllMyLights.Transformations +{ + public class JsonPathTransformation: ITransformation + { + public const string Type = "JsonPath"; + + private string Path { get; set; } + + public JsonPathTransformation(JsonPathTransformationOptions options) + { + Path = options.Expression; + } + + public Func, IObservable> GetOperator() + { + return (source) => + { + return source.Select((input) => + { + if (!(input is string)) { + throw new ArgumentException($"{nameof(JsonPathTransformation)} requires input to be of type string"); + } + + JObject o = JObject.Parse(input as string); + var value = o.SelectToken(Path); + + return value.ToObject(); + }); + }; + } + } +} diff --git a/AllMyLights/Transformations/TransformationFactory.cs b/AllMyLights/Transformations/TransformationFactory.cs new file mode 100644 index 0000000..8c601c1 --- /dev/null +++ b/AllMyLights/Transformations/TransformationFactory.cs @@ -0,0 +1,18 @@ +using System; +using AllMyLights.Models.Transformations; + +namespace AllMyLights.Transformations +{ + public class TransformationFactory + { + public static ITransformation GetInstance(TransformationOptions options) + { + return options switch + { + ColorTransformationOptions colorOptions => new ColorTransformation(colorOptions), + JsonPathTransformationOptions jpathOptions => new JsonPathTransformation(jpathOptions), + _ => throw new NotImplementedException($"Transformation for type {options.Type} not registered") + }; + } + } +} diff --git a/AllMyLights/allmylightsrc.json b/AllMyLights/allmylightsrc.json index 53c4055..f6fa1bf 100644 --- a/AllMyLights/allmylightsrc.json +++ b/AllMyLights/allmylightsrc.json @@ -1,34 +1,38 @@ { - "Sinks": { - "OpenRgb": [ - { - "Server": "127.0.0.1", - "Port": 6742, - "Overrides": { - "MSI Mystic Light MS_7C84": { - "Zones": { - "JRGB2": { - "ChannelLayout": "GRB" - } + "Sinks": [ + { + "Type": "OpenRGB", + "Server": "127.0.0.1", + "Port": 6742, + "Overrides": { + "MSI Mystic Light MS_7C84": { + "Zones": { + "JRGB2": { + "ChannelLayout": "GRB" } } } } - ] - }, - "Sources": { - "Mqtt": [ - { - "Server": "192.168.178.20", - "Port": 1883, - "Topics": { - "Command": "cmnd/sonoff-1144-dimmer-5/color", - "Result": { - "Path": "stat/sonoff-1144-dimmer-5/RESULT", - "ValuePath": "$.Color" - } + } + ], + "Sources": [ + { + "Type": "Mqtt", + "Server": "192.168.178.20", + "Port": 1883, + "Topics": { + "Command": "cmnd/sonoff-1144-dimmer-5/color", + "Result": "stat/sonoff-1144-dimmer-5/RESULT" + }, + "Transformations": [ + { + "Type": "JsonPath", + "Expression": "$.Color" + }, + { + "Type": "Color" } - } - ] - } -} + ] + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f19b16c..105f906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [0.3.0] - 12/12/2020 +### General +- In preparation for future changes, this release aims to achieve a clear separation between transport and transformation logic +- Introduce pluggable transformations to make logic such as extracting values via JsonPath or color conversions reusable across different source and sink types +## MqttSource +- Remove transformation logic from MqttSource + ## [0.2.0] - 12/06/2020 ### General - Introduce the concept of sources where multiple sources can be used as a color signal @@ -8,4 +15,5 @@ - Add option to override the channel layout of a device - Add option to override the channel layout of a zone +[0.3.0]: https://github.com/sparten11740/allmylights/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/sparten11740/allmylights/compare/v0.1.0...v0.2.0 diff --git a/README.md b/README.md index 8ce2d49..33f55db 100644 --- a/README.md +++ b/README.md @@ -97,52 +97,65 @@ I read the required server ip addresses, ports, topics etc. from a configuration // allmylightsrc.json { - "Sources": { - "Mqtt": [{ - "Server": "192.168.1.20", - "Port": 1883, - "Topics": { - // optional command topic that is used to request the current color on startup - "Command": "cmnd/sonoff-1144-dimmer-5/color", - "Result": { - // topic to grab the color from - "Path": "stat/sonoff-1144-dimmer-5/RESULT", - // JsonPath expression pointing to the property that holds the color value - "ValuePath": "$.Color" - } - } - }] - }, + "Sources": [{ + "Type" : "Mqtt", + "Server": "192.168.1.20", + "Port": 1883, + "Topics": { + // optional command topic that is used to request the current color on startup + "Command": "cmnd/sonoff-1144-dimmer-5/color", + "Result": "stat/sonoff-1144-dimmer-5/RESULT" + }, + // transformations are applied in order on any received message + "Transformations": [ + // JsonPath expression transformation to extract the value that holds the color + { + "Type": "JsonPath", + "Expression": "$.Color" + }, + // decodes Color from string value (required type f.i. for the OpenRGB source) + { "Type": "Color" } + ] + }], // ip address & port of machine that runs openrgb - "Sinks": { + "Sinks": [{ // configure one or more target OpenRGB instances - "OpenRgb": [{ - "Server": "127.0.0.1", - "Port": 6742, - // if you want to override certain OpenRGB controlled devices you can do so here - "Overrides": { - // ignore an entire device - "Razer Copperhead": { - "Ignore": true, + "Type": "OpenRGB", + "Server": "127.0.0.1", + "Port": 6742, + // if you want to override certain OpenRGB controlled devices you can do so here + "Overrides": { + // ignore an entire device + "Razer Copperhead": { + "Ignore": true, + }, + "MSI Mystic Light MS_7C84": { + "Zones": { + // configure what color is passed to what channel of a zone + "JRGB2": { + "ChannelLayout": "GRB" }, - "MSI Mystic Light MS_7C84": { - "Zones": { - // configure what color is passed to what channel of a zone - "JRGB2": { - "ChannelLayout": "GRB" - }, - // ignore a single zone of a device - "JRAINBOW1": { - "Ignore": true - } - } + // ignore a single zone of a device + "JRAINBOW1": { + "Ignore": true } } - }] - } + } + }, + // transformations can also be applied before a sink consumes a value + "Transformations" : [] + }] } ``` +Available source, sink, and transformations types as of this version are: + +| Type | Options | +| ---------------| -------------------------- | +| Source | `Mqtt` | +| Sink | `OpenRGB` | +| Transformation | `JsonPath`, `Color` | + For further information on how to extract a value from JSON using `JsonPath` expressions, please refer to [this documentation](https://support.smartbear.com/alertsite/docs/monitors/api/endpoint/jsonpath.html). Supported are hex strings such as the following `#f2d`, `#ed20ff`, `#2020ffed` and color names where the name can be any [known color](https://docs.microsoft.com/en-us/dotnet/api/system.drawing.knowncolor?view=net-5.0).