Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8817d70
feat: add interfaces for VLAN and PoE management in network switches
ndorin Mar 20, 2026
76311b8
feat: add event and arguments for port state changes in network switc…
ndorin Mar 20, 2026
6db7581
feat: add Unknown event type to NetworkSwitchPortEventType enum for i…
ndorin Mar 20, 2026
84c730b
feat: enhance NetworkSwitchPortEventType enum with additional states …
ndorin Mar 20, 2026
1b10910
fix: update GetFeedbacksForDeviceRequestHandler to return JSON respon…
ndorin Apr 1, 2026
d0ab42c
Merge branch 'feature/add-network-switch-poe-interfaces' into feature…
ndorin Apr 1, 2026
2121734
Merge branch 'main' into feature/web-api-updates
erikdred May 19, 2026
94c9e75
Merge branch 'main' into feature/web-api-updates
ngenovese11 May 21, 2026
6587444
fix: update routing logic and enhance logging in Extensions and MockV…
ndorin May 21, 2026
92d13f2
fix: refine routing logic by filtering IRoutingInputs and IRoutingOut…
ndorin May 21, 2026
74a12c9
fix: add null or empty checks for midpoint keys in RoutingFeedbackMan…
ndorin May 22, 2026
b32bab0
fix: remove impossibleRoutes cache
andrew-welker Jun 9, 2026
162a06f
feat: adds interface for wireless sharing
aknous Jun 11, 2026
d18fca8
fix: handle null properties in DeviceMessageBase and DeviceStateMessa…
ndorin Jun 10, 2026
6a4e3d5
feat: add port forwarding functionality for debug websocket in DebugS…
jkdevito Jun 12, 2026
a7b8392
Potential fix for pull request finding
equinoy Jun 15, 2026
e03e45c
Potential fix for pull request finding
equinoy Jun 15, 2026
b982219
Potential fix for pull request finding
equinoy Jun 15, 2026
a220474
Potential fix for pull request finding
equinoy Jun 15, 2026
8d3edde
feat: adds config props to disable power on/off automation
aknous Jun 17, 2026
aa76551
fix: guard CecPortController against null StreamCec and lazily subscribe
erikdred Jul 16, 2026
f4cb173
Potential fix for pull request finding
erikdred Jul 16, 2026
fbf4c56
Potential fix for pull request finding
erikdred Jul 16, 2026
36c15b8
Clarify CecPortController subscription XML comment
Copilot Jul 16, 2026
14cd082
Remove redundant IRoutingInputsOutputs filtering in route mapping loops
Copilot Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public class CecPortController : Device, IBasicCommunicationWithStreamDebugging

ICec Port;

bool _cecSubscribed;

/// <summary>
/// Constructor
/// </summary>
Expand All @@ -54,7 +56,7 @@ public CecPortController(string key, Func<EssentialsControlPropertiesConfig, ICe
{
Port = postActivationFunc(config);

Port.StreamCec.CecChange += StreamCec_CecChange;
TryEnsureCecSubscription();
});
}

Expand All @@ -68,7 +70,29 @@ public CecPortController(string key, ICec port)
{
Port = port;

TryEnsureCecSubscription();
}

/// <summary>
/// Subscribes to the CEC change event once <see cref="ICec.StreamCec"/> is available.
/// Safe to call repeatedly; the subscription is only wired a single time.
/// If StreamCec is null during construction, this is retried when send methods invoke
/// this method later.
/// </summary>
void TryEnsureCecSubscription()
{
if (_cecSubscribed)
return;

if (Port?.StreamCec == null)
{
Debug.LogMessage(LogEventLevel.Warning, this, "StreamCec is not available; CEC feedback is deferred until the device is ready");
return;
}

Port.StreamCec.CecChange += new CecChangeEventHandler(StreamCec_CecChange);
_cecSubscribed = true;
Debug.LogMessage(LogEventLevel.Information, this, "Subscribed to CEC feedback");
}

