From 6bb6e7af199b1b27e8bb0f7125134a34fdbede5b Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 28 Sep 2026 12:55:21 -0600 Subject: [PATCH 1/9] feat: add MockLightingDevice and configuration classes for simulated lighting scenes --- .../Lighting/MockLightingDevice.cs | 135 ++++++++++++++++++ .../Routing/MockRoutingMidpoint.cs | 119 +++++++++++---- 2 files changed, 229 insertions(+), 25 deletions(-) create mode 100644 src/PepperDash.Essentials.Devices.Common/Lighting/MockLightingDevice.cs diff --git a/src/PepperDash.Essentials.Devices.Common/Lighting/MockLightingDevice.cs b/src/PepperDash.Essentials.Devices.Common/Lighting/MockLightingDevice.cs new file mode 100644 index 000000000..9ec2a1ca0 --- /dev/null +++ b/src/PepperDash.Essentials.Devices.Common/Lighting/MockLightingDevice.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.Linq; +using Crestron.SimplSharpPro.DeviceSupport; +using Newtonsoft.Json; +using PepperDash.Core.Logging; +using PepperDash.Essentials.Core; +using PepperDash.Essentials.Core.Bridges; +using PepperDash.Essentials.Core.Config; +using PepperDash.Essentials.Core.Lighting; + +namespace PepperDash.Essentials.Devices.Common.Lighting; + +/// +/// Scenes to simulate for a . +/// +public class MockLightingConfig +{ + /// + /// Scenes to simulate, in display order. Defaults to a generic 3-scene set (On/Dim/Off) when + /// omitted so the device works with no config at all. + /// + [JsonProperty("scenes", NullValueHandling = NullValueHandling.Ignore)] + public List Scenes { get; set; } + + /// + /// ID of the scene active on startup. Falls back to the first configured scene when unset + /// or when it does not match any configured scene. + /// + [JsonProperty("defaultSceneId", NullValueHandling = NullValueHandling.Ignore)] + public string DefaultSceneId { get; set; } +} + +/// +/// One configured scene. +/// +public class MockLightingSceneConfig +{ + /// + /// Scene ID, referenced by the client's /selectScene request + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// Display name for the scene + /// + [JsonProperty("name")] + public string Name { get; set; } +} + +/// +/// Simulated lighting scene controller. No hardware behind it - selecting a scene just updates +/// and fires feedback, exactly like a real DSP +/// or lighting processor would once the scene recall is acknowledged. +/// +public class MockLightingDevice : LightingBase +{ + private static readonly List DefaultScenes = new List + { + new MockLightingSceneConfig { Id = "on", Name = "On" }, + new MockLightingSceneConfig { Id = "dim", Name = "Dim" }, + new MockLightingSceneConfig { Id = "off", Name = "Off" }, + }; + + /// + /// Initializes a new instance of the MockLightingDevice class + /// + /// The device key + /// The device name + /// Scenes to simulate; null falls back to a generic On/Dim/Off set + public MockLightingDevice(string key, string name, MockLightingConfig config) + : base(key, name) + { + var scenesConfig = config?.Scenes != null && config.Scenes.Count > 0 + ? config.Scenes + : DefaultScenes; + + LightingScenes = scenesConfig + .Select(s => new LightingScene { ID = s.Id, Name = s.Name }) + .ToList(); + + CurrentLightingScene = LightingScenes.FirstOrDefault(s => s.ID == config?.DefaultSceneId) + ?? LightingScenes.FirstOrDefault() + ?? new LightingScene(); + + CurrentLightingSceneFeedback = new IntFeedback("currentLightingScene", + () => LightingScenes.IndexOf(CurrentLightingScene)); + + OnLightingSceneChange(); + } + + /// + public override void SelectScene(LightingScene scene) + { + var match = LightingScenes.FirstOrDefault(s => s.ID == scene?.ID); + + if (match == null) + { + this.LogWarning("No lighting scene with id '{sceneId}'", scene?.ID); + return; + } + + this.LogInformation("Selecting lighting scene '{sceneId}'", match.ID); + + CurrentLightingScene = match; + CurrentLightingSceneFeedback.FireUpdate(); + OnLightingSceneChange(); + } + + /// + public override void LinkToApi(BasicTriList trilist, uint joinStart, string joinMapKey, EiscApiAdvanced bridge) => + LinkLightingToApi(this, trilist, joinStart, joinMapKey, bridge); +} + +/// +/// Builds a for any config entry typed "mockLightingDevice". +/// +public class MockLightingDeviceFactory : EssentialsDeviceFactory +{ + /// + /// Initializes a new instance of the MockLightingDeviceFactory class + /// + public MockLightingDeviceFactory() + { + TypeNames = new List { "mockLightingDevice" }; + } + + /// + public override EssentialsDevice BuildDevice(DeviceConfig dc) + { + var config = dc.Properties?.ToObject(); + + return new MockLightingDevice(dc.Key, dc.Name, config); + } +} diff --git a/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs b/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs index 8a6ef2f6a..818356eb3 100644 --- a/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs +++ b/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs @@ -139,24 +139,9 @@ public void ExecuteSwitch(object inputSelector, object outputSelector, eRoutingS 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); - - if (_outputSlotsByKey.TryGetValue(outputPort.Key, out var clearedSlot)) - { - clearedSlot.ClearRoute(signalType); - } - - var clearedDescriptor = new RouteSwitchDescriptor(outputPort, null); - RouteChanged?.Invoke(this, clearedDescriptor); + ClearRouteOnOutput(outputPort, signalType); return; } @@ -168,9 +153,6 @@ public void ExecuteSwitch(object inputSelector, object outputSelector, eRoutingS return; } - var descriptor = new RouteSwitchDescriptor(outputPort, inputPort); - CurrentRoutes.Add(descriptor); - if (_outputSlotsByKey.TryGetValue(outputPort.Key, out var routedSlot)) { routedSlot.SetRoute(signalType, inputPort.Key); @@ -179,7 +161,9 @@ public void ExecuteSwitch(object inputSelector, object outputSelector, eRoutingS this.LogInformation("Executed switch: {input} -> {output} ({signalType}) on {key}", inputPort.Key, outputPort.Key, signalType, Key); - RouteChanged?.Invoke(this, descriptor); + RefreshCurrentRoutes(); + + RouteChanged?.Invoke(this, new RouteSwitchDescriptor(outputPort, inputPort)); } catch (Exception ex) { @@ -190,7 +174,55 @@ public void ExecuteSwitch(object inputSelector, object outputSelector, eRoutingS /// public void ClearRoute(object outputSelector, eRoutingSignalType signalType) { - ExecuteSwitch(null, outputSelector, signalType); + 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; + } + + ClearRouteOnOutput(outputPort, signalType); + } + + private void ClearRouteOnOutput(RoutingOutputPort outputPort, eRoutingSignalType signalType) + { + this.LogInformation("Clearing route to output {output} on {key} ({signalType})", outputPort.Key, Key, signalType); + + if (_outputSlotsByKey.TryGetValue(outputPort.Key, out var clearedSlot)) + { + clearedSlot.ClearRoute(signalType); + } + + RefreshCurrentRoutes(); + + RouteChanged?.Invoke(this, new RouteSwitchDescriptor(outputPort, null)); + } + + /// + /// Rebuilds the flat list (required by , + /// consumed by clients that only understand the bare contract) from the per-signal-type routes each + /// output slot actually tracks - one descriptor per (output, signal type) currently routed, so an + /// output with independent audio/video sources yields two descriptors rather than one overwriting + /// the other. + /// + private void RefreshCurrentRoutes() + { + CurrentRoutes.Clear(); + + foreach (var outputPort in OutputPorts) + { + if (!_outputSlotsByKey.TryGetValue(outputPort.Key, out var slot)) continue; + + foreach (var route in slot.CurrentRouteInputKeys) + { + var inputPort = InputPorts.FirstOrDefault(p => p.Key == route.Value); + if (inputPort != null) + { + CurrentRoutes.Add(new RouteSwitchDescriptor(outputPort, inputPort)); + } + } + } } } @@ -249,18 +281,55 @@ public MockRoutingOutputSlotInfo(string key, string name, int slotNumber, eRouti /// /// Records the input key routed to this output for the given signal type and raises . /// + /// + /// Decomposes a combined signal type (e.g. ) into + /// separate Audio/Video entries - the same way DisplayBase.SetCurrentSource does - rather + /// than keying the dictionary by the combined value itself. Without this, routing "Audio & + /// Video" (the common case) would record a single entry under the key AudioVideo that an + /// output slot's independent Audio/Video crosspoint feedback would never match, since a later + /// audio-only or video-only switch looks up Audio/Video individually. + /// public void SetRoute(eRoutingSignalType signalType, string inputKey) { - _currentRouteInputKeys[signalType] = inputKey; - OutputSlotChanged?.Invoke(this, EventArgs.Empty); + var changed = false; + + if (signalType.HasFlag(eRoutingSignalType.Audio)) + { + _currentRouteInputKeys[eRoutingSignalType.Audio] = inputKey; + changed = true; + } + + if (signalType.HasFlag(eRoutingSignalType.Video)) + { + _currentRouteInputKeys[eRoutingSignalType.Video] = inputKey; + changed = true; + } + + if (changed) + { + OutputSlotChanged?.Invoke(this, EventArgs.Empty); + } } /// - /// Clears the routed input key for the given signal type and raises if it changed. + /// Clears the routed input key for the given signal type (decomposed the same way as + /// ) and raises if anything changed. /// public void ClearRoute(eRoutingSignalType signalType) { - if (_currentRouteInputKeys.Remove(signalType)) + var changed = false; + + if (signalType.HasFlag(eRoutingSignalType.Audio)) + { + changed |= _currentRouteInputKeys.Remove(eRoutingSignalType.Audio); + } + + if (signalType.HasFlag(eRoutingSignalType.Video)) + { + changed |= _currentRouteInputKeys.Remove(eRoutingSignalType.Video); + } + + if (changed) { OutputSlotChanged?.Invoke(this, EventArgs.Empty); } From 40ebacd59c7202ec1edef08408aa2d4bb044dbf1 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 28 Sep 2026 13:05:05 -0600 Subject: [PATCH 2/9] feat: enhance MockRoutingMidpoint to support video sync detection and configuration --- .../Routing/MockRoutingMidpoint.cs | 98 ++++++++++++++++++- .../MockRoutingMidpointPropertiesConfig.cs | 21 ++++ 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs b/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs index 818356eb3..5e1bf2546 100644 --- a/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs +++ b/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs @@ -6,6 +6,7 @@ using PepperDash.Core.Logging; using PepperDash.Essentials.Core; using PepperDash.Essentials.Core.Config; +using PepperDash.Essentials.Core.Routing; using Serilog.Events; namespace PepperDash.Essentials.Devices.Common.Routing; @@ -39,6 +40,7 @@ public class MockRoutingMidpoint : EssentialsDevice, IHasNamedRoutingSlots public event RouteChangedEventHandler RouteChanged; private readonly Dictionary _outputSlotsByKey = new Dictionary(); + private readonly List _syncAwareInputSlots = new List(); /// public IReadOnlyDictionary InputSlots { get; private set; } @@ -91,8 +93,23 @@ private void BuildPorts() InputPorts.Add(port); slotNumber++; - inputSlots[portConfig.Name] = new MockRoutingSlotInfo( - portConfig.Name, portConfig.Label ?? portConfig.Name, slotNumber, portConfig.SignalType); + + // Video sync is a video concept - an audio-only input (e.g. a mic) gets the bare + // slot info, with no IRoutingInputSlotInfo/sync status at all, rather than a + // meaningless always-true dot. + if (portConfig.SignalType.HasFlag(eRoutingSignalType.Video)) + { + var syncSlot = new MockRoutingInputSlotInfo( + portConfig.Name, portConfig.Label ?? portConfig.Name, slotNumber, portConfig.SignalType, + portConfig.TxDeviceKey, portConfig.StartsWithSync); + inputSlots[portConfig.Name] = syncSlot; + _syncAwareInputSlots.Add(syncSlot); + } + else + { + inputSlots[portConfig.Name] = new MockRoutingSlotInfo( + portConfig.Name, portConfig.Label ?? portConfig.Name, slotNumber, portConfig.SignalType); + } } slotNumber = 0; @@ -126,6 +143,31 @@ private void BuildPorts() } } + /// + /// Links each input slot's (when configured) to + /// that device's real video sync, once every device exists - a plain constructor-time lookup + /// can't do this since the device it names may not have been built yet. + /// + protected override bool CustomActivate() + { + foreach (var slot in _syncAwareInputSlots) + { + if (string.IsNullOrEmpty(slot.TxDeviceKey)) continue; + + if (!(DeviceManager.GetDeviceForKey(slot.TxDeviceKey) is IVideoSync txDevice)) + { + this.LogWarning("Tx device '{txDeviceKey}' for input slot '{slot}' not found or does not implement IVideoSync", + slot.TxDeviceKey, slot.Key); + continue; + } + + slot.SetVideoSyncDetected(txDevice.VideoSyncDetected); + txDevice.VideoSyncChanged += (sender, args) => slot.SetVideoSyncDetected(txDevice.VideoSyncDetected); + } + + return base.CustomActivate(); + } + /// public void ExecuteSwitch(object inputSelector, object outputSelector, eRoutingSignalType signalType) { @@ -255,6 +297,58 @@ public MockRoutingSlotInfo(string key, string name, int slotNumber, eRoutingSign } } +/// +/// Named input routing slot info for a input port that supports +/// video, adding the status (video sync, online state, +/// transmitter device key) the mobile-control named-routing-slots messenger surfaces when present. +/// +class MockRoutingInputSlotInfo : MockRoutingSlotInfo, IRoutingInputSlotInfo +{ + private bool _videoSyncDetected; + + /// + public string TxDeviceKey { get; } + + /// + public BoolFeedback IsOnline { get; } + + /// + public bool VideoSyncDetected => _videoSyncDetected; + + /// + public event EventHandler VideoSyncChanged; + + /// + /// Initializes a new instance of the class. + /// + public MockRoutingInputSlotInfo(string key, string name, int slotNumber, eRoutingSignalType supportedSignalTypes, + string txDeviceKey, bool startsWithSync) + : base(key, name, slotNumber, supportedSignalTypes) + { + TxDeviceKey = txDeviceKey ?? string.Empty; + _videoSyncDetected = startsWithSync; + + // No real endpoint behind a mock port, so this is always online - only VideoSyncDetected + // (fixed config value, or live-linked via TxDeviceKey - see MockRoutingMidpoint.CustomActivate) + // varies. + IsOnline = new BoolFeedback(() => true); + IsOnline.FireUpdate(); + } + + /// + /// Sets and raises if it changed - + /// called from config-seeded startup and, when names a device + /// implementing IVideoSync, to mirror that device's real sync state live. + /// + public void SetVideoSyncDetected(bool detected) + { + if (_videoSyncDetected == detected) return; + + _videoSyncDetected = detected; + VideoSyncChanged?.Invoke(this, EventArgs.Empty); + } +} + /// /// Named output routing slot info for a output port, tracking the /// currently routed input key per signal type since the mock's flat diff --git a/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpointPropertiesConfig.cs b/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpointPropertiesConfig.cs index b24e31aa9..3e433ffbe 100644 --- a/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpointPropertiesConfig.cs +++ b/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpointPropertiesConfig.cs @@ -58,4 +58,25 @@ public class MockRoutingMidpointPortConfig /// [JsonProperty("label", NullValueHandling = NullValueHandling.Ignore)] public string Label { get; set; } + + /// + /// For an input port that supports video only: whether it reports video sync detected on + /// startup, via . Defaults to true - a + /// source you have to explicitly break is a better mock default than one that starts broken. + /// Ignored for output ports and for audio-only input ports (no video to sync), and overridden by + /// when that names a device implementing IVideoSync. + /// + [JsonProperty("startsWithSync")] + public bool StartsWithSync { get; set; } = true; + + /// + /// For an input port that supports video only: optional key of the device feeding it, surfaced + /// via . When that device implements + /// PepperDash.Essentials.Core.Routing.IVideoSync, the slot mirrors its real + /// VideoSyncDetected/VideoSyncChanged live instead of using the fixed + /// value - otherwise it's purely informational. Ignored for output + /// ports and for audio-only input ports. + /// + [JsonProperty("txDeviceKey", NullValueHandling = NullValueHandling.Ignore)] + public string TxDeviceKey { get; set; } } From 32311dd3ea87d072da49ca0c58a99d36ced82772 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 28 Sep 2026 20:14:28 -0600 Subject: [PATCH 3/9] fix: avoid enumeration issues by copying current source keys and handling nulls --- .../Messengers/ICurrentSourcesMessenger.cs | 13 ++++++---- .../IHasNamedRoutingSlotsMessenger.cs | 24 +++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/ICurrentSourcesMessenger.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/ICurrentSourcesMessenger.cs index 12d1dea6f..83b37a845 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/ICurrentSourcesMessenger.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/ICurrentSourcesMessenger.cs @@ -40,21 +40,24 @@ protected override void RegisterActions() sourceDevice.CurrentSourcesChanged += (sender, e) => { - // need to copy the dictionaries to avoid enumeration issues - var currentSourceKeys = sourceDevice.CurrentSourceKeys.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - PostStatusMessage(JToken.FromObject(new { - currentSourceKeys, + currentSourceKeys = CopyCurrentSourceKeys(), })); }; } + // Copies to avoid enumeration issues, and sends a cleared source as "" rather than null: + // clients merge status updates into existing state and skip nulls, so a null would leave the + // previous source showing. + private Dictionary CopyCurrentSourceKeys() => + sourceDevice.CurrentSourceKeys.ToDictionary(kvp => kvp.Key, kvp => kvp.Value ?? string.Empty); + private void SendCurrentSourceStatus(string id) { var message = new CurrentSourcesStateMessage { - CurrentSourceKeys = sourceDevice.CurrentSourceKeys, + CurrentSourceKeys = CopyCurrentSourceKeys(), }; PostStatusMessage(message, id); diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IHasNamedRoutingSlotsMessenger.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IHasNamedRoutingSlotsMessenger.cs index ec247a85e..296b75470 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IHasNamedRoutingSlotsMessenger.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IHasNamedRoutingSlotsMessenger.cs @@ -72,19 +72,29 @@ private static RoutingSlotMessage BuildOutputMessage( IRoutingOutputSlotInfo slot, IReadOnlyDictionary inputs) { + // Every supported signal type gets an entry, with "" for "nothing routed": clients merge + // status updates into existing state, so an omitted or null entry would leave a cleared + // route showing its previous input. + var routeInputKeys = new[] { eRoutingSignalType.Audio, eRoutingSignalType.Video } + .Where(t => slot.SupportedSignalTypes.HasFlag(t)) + .ToDictionary( + t => t.ToString(), + t => slot.CurrentRouteInputKeys.TryGetValue(t, out var inputKey) && inputKey != null ? inputKey : string.Empty); + var message = new RoutingSlotMessage { Key = slot.Key, Name = slot.Name, SlotNumber = slot.SlotNumber, SupportedSignalTypes = slot.SupportedSignalTypes.ToString(), - CurrentRouteInputKeys = slot.CurrentRouteInputKeys - .ToDictionary(r => r.Key.ToString(), r => r.Value), - CurrentRoutes = slot.CurrentRouteInputKeys.ToDictionary( - r => r.Key.ToString(), - r => inputs.TryGetValue(r.Value, out var input) - ? input - : new RoutingSlotMessage { Key = r.Value }) + CurrentRouteInputKeys = routeInputKeys, + CurrentRoutes = routeInputKeys.ToDictionary( + r => r.Key, + r => r.Value == string.Empty + ? new RoutingSlotMessage { Key = string.Empty, Name = string.Empty } + : inputs.TryGetValue(r.Value, out var input) + ? input + : new RoutingSlotMessage { Key = r.Value }) }; if (slot is IRoutingOutputSlotStatus status) From 264bb09eea270dfb858477a5d61fbab86c052c2d Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 28 Sep 2026 20:24:08 -0600 Subject: [PATCH 4/9] feat: add ICommunicationMonitor implementation to mock devices and create MockCommunicationMonitor class --- .../Audio/MockAudioDevice.cs | 8 ++++- .../Displays/MockDisplay.cs | 8 ++++- .../Lighting/MockLightingDevice.cs | 8 ++++- .../MockCommunicationMonitor.cs | 30 +++++++++++++++++++ .../Routing/MockRoutingMidpoint.cs | 8 ++++- 5 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 src/PepperDash.Essentials.Devices.Common/MockCommunicationMonitor.cs diff --git a/src/PepperDash.Essentials.Devices.Common/Audio/MockAudioDevice.cs b/src/PepperDash.Essentials.Devices.Common/Audio/MockAudioDevice.cs index e368a5219..4bc4110ba 100644 --- a/src/PepperDash.Essentials.Devices.Common/Audio/MockAudioDevice.cs +++ b/src/PepperDash.Essentials.Devices.Common/Audio/MockAudioDevice.cs @@ -14,8 +14,12 @@ namespace PepperDash.Essentials.Devices.Common; /// with ramping volume up/down while pressed and mute on/off/toggle, all backed by fake in-memory /// state rather than any real hardware communication. /// -public class MockAudioDevice : EssentialsDevice, IBasicVolumeWithFeedback +public class MockAudioDevice : EssentialsDevice, IBasicVolumeWithFeedback, ICommunicationMonitor { + /// + /// Always online: there's no real connection behind this mock to lose. + public StatusMonitorBase CommunicationMonitor { get; } + private const int VolumeHeldRepeatIntervalMs = 100; private const ushort VolumeStep = 655; @@ -38,6 +42,8 @@ public class MockAudioDevice : EssentialsDevice, IBasicVolumeWithFeedback public MockAudioDevice(string key, string name) : base(key, name) { + CommunicationMonitor = new MockCommunicationMonitor(this); + VolumeLevelFeedback = new IntFeedback("volume", () => _volumeLevel); MuteFeedback = new BoolFeedback("muteOn", () => _isMuted); diff --git a/src/PepperDash.Essentials.Devices.Common/Displays/MockDisplay.cs b/src/PepperDash.Essentials.Devices.Common/Displays/MockDisplay.cs index a7e306601..0b5a87b73 100644 --- a/src/PepperDash.Essentials.Devices.Common/Displays/MockDisplay.cs +++ b/src/PepperDash.Essentials.Devices.Common/Displays/MockDisplay.cs @@ -15,8 +15,12 @@ namespace PepperDash.Essentials.Devices.Common.Displays; /// /// Represents a mock display device for testing and simulation purposes. /// -public class MockDisplay : TwoWayDisplayBase, IBasicVolumeWithFeedback, IBridgeAdvanced, IHasInputs, IHasPowerControlWithFeedback +public class MockDisplay : TwoWayDisplayBase, IBasicVolumeWithFeedback, IBridgeAdvanced, IHasInputs, IHasPowerControlWithFeedback, ICommunicationMonitor { + /// + /// Always online: there's no real connection behind this mock to lose. + public StatusMonitorBase CommunicationMonitor { get; } + /// public ISelectableItems Inputs { get; private set; } @@ -77,6 +81,8 @@ protected override Func IsWarmingUpFeedbackFunc public MockDisplay(string key, string name) : base(key, name) { + CommunicationMonitor = new MockCommunicationMonitor(this); + Inputs = new MockDisplayInputs { Items = new Dictionary diff --git a/src/PepperDash.Essentials.Devices.Common/Lighting/MockLightingDevice.cs b/src/PepperDash.Essentials.Devices.Common/Lighting/MockLightingDevice.cs index 9ec2a1ca0..977781586 100644 --- a/src/PepperDash.Essentials.Devices.Common/Lighting/MockLightingDevice.cs +++ b/src/PepperDash.Essentials.Devices.Common/Lighting/MockLightingDevice.cs @@ -53,8 +53,12 @@ public class MockLightingSceneConfig /// and fires feedback, exactly like a real DSP /// or lighting processor would once the scene recall is acknowledged. /// -public class MockLightingDevice : LightingBase +public class MockLightingDevice : LightingBase, ICommunicationMonitor { + /// + /// Always online: there's no real connection behind this mock to lose. + public StatusMonitorBase CommunicationMonitor { get; } + private static readonly List DefaultScenes = new List { new MockLightingSceneConfig { Id = "on", Name = "On" }, @@ -71,6 +75,8 @@ public class MockLightingDevice : LightingBase public MockLightingDevice(string key, string name, MockLightingConfig config) : base(key, name) { + CommunicationMonitor = new MockCommunicationMonitor(this); + var scenesConfig = config?.Scenes != null && config.Scenes.Count > 0 ? config.Scenes : DefaultScenes; diff --git a/src/PepperDash.Essentials.Devices.Common/MockCommunicationMonitor.cs b/src/PepperDash.Essentials.Devices.Common/MockCommunicationMonitor.cs new file mode 100644 index 000000000..546487f9c --- /dev/null +++ b/src/PepperDash.Essentials.Devices.Common/MockCommunicationMonitor.cs @@ -0,0 +1,30 @@ +using PepperDash.Core; +using PepperDash.Essentials.Core; + +namespace PepperDash.Essentials.Devices.Common; + +/// +/// A for mock devices, whose status is set directly rather than +/// derived from polling a real connection. Reports until told otherwise. +/// +public class MockCommunicationMonitor : StatusMonitorBase +{ + /// + /// Initializes a new instance of the class, reporting online. + /// + public MockCommunicationMonitor(IKeyed parent) : base(parent, 30000, 60000) + { + Status = MonitorStatus.IsOk; + } + + /// + public override void Start() { } + + /// + public override void Stop() { } + + /// + /// Sets the monitor's current status. + /// + public void SetStatus(MonitorStatus status) => Status = status; +} diff --git a/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs b/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs index 5e1bf2546..4e6858897 100644 --- a/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs +++ b/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs @@ -20,8 +20,12 @@ namespace PepperDash.Essentials.Devices.Common.Routing; /// device cannot support. /// [Description("A mock routing midpoint (e.g. matrix switcher) device for testing routing logic without real hardware")] -public class MockRoutingMidpoint : EssentialsDevice, IHasNamedRoutingSlots +public class MockRoutingMidpoint : EssentialsDevice, IHasNamedRoutingSlots, ICommunicationMonitor { + /// + /// Always online: there's no real connection behind this mock to lose. + public StatusMonitorBase CommunicationMonitor { get; } + /// /// The configuration properties for this device. /// @@ -55,6 +59,8 @@ public class MockRoutingMidpoint : EssentialsDevice, IHasNamedRoutingSlots public MockRoutingMidpoint(DeviceConfig config) : base(config.Key, config.Name) { + CommunicationMonitor = new MockCommunicationMonitor(this); + PropertiesConfig = config.Properties != null ? JsonConvert.DeserializeObject(config.Properties.ToString()) : null; From 9e28671a792ddec4ce8a34d50ba7edebe8e2b5c4 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 28 Sep 2026 23:28:36 -0600 Subject: [PATCH 5/9] fix: update app URL resolution to use ResolveAppUrlHost for improved handling of development server addresses --- .../MobileControlTouchpanelController.cs | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs index 6d4915d98..361a87a81 100644 --- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs +++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs @@ -491,7 +491,7 @@ private void SendUrlToPanel() { // Mint a fresh cache buster on every send. Some panels hold on to a cached copy of the app // and won't re-download it unless the URL they're handed is different from the last one. - var appUrl = GetUrlWithCacheBuster(GetUrlWithCorrectIp(_appUrl)); + var appUrl = GetUrlWithCacheBuster(ResolveAppUrlHost(_appUrl)); this.LogInformation("Sending {appUrl} on join 1", appUrl); @@ -688,19 +688,31 @@ public void SetAppUrl(string url) return; } - if(localConfig.DevelopmentServerAddress != null) - { - url = Regex.Replace(url, @"^(https?)://[^/]+", $"$1://{localConfig.DevelopmentServerAddress}"); - this.LogInformation("Using development server IP, updated URL: {url}", url); - } - else + _appUrl = GetUrlWithCacheBuster(ResolveAppUrlHost(url)); + + AppUrlFeedback.FireUpdate(); + } + + /// + /// Points the URL at the configured development server if there is one, otherwise at the + /// processor IP the panel can reach. + /// + private string ResolveAppUrlHost(string url) + { + if (localConfig.DevelopmentServerAddress == null) { - url = GetUrlWithCorrectIp(url); + return GetUrlWithCorrectIp(url); } - _appUrl = GetUrlWithCacheBuster(url); + var devServerAddress = localConfig.DevelopmentServerAddress.TrimEnd('/'); + url = Regex.Replace(url, @"^(https?)://[^/]+", $"$1://{devServerAddress}"); + // Dev servers (e.g. Vite with base "/mc/app/") only match the app path with its + // trailing slash; the processor's own server accepts "/mc/app?token=..." without it. + url = Regex.Replace(url, @"/mc/app(?=\?|$)", "/mc/app/"); - AppUrlFeedback.FireUpdate(); + this.LogInformation("Using development server address, updated URL: {url}", url); + + return url; } private void UpdateFeedbacks(object sender, EventArgs args) From e23d6292b5c5bd2ab2b5993665b46db309de9666 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 29 Sep 2026 11:09:12 -0600 Subject: [PATCH 6/9] feat: enhance GenericSink to track last switched input port and improve port selection handling closes #1490 --- .../Generic/GenericSink.cs | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/PepperDash.Essentials.Devices.Common/Generic/GenericSink.cs b/src/PepperDash.Essentials.Devices.Common/Generic/GenericSink.cs index 7e1c72977..e6a76d3d7 100644 --- a/src/PepperDash.Essentials.Devices.Common/Generic/GenericSink.cs +++ b/src/PepperDash.Essentials.Devices.Common/Generic/GenericSink.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using PepperDash.Core; using PepperDash.Core.Logging; using PepperDash.Essentials.Core; @@ -32,8 +33,9 @@ public GenericSink(string key, string name) : base(key, name) { InputPorts = new RoutingPortCollection(); - var inputPort = new RoutingInputPort(RoutingPortNames.AnyVideoIn, eRoutingSignalType.AudioVideo, eRoutingPortConnectionType.Hdmi, null, this); - var audioInputPort = new RoutingInputPort(RoutingPortNames.AnyAudioIn, eRoutingSignalType.Audio, eRoutingPortConnectionType.LineAudio, null, this); + // Each port's selector is its own key, so ExecuteSwitch can tell which one was switched to. + var inputPort = new RoutingInputPort(RoutingPortNames.AnyVideoIn, eRoutingSignalType.AudioVideo, eRoutingPortConnectionType.Hdmi, RoutingPortNames.AnyVideoIn, this); + var audioInputPort = new RoutingInputPort(RoutingPortNames.AnyAudioIn, eRoutingSignalType.Audio, eRoutingPortConnectionType.LineAudio, RoutingPortNames.AnyAudioIn, this); InputPorts.Add(inputPort); InputPorts.Add(audioInputPort); @@ -111,10 +113,12 @@ private void UpdateCurrentSources(eRoutingSignalType signalType, IRoutingSource private SourceListItem _currentSource; + private RoutingInputPort _currentInputPort; + /// - /// Gets the current input port + /// Gets the input port last switched to, or the first input port before any switch /// - public RoutingInputPort CurrentInputPort => InputPorts[0]; + public RoutingInputPort CurrentInputPort => _currentInputPort ?? InputPorts[0]; /// public event InputChangedEventHandler InputChanged; @@ -123,6 +127,20 @@ private void UpdateCurrentSources(eRoutingSignalType signalType, IRoutingSource public void ExecuteSwitch(object inputSelector) { this.LogDebug("GenericSink Executing Switch to: {inputSelector}", inputSelector); + + var port = InputPorts.FirstOrDefault(p => Equals(p.Selector, inputSelector)); + + if (port == null) + { + this.LogWarning("GenericSink has no input port with selector {inputSelector}", inputSelector); + return; + } + + if (_currentInputPort == port) return; + + _currentInputPort = port; + + InputChanged?.Invoke(this, _currentInputPort); } } From f734650584e24a5944e3ea6c50a7bd122381fa3c Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 29 Sep 2026 11:20:43 -0600 Subject: [PATCH 7/9] feat: enhance routing logic for midpoints to handle null output ports and improve signal type management Closes NullReferenceException in RouteDescriptor.ExecuteRoutes when a route terminates on an IRoutingMidpointWithFeedback Fixes #1491 --- .../Routing/Extensions.cs | 21 +++++++++++++++++-- .../Routing/RouteDescriptor.cs | 21 +++++++++++++------ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index 84f618ab3..aed888051 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -710,13 +710,30 @@ private static bool GetRouteToSource(this IRoutingInputs destination, IRoutingOu // it's a sink device routeTable.Routes.Add(new RouteSwitchDescriptor(goodInputPort)); } - else if (destination is IRoutingMidpointWithFeedback) + else if (destination is IRoutingMidpointWithFeedback midpoint) { - routeTable.Routes.Add(new RouteSwitchDescriptor(outputPortToUse, goodInputPort)); + // outputPortToUse is only null when the midpoint is the route's terminal destination, so no + // downstream device asked for a particular output. Keep the hop so the midpoint still selects + // its input, and use its output only when that choice is unambiguous. + var outputPort = outputPortToUse ?? GetOnlyOutputPortForSignalType(midpoint, signalType); + + if (outputPort == null) + { + Debug.LogMessage(LogEventLevel.Debug, "Route terminates on midpoint {midpointKey} with no single output for {signalType}; switching input {inputPortKey} with no output selector", destination, destination.Key, signalType, goodInputPort.Key); + } + + routeTable.Routes.Add(new RouteSwitchDescriptor(outputPort, goodInputPort)); } else // device is merely IRoutingInputOutputs Debug.LogMessage(LogEventLevel.Verbose, "No routing. Passthrough device", destination); return true; } + + private static RoutingOutputPort GetOnlyOutputPortForSignalType(IRoutingOutputs device, eRoutingSignalType signalType) + { + var candidates = device.OutputPorts?.Where(p => p.Type.HasFlag(signalType)).Take(2).ToList(); + + return candidates?.Count == 1 ? candidates[0] : null; + } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs b/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs index 32822d0d4..5973eb1ae 100644 --- a/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs +++ b/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs @@ -100,7 +100,14 @@ public void ExecuteRoutes() if (route.SwitchingDevice is IRoutingMidpointWithFeedback switchingDevice) { - switchingDevice.ExecuteSwitch(route.InputPort.Selector, route.OutputPort.Selector, SignalType); + // OutputPort is null when the route terminates on this midpoint and it has no single + // output for the signal type; the device decides what a null output selector means. + switchingDevice.ExecuteSwitch(route.InputPort?.Selector, route.OutputPort?.Selector, SignalType); + + if (route.OutputPort?.InUseTracker == null) + { + continue; + } route.OutputPort.InUseTracker.AddUser(Destination, "destination-" + SignalType); @@ -121,6 +128,13 @@ public void ReleaseRoutes(bool clearRoute = false) { if (route.SwitchingDevice is IRoutingMidpointWithFeedback switchingDevice) { + // With no output port (a terminal midpoint with no single output), there is nothing to + // clear: a null input and null output selector would carry no meaning for the device. + if (route.OutputPort == null) + { + continue; + } + if (clearRoute) { try @@ -134,11 +148,6 @@ public void ReleaseRoutes(bool clearRoute = false) } } - if (route.OutputPort == null) - { - continue; - } - if (route.OutputPort.InUseTracker != null) { route.OutputPort.InUseTracker.RemoveUser(Destination, "destination-" + SignalType); From cb7970b9c455662652bbe3dd8376d5caeba68c99 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 29 Sep 2026 11:37:10 -0600 Subject: [PATCH 8/9] fix: feedback update for current lighting scene Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Lighting/MockLightingDevice.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/PepperDash.Essentials.Devices.Common/Lighting/MockLightingDevice.cs b/src/PepperDash.Essentials.Devices.Common/Lighting/MockLightingDevice.cs index 977781586..3679d5dfd 100644 --- a/src/PepperDash.Essentials.Devices.Common/Lighting/MockLightingDevice.cs +++ b/src/PepperDash.Essentials.Devices.Common/Lighting/MockLightingDevice.cs @@ -91,6 +91,7 @@ public MockLightingDevice(string key, string name, MockLightingConfig config) CurrentLightingSceneFeedback = new IntFeedback("currentLightingScene", () => LightingScenes.IndexOf(CurrentLightingScene)); + CurrentLightingSceneFeedback.FireUpdate(); OnLightingSceneChange(); } From 59e1fb1961f347101da654c414e6717d990104af Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 29 Sep 2026 11:41:27 -0600 Subject: [PATCH 9/9] fix: track USB and per-signal route types in mock midpoint feedback MockRoutingOutputSlotInfo and IHasNamedRoutingSlotsMessenger only handled Audio/Video, dropping USB routes. RouteSwitchDescriptor gains an optional SignalType so split audio/video routes from AudioVideo inputs report correctly over the routing feedback websocket. Co-Authored-By: Claude Opus 5.5 --- .../Routing/RouteSwitchDescriptor.cs | 6 ++++ .../Web/RoutingFeedbackWebsocket.cs | 4 +-- .../Routing/MockRoutingMidpoint.cs | 29 +++++++++---------- .../IHasNamedRoutingSlotsMessenger.cs | 9 +++++- 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/RouteSwitchDescriptor.cs b/src/PepperDash.Essentials.Core/Routing/RouteSwitchDescriptor.cs index 2e526589a..c5cb843d7 100644 --- a/src/PepperDash.Essentials.Core/Routing/RouteSwitchDescriptor.cs +++ b/src/PepperDash.Essentials.Core/Routing/RouteSwitchDescriptor.cs @@ -19,6 +19,12 @@ public class RouteSwitchDescriptor /// public RoutingInputPort InputPort { get; set; } + /// + /// The signal type this switch carries, for devices that route signal types independently. + /// Null means the switch carries whatever the input port supports. + /// + public eRoutingSignalType? SignalType { get; set; } + /// /// Initializes a new instance of the class for sink devices (no output port). /// diff --git a/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs b/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs index 781e5f242..b344fa0ae 100644 --- a/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs +++ b/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs @@ -209,7 +209,7 @@ internal string GetSnapshotMessage() { InputPortKey = r.InputPort.Key, OutputPortKey = r.OutputPort?.Key, - SignalType = r.InputPort.Type.ToString() + SignalType = (r.SignalType ?? r.InputPort.Type).ToString() }) .ToList(); } @@ -330,7 +330,7 @@ private void HandleMidpointRouteChanged(IRoutingMidpointWithFeedback midpoint, R { InputPortKey = r.InputPort.Key, OutputPortKey = r.OutputPort?.Key, - SignalType = r.InputPort.Type.ToString() + SignalType = (r.SignalType ?? r.InputPort.Type).ToString() }) .ToList() ?? new List(); diff --git a/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs b/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs index 4e6858897..05919855f 100644 --- a/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs +++ b/src/PepperDash.Essentials.Devices.Common/Routing/MockRoutingMidpoint.cs @@ -267,7 +267,9 @@ private void RefreshCurrentRoutes() var inputPort = InputPorts.FirstOrDefault(p => p.Key == route.Value); if (inputPort != null) { - CurrentRoutes.Add(new RouteSwitchDescriptor(outputPort, inputPort)); + // Keep which signal this entry carries: an AudioVideo input can be routed for audio + // only, so the input port's own type isn't enough to tell consumers what's routed. + CurrentRoutes.Add(new RouteSwitchDescriptor(outputPort, inputPort) { SignalType = route.Key }); } } } @@ -370,6 +372,12 @@ class MockRoutingOutputSlotInfo : MockRoutingSlotInfo, IRoutingOutputSlotInfo /// public event EventHandler OutputSlotChanged; + // The single-flag signal types (Audio, Video, Usb, ...) a combined value such as AudioVideo breaks into. + private static readonly eRoutingSignalType[] AtomicSignalTypes = Enum.GetValues(typeof(eRoutingSignalType)) + .Cast() + .Where(t => t != 0 && ((int)t & ((int)t - 1)) == 0) + .ToArray(); + /// /// Initializes a new instance of the class. /// @@ -393,15 +401,9 @@ public void SetRoute(eRoutingSignalType signalType, string inputKey) { var changed = false; - if (signalType.HasFlag(eRoutingSignalType.Audio)) - { - _currentRouteInputKeys[eRoutingSignalType.Audio] = inputKey; - changed = true; - } - - if (signalType.HasFlag(eRoutingSignalType.Video)) + foreach (var type in AtomicSignalTypes.Where(t => signalType.HasFlag(t))) { - _currentRouteInputKeys[eRoutingSignalType.Video] = inputKey; + _currentRouteInputKeys[type] = inputKey; changed = true; } @@ -419,14 +421,9 @@ public void ClearRoute(eRoutingSignalType signalType) { var changed = false; - if (signalType.HasFlag(eRoutingSignalType.Audio)) - { - changed |= _currentRouteInputKeys.Remove(eRoutingSignalType.Audio); - } - - if (signalType.HasFlag(eRoutingSignalType.Video)) + foreach (var type in AtomicSignalTypes.Where(t => signalType.HasFlag(t))) { - changed |= _currentRouteInputKeys.Remove(eRoutingSignalType.Video); + changed |= _currentRouteInputKeys.Remove(type); } if (changed) diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IHasNamedRoutingSlotsMessenger.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IHasNamedRoutingSlotsMessenger.cs index 296b75470..626eb039c 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IHasNamedRoutingSlotsMessenger.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IHasNamedRoutingSlotsMessenger.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; @@ -68,6 +69,12 @@ private static RoutingSlotMessage BuildInputMessage(IRoutingSlotInfo slot) return message; } + // The single-flag signal types (Audio, Video, Usb, ...) a slot's supported types break into. + private static readonly eRoutingSignalType[] AtomicSignalTypes = Enum.GetValues(typeof(eRoutingSignalType)) + .Cast() + .Where(t => t != 0 && ((int)t & ((int)t - 1)) == 0) + .ToArray(); + private static RoutingSlotMessage BuildOutputMessage( IRoutingOutputSlotInfo slot, IReadOnlyDictionary inputs) @@ -75,7 +82,7 @@ private static RoutingSlotMessage BuildOutputMessage( // Every supported signal type gets an entry, with "" for "nothing routed": clients merge // status updates into existing state, so an omitted or null entry would leave a cleared // route showing its previous input. - var routeInputKeys = new[] { eRoutingSignalType.Audio, eRoutingSignalType.Video } + var routeInputKeys = AtomicSignalTypes .Where(t => slot.SupportedSignalTypes.HasFlag(t)) .ToDictionary( t => t.ToString(),