diff --git a/src/PepperDash.Essentials.Core/Devices/DeviceJsonApi.cs b/src/PepperDash.Essentials.Core/Devices/DeviceJsonApi.cs index b2565b3d..b3e6d48f 100644 --- a/src/PepperDash.Essentials.Core/Devices/DeviceJsonApi.cs +++ b/src/PepperDash.Essentials.Core/Devices/DeviceJsonApi.cs @@ -78,9 +78,7 @@ public class DeviceJsonApi } var mParams = method.GetParameters(); - var convertedParams = mParams - .Select((p, i) => ConvertType(action.Params[i], p.ParameterType)) - .ToArray(); + var convertedParams = BuildConvertedParams(mParams, action.Params); Task.Run(() => { @@ -142,9 +140,7 @@ public class DeviceJsonApi } var mParams = method.GetParameters(); - var convertedParams = mParams - .Select((p, i) => ConvertType(action.Params[i], p.ParameterType)) - .ToArray(); + var convertedParams = BuildConvertedParams(mParams, action.Params); try { @@ -173,8 +169,56 @@ public class DeviceJsonApi } } + /// + /// Builds the ordered, converted argument array to pass to . + /// + /// + /// Handles the common positional case (one supplied param per method parameter), as well as a method + /// with a single array-typed parameter (e.g. void Foo(string[] items)). For the latter, config + /// authors may either supply the params as a flat list matching the array's contents + /// ("params": ["a", "b", "c"]) or as a single nested array ("params": [["a", "b", "c"]]) - + /// both are converted into the single array argument correctly. + /// + /// The target method's parameters. + /// The raw parameter values supplied in the action. + /// The converted arguments, in order, ready to pass to Invoke. + private static object[] BuildConvertedParams(ParameterInfo[] mParams, object[] actionParams) + { + if (mParams.Length == 1 && mParams[0].ParameterType.IsArray && actionParams.Length != 1) + { + return new[] { ConvertType(actionParams, mParams[0].ParameterType) }; + } + + return mParams + .Select((p, i) => ConvertType(actionParams[i], p.ParameterType)) + .ToArray(); + } + private static object ConvertType(object value, Type conversionType) { + if (conversionType.IsArray) + { + var elementType = conversionType.GetElementType(); + + // The supplied value is a collection of raw items (a JArray, or the whole flat params list) - + // convert each item to the array's element type. + if (value is IEnumerable enumerable && !(value is string)) + { + var items = enumerable.Cast() + .Select(item => ConvertType(item, elementType)) + .ToArray(); + + var typedArray = Array.CreateInstance(elementType, items.Length); + Array.Copy(items, typedArray, items.Length); + return typedArray; + } + + // A single scalar value was supplied for an array parameter - wrap it in a single-element array. + var singleArray = Array.CreateInstance(elementType, 1); + singleArray.SetValue(ConvertType(value, elementType), 0); + return singleArray; + } + if (!conversionType.IsEnum) { return Convert.ChangeType(value, conversionType, System.Globalization.CultureInfo.InvariantCulture); diff --git a/src/PepperDash.Essentials.Devices.Common/Displays/MockDisplay.cs b/src/PepperDash.Essentials.Devices.Common/Displays/MockDisplay.cs index f176c24c..a7e30660 100644 --- a/src/PepperDash.Essentials.Devices.Common/Displays/MockDisplay.cs +++ b/src/PepperDash.Essentials.Devices.Common/Displays/MockDisplay.cs @@ -65,7 +65,7 @@ public class MockDisplay : TwoWayDisplayBase, IBasicVolumeWithFeedback, IBridgeA int VolumeHeldRepeatInterval = 200; ushort VolumeInterval = 655; - ushort _FakeVolumeLevel = 31768; + ushort _FakeVolumeLevel = 32768; bool _IsMuted; Timer _volumeUpTimer; Timer _volumeDownTimer; @@ -107,6 +107,11 @@ public class MockDisplay : TwoWayDisplayBase, IBasicVolumeWithFeedback, IBridgeA VolumeLevelFeedback = new IntFeedback("volume", () => { return _FakeVolumeLevel; }); MuteFeedback = new BoolFeedback("muteOn", () => _IsMuted); + // Seed the feedback's cached value from _FakeVolumeLevel immediately. IntFeedback.IntValue only + // reflects the current backing value after FireUpdate has run once, so without this the UI would + // report 0 until the volume was first incremented/decremented. + VolumeLevelFeedback.FireUpdate(); + WarmupTime = 10000; CooldownTime = 10000; } diff --git a/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs b/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs new file mode 100644 index 00000000..74853223 --- /dev/null +++ b/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using PepperDash.Core; +using PepperDash.Core.Logging; +using PepperDash.Essentials.Core; +using PepperDash.Essentials.Core.Config; +using Serilog.Events; + +namespace PepperDash.Essentials.Devices.Common.Routing; + +/// +/// A mock midpoint routing device (e.g. a matrix switcher) that implements +/// without any real hardware communication. Its input and output ports are configured via +/// , each with a name, signal type, and physical port +/// (connection) type - so it can stand in for a real switching device (such as a StreamSync matrix) for +/// development and testing of routing logic. +/// +[Description("A mock routing midpoint (e.g. matrix switcher) device for testing routing logic without real hardware")] +public class MockRoutingMidpoint : EssentialsDevice, IRoutingMidpointWithFeedback +{ + /// + /// The configuration properties for this device. + /// + public MockRoutingMidpointPropertiesConfig PropertiesConfig { get; private set; } + + /// + public RoutingPortCollection InputPorts { get; private set; } + + /// + public RoutingPortCollection OutputPorts { get; private set; } + + /// + public List CurrentRoutes { get; } = new List(); + + /// + public event RouteChangedEventHandler RouteChanged; + + /// + /// Initializes a new instance of the class from a . + /// + /// The device configuration, whose Properties are deserialized as . + public MockRoutingMidpoint(DeviceConfig config) + : base(config.Key, config.Name) + { + PropertiesConfig = config.Properties != null + ? JsonConvert.DeserializeObject(config.Properties.ToString()) + : null; + PropertiesConfig ??= new MockRoutingMidpointPropertiesConfig(); + + InputPorts = new RoutingPortCollection(); + OutputPorts = new RoutingPortCollection(); + + BuildPorts(); + } + + /// + /// Builds the input and output ports from . Each port's Key and Selector + /// are both set to the configured port name, so selectors passed to and + /// can simply be looked up by matching against the port's Selector. + /// + private void BuildPorts() + { + try + { + foreach (var portConfig in PropertiesConfig.InputPorts) + { + if (string.IsNullOrEmpty(portConfig.Name)) + { + this.LogWarning("Skipping input port with no name configured for {key}", Key); + continue; + } + + var port = new RoutingInputPort(portConfig.Name, portConfig.SignalType, portConfig.PortType, portConfig.Name, this); + InputPorts.Add(port); + } + + foreach (var portConfig in PropertiesConfig.OutputPorts) + { + if (string.IsNullOrEmpty(portConfig.Name)) + { + this.LogWarning("Skipping output port with no name configured for {key}", Key); + continue; + } + + var port = new RoutingOutputPort(portConfig.Name, portConfig.SignalType, portConfig.PortType, portConfig.Name, this); + OutputPorts.Add(port); + } + + this.LogInformation("Built {inputCount} input port(s) and {outputCount} output port(s) for mock midpoint {key}", + InputPorts.Count, OutputPorts.Count, Key); + } + catch (Exception ex) + { + this.LogException(ex, "Error building ports for mock midpoint {0}", Key); + } + } + + /// + public void ExecuteSwitch(object inputSelector, object outputSelector, eRoutingSignalType signalType) + { + try + { + var outputPort = OutputPorts.FirstOrDefault(p => Equals(p.Selector, outputSelector)); + + if (outputPort == null) + { + this.LogWarning("Unable to find output port for selector {selector} on {key}", outputSelector, Key); + return; + } + + // Remove any existing route to this output before making or clearing the new one. + var existingRoute = CurrentRoutes.FirstOrDefault(r => r.OutputPort?.Key == outputPort.Key); + if (existingRoute != null) + { + CurrentRoutes.Remove(existingRoute); + } + + if (inputSelector == null) + { + this.LogInformation("Clearing route to output {output} on {key}", outputPort.Key, Key); + + var clearedDescriptor = new RouteSwitchDescriptor(outputPort, null); + RouteChanged?.Invoke(this, clearedDescriptor); + return; + } + + var inputPort = InputPorts.FirstOrDefault(p => Equals(p.Selector, inputSelector)); + + if (inputPort == null) + { + this.LogWarning("Unable to find input port for selector {selector} on {key}", inputSelector, Key); + return; + } + + var descriptor = new RouteSwitchDescriptor(outputPort, inputPort); + CurrentRoutes.Add(descriptor); + + this.LogInformation("Executed switch: {input} -> {output} ({signalType}) on {key}", + inputPort.Key, outputPort.Key, signalType, Key); + + RouteChanged?.Invoke(this, descriptor); + } + catch (Exception ex) + { + this.LogException(ex, "Error executing switch on mock midpoint {0}", Key); + } + } + + /// + public void ClearRoute(object outputSelector, eRoutingSignalType signalType) + { + ExecuteSwitch(null, outputSelector, signalType); + } +} + +/// +/// Factory for building devices. +/// +public class MockRoutingMidpointFactory : EssentialsDeviceFactory +{ + /// + /// Initializes a new instance of the class. + /// + public MockRoutingMidpointFactory() + { + TypeNames = new List { "mockroutingmidpoint", "mockmidpoint" }; + } + + /// + public override EssentialsDevice BuildDevice(DeviceConfig dc) + { + Debug.LogMessage(LogEventLevel.Debug, "Factory Attempting to create new MockRoutingMidpoint Device"); + return new MockRoutingMidpoint(dc); + } +} diff --git a/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpointPropertiesConfig.cs b/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpointPropertiesConfig.cs new file mode 100644 index 00000000..7c7d4bc3 --- /dev/null +++ b/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpointPropertiesConfig.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using PepperDash.Essentials.Core; + +namespace PepperDash.Essentials.Devices.Common.Routing; + +/// +/// Configuration properties for a device. +/// +public class MockRoutingMidpointPropertiesConfig +{ + /// + /// The input ports to build on the mock midpoint. + /// + [JsonProperty("inputPorts")] + public List InputPorts { get; set; } = new List(); + + /// + /// The output ports to build on the mock midpoint. + /// + [JsonProperty("outputPorts")] + public List OutputPorts { get; set; } = new List(); +} + +/// +/// Configuration for a single input or output port on a device. +/// +public class MockRoutingMidpointPortConfig +{ + /// + /// The name of the port. Used as both the port's key and its selector value, so it must be unique + /// within the collection (input ports or output ports) it is configured under. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// The routing signal type supported by this port (e.g. "Audio", "Video", "AudioVideo", "Usb"). + /// Defaults to AudioVideo if not specified. + /// + [JsonProperty("signalType")] + [JsonConverter(typeof(StringEnumConverter))] + public eRoutingSignalType SignalType { get; set; } = eRoutingSignalType.AudioVideo; + + /// + /// The physical connection type of this port (e.g. "Hdmi", "Dm", "DisplayPort"). Defaults to Hdmi if + /// not specified. + /// + [JsonProperty("portType")] + [JsonConverter(typeof(StringEnumConverter))] + public eRoutingPortConnectionType PortType { get; set; } = eRoutingPortConnectionType.Hdmi; +}