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);
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/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/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);
}
}
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..3679d5dfd
--- /dev/null
+++ b/src/PepperDash.Essentials.Devices.Common/Lighting/MockLightingDevice.cs
@@ -0,0 +1,142 @@
+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, 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" },
+ 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)
+ {
+ CommunicationMonitor = new MockCommunicationMonitor(this);
+
+ 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));
+ CurrentLightingSceneFeedback.FireUpdate();
+
+ 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/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 8a6ef2f6a..05919855f 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;
@@ -19,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.
///
@@ -39,6 +44,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; }
@@ -53,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;
@@ -91,8 +99,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 +149,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)
{
@@ -139,24 +187,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 +201,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 +209,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 +222,57 @@ 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)
+ {
+ // 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 });
+ }
+ }
+ }
}
}
@@ -223,6 +305,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
@@ -238,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.
///
@@ -249,18 +389,44 @@ 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;
+
+ foreach (var type in AtomicSignalTypes.Where(t => signalType.HasFlag(t)))
+ {
+ _currentRouteInputKeys[type] = 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;
+
+ foreach (var type in AtomicSignalTypes.Where(t => signalType.HasFlag(t)))
+ {
+ changed |= _currentRouteInputKeys.Remove(type);
+ }
+
+ if (changed)
{
OutputSlotChanged?.Invoke(this, EventArgs.Empty);
}
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; }
}
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..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,23 +69,39 @@ 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)
{
+ // 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 = AtomicSignalTypes
+ .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)
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)