void StreamCec_CecChange(Cec cecDevice, CecEventArgs args)
Expand Down Expand Up @@ -104,8 +128,9 @@ void OnDataReceived(string s)
/// </summary>
public void SendText(string text)
{
if (Port == null)
if (Port?.StreamCec == null)
return;
TryEnsureCecSubscription();
this.PrintSentText(text);
Port.StreamCec.Send.StringValue = text;
}
Expand All @@ -115,8 +140,9 @@ public void SendText(string text)
/// </summary>
public void SendBytes(byte[] bytes)
{
if (Port == null)
if (Port?.StreamCec == null)
return;
TryEnsureCecSubscription();
var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length);
this.PrintSentBytes(bytes);
Debug.LogMessage(LogEventLevel.Information, this, "Sending {0} bytes: '{1}'", bytes.Length, ComTextHelper.GetEscapedText(bytes));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System;

namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{
/// <summary>
/// Defines the contract for a wireless presentation endpoint that reports whether a wireless
/// sharing session is currently active. Implemented by platforms such as Crestron AirMedia,
/// Mersive Solstice, Barco ClickShare, Miracast/Teams receivers, etc. Allows consumers (e.g.
/// room plugins) to react to wireless sharing activity without taking a dependency on any
/// concrete device implementation.
/// </summary>
/// <remarks>
/// This refers specifically to wireless screen/device mirroring, as distinct from in-call
/// content sharing on a video conference.
/// </remarks>
public interface IHasWirelessSharing
{
/// <summary>
/// Reports whether a wireless sharing session is currently active (content is being presented).
/// </summary>
BoolFeedback IsSharingFeedback { get; }

/// <summary>
/// Raised when wireless sharing starts or stops. The event args carry the new sharing state.
/// </summary>
event EventHandler<WirelessSharingEventArgs> SharingChanged;
}

/// <summary>
/// Event arguments describing a change in wireless sharing state.
/// </summary>
public class WirelessSharingEventArgs : EventArgs
{
/// <summary>
/// True if a wireless sharing session is active (content is being presented), false otherwise.
/// </summary>
public bool IsSharing { get; private set; }

/// <summary>
/// Creates a new <see cref="WirelessSharingEventArgs"/>.
/// </summary>
/// <param name="isSharing">True if a wireless sharing session is active, false otherwise.</param>
public WirelessSharingEventArgs(bool isSharing)
{
IsSharing = isSharing;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System;

namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{
/// <summary>
/// Interface for network switches that support VLAN assignment on individual ports.
/// </summary>
public interface INetworkSwitchVlanManager
{
/// <summary>
/// Returns the current access VLAN ID configured on the port.
/// Return -1 when the value is unavailable (e.g. the switch has not been polled yet
/// or the implementation does not support VLAN queries).
/// </summary>
/// <param name="port">Switch port identifier</param>
/// <returns>VLAN ID or -1 when unavailable</returns>
int GetPortCurrentVlan(string port);

/// <summary>
/// Changes the access VLAN of a single switch port.
/// The implementation is responsible for entering/exiting privileged/config mode.
/// </summary>
/// <param name="port">Switch port identifier (e.g. "1/0/3" for Netgear, "gi1/0/3" for Cisco)</param>
/// <param name="vlanId">Target VLAN ID (1-4093)</param>
void SetPortVlan(string port, uint vlanId);
}

/// <summary>
/// Interface for network switches that support Power over Ethernet (PoE) control on individual ports.
/// </summary>
public interface INetworkSwitchPoeManager
{
/// <summary>
/// Enables or disables PoE power delivery on a single switch port.
/// The implementation is responsible for entering/exiting privileged/config mode.
/// </summary>
/// <param name="port">Switch port identifier</param>
/// <param name="enabled">True to enable PoE; false to disable PoE</param>
void SetPortPoeState(string port, bool enabled);
}

/// <summary>
/// Standardized interface for network switch devices that support per-port PoE control
/// and VLAN assignment.
/// </summary>
public interface INetworkSwitchPoeVlanManager : INetworkSwitchVlanManager, INetworkSwitchPoeManager
{
/// <summary>
/// Event that is raised when the state of a switch port changes, such as a VLAN change or PoE state change.
/// </summary>
event EventHandler<NetworkSwitchPortEventArgs> PortStateChanged;

}

/// <summary>
/// Event arguments for port state changes on a network switch, such as VLAN changes or PoE state changes.
/// </summary>
public class NetworkSwitchPortEventArgs : EventArgs
{
/// <summary>
/// The identifier of the port that changed state (e.g. "1/0/3" for Netgear, "gi1/0/3" for Cisco).
/// </summary>
public string Port { get; private set; }

/// <summary>
/// The type of event that occurred on the port (e.g. VLAN change, PoE enabled/disabled).
/// </summary>
public NetworkSwitchPortEventType EventType { get; private set; }

/// <summary>
/// Constructor for NetworkSwitchPortEventArgs
/// </summary>
/// <param name="port">The identifier of the port that changed state</param>
/// <param name="eventType">The type of event that occurred on the port</param>
public NetworkSwitchPortEventArgs(string port, NetworkSwitchPortEventType eventType)
{
Port = port;
EventType = eventType;
}
}

/// <summary>
/// Enumeration of network switch port state change event types (e.g. VLAN changes or PoE state changes).
/// </summary>
public enum NetworkSwitchPortEventType
{
/// <summary>
/// Indicates that the type of event is unknown or cannot be determined.
/// </summary>
Unknown,

/// <summary>
/// Indicates that a VLAN change is in progress on the port, either through a call to SetPortVlan or an external change detected by polling.
/// </summary>
VlanChangeInProgress,

/// <summary>
/// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan or an external change detected by polling.
/// </summary>
VlanChanged,

/// <summary>
/// Indicates that PoE is being disabled on the port, either through a call to SetPortPoeState or an external change detected by polling.
/// </summary>
PoeDisableInProgress,

/// <summary>
/// Indicates that PoE has been disabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling.
/// </summary>
PoEDisabled,

/// <summary>
/// Indicates that PoE is being enabled on the port, either through a call to SetPortPoeState or an external change detected by polling.
/// </summary>
PoeEnableInProgress,

/// <summary>
/// Indicates that PoE has been enabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling.
/// </summary>
PoEEnabled
}
}
64 changes: 17 additions & 47 deletions src/PepperDash.Essentials.Core/Routing/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
Expand Down Expand Up @@ -52,13 +51,6 @@ public static class Extensions
/// </summary>
private static Dictionary<string, List<TieLine>> _tieLinesBySource;

/// <summary>
/// Cache of failed route attempts to avoid re-checking impossible paths.
/// Format: "sourceKey|destKey|signalType"
/// Uses ConcurrentDictionary as a thread-safe set (byte value is unused).
/// </summary>
private static readonly ConcurrentDictionary<string, byte> _impossibleRoutes = new ConcurrentDictionary<string, byte>();

/// <summary>
/// Indexes all TieLines by source and destination device keys for faster lookups.
/// Should be called once at system startup after all TieLines are created.
Expand Down Expand Up @@ -121,29 +113,6 @@ private static IEnumerable<TieLine> GetTieLinesForSource(string sourceKey)
return TieLineCollection.Default.Where(t => t.SourcePort.ParentDevice.Key == sourceKey);
}

/// <summary>
/// Creates a cache key for route impossibility tracking.
/// </summary>
/// <param name="sourceKey">Source device key</param>
/// <param name="destKey">Destination device key</param>
/// <param name="sourcePortKey">Source port key</param>
/// <param name="destinationPortKey">Destination port key</param>
/// <param name="type">Signal type</param>
/// <returns>Cache key string</returns>
private static string GetRouteKey(string sourceKey, string destKey, string sourcePortKey, string destinationPortKey, eRoutingSignalType type)
{
return $"{sourceKey}|{destKey}|{sourcePortKey}|{destinationPortKey}|{type}";
}

/// <summary>
/// Clears the impossible routes cache. Should be called if TieLines are added/removed at runtime.
/// </summary>
public static void ClearImpossibleRoutesCache()
{
_impossibleRoutes.Clear();
Debug.LogInformation("Impossible routes cache cleared");
}

/// <summary>
/// Gets any existing RouteDescriptor for a destination, clears it using ReleaseRoute
/// and then attempts a new Route and if sucessful, stores that RouteDescriptor
Expand Down Expand Up @@ -374,8 +343,10 @@ public static void MapDestinationsToSources()
IndexTieLines();
}

var sinks = DeviceManager.AllDevices.OfType<IRoutingInputs>();
var sources = DeviceManager.AllDevices.OfType<IRoutingOutputs>();
var sinks = DeviceManager.AllDevices.OfType<IRoutingInputs>()
.Where(d => !(d is IRoutingInputsOutputs)).ToList();
var sources = DeviceManager.AllDevices.OfType<IRoutingOutputs>()
.Where(d => !(d is IRoutingInputsOutputs)).ToList();
Comment thread
erikdred marked this conversation as resolved.

foreach (var sink in sinks.Where(d => !(d is IRoutingInputsOutputs)))
{
Expand Down Expand Up @@ -404,6 +375,10 @@ public static void MapDestinationsToSources()
continue;
}

Debug.LogVerbose("Route mapped: {source} -> {sink} via {input}/{output}, type {type}",
source.Key, sink.Key,
inputPort.Key, outputPort.Key, audioOrSingleRoute.SignalType);

// Add to the appropriate collection(s) based on signal type
// Note: A single route descriptor with combined flags (e.g., AudioVideo) will be added once per matching signal type
if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.Audio))
Expand Down Expand Up @@ -435,6 +410,10 @@ public static void MapDestinationsToSources()
continue;
}

Debug.LogVerbose("Video route mapped: {source} -> {sink} via {input}/{output}",
source.Key, sink.Key,
inputPort.Key, outputPort.Key);

RouteDescriptors[eRoutingSignalType.Video].AddRouteDescriptor(videoRoute);
}
}
Expand Down Expand Up @@ -588,14 +567,6 @@ private static bool GetRouteToSource(this IRoutingInputs destination, IRoutingOu
{
cycle++;

// Check if this route has already been determined to be impossible
var routeKey = GetRouteKey(source.Key, destination.Key, sourcePort?.Key ?? "auto", destinationPort?.Key ?? "auto", signalType);
if (_impossibleRoutes.ContainsKey(routeKey))
{
Debug.LogVerbose("Route {0} is cached as impossible, skipping", routeKey);
return false;
}

Debug.LogVerbose("GetRouteToSource: {cycle} {sourceKey}:{sourcePortKey}--> {destinationKey}:{destinationPortKey} {type}", null, cycle, source.Key, sourcePort?.Key ?? "auto", destination.Key, destinationPort?.Key ?? "auto", signalType.ToString());

RoutingInputPort goodInputPort = null;
Expand Down Expand Up @@ -648,10 +619,12 @@ private static bool GetRouteToSource(this IRoutingInputs destination, IRoutingOu

// No direct tie? Run back out on the inputs' attached devices...
// Only the ones that are routing devices
var midpointTieLines = destinationTieLines.Where(t => t.SourcePort.ParentDevice is IRoutingInputsOutputs);

Debug.LogVerbose(destination, "Found {tieLineCount} tie lines to walk for {destinationKey}", midpointTieLines.Count(), destination.Key);
var midpointTieLines = destinationTieLines
.Where(t => t.SourcePort.ParentDevice is IRoutingInputsOutputs)
.ToList();

Debug.LogVerbose(destination, "Found {tieLineCount} tie lines to walk for {destinationKey}", midpointTieLines.Count, destination.Key);

//Create a list for tracking already checked devices to avoid loops, if it doesn't already exist from previous iteration
if (alreadyCheckedDevices == null)
alreadyCheckedDevices = new List<IRoutingInputsOutputs>();
Expand Down Expand Up @@ -693,9 +666,6 @@ private static bool GetRouteToSource(this IRoutingInputs destination, IRoutingOu
{
Debug.LogVerbose(destination, "No route found to {0} from destination {1} for type {2}", source.Key, destination.Key, signalType);

// Cache this as an impossible route
_impossibleRoutes.TryAdd(routeKey, 0);

return false;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
using System.Linq;
using System;
using System.Linq;
using Crestron.SimplSharp.WebScripting;
using Newtonsoft.Json;
using PepperDash.Core.Web.RequestHandlers;

namespace PepperDash.Essentials.Core.Web.RequestHandlers
{
/// <summary>
/// Represents a GetFeedbacksForDeviceRequestHandler
/// </summary>
/// <summary>
/// Represents a GetFeedbacksForDeviceRequestHandler
/// </summary>
public class GetFeedbacksForDeviceRequestHandler : WebApiBaseRequestHandler
{
/// <summary>
Expand Down Expand Up @@ -76,7 +77,7 @@ from feedback in device.Feedbacks.OfType<IntFeedback>()
Value = feedback.IntValue
};

var stringFeedback =
var stringFeedback =
from feedback in device.Feedbacks.OfType<StringFeedback>()
where !string.IsNullOrEmpty(feedback.Key)
select new
Expand Down
Loading