diff --git a/README.md b/README.md
index b485eade..0ad097ca 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,38 @@ Utilization of Essentials Framework falls into the following categories:
For detailed documentation, see the [Wiki](https://github.com/PepperDash/EssentialsFramework/wiki).
+## Room Combiner Operation Lifecycle
+
+Essentials room combiner now exposes an operation lifecycle that clients can use to show progress and terminal status during scenario changes.
+
+Lifecycle states:
+- Idle
+- InProgress
+- Completed
+- Failed
+- TimedOut
+
+The lifecycle is exposed on room combiners that implement the operation-status extension and is included in app server combiner messages under the combinationOperation payload.
+
+Configuration:
+- combinationOperationTimeoutSeconds (optional): timeout in seconds for an in-progress operation.
+- Default when omitted: 300 seconds.
+- Values less than or equal to 0 are treated as invalid and fall back to the default.
+
+Example room combiner properties:
+
+```json
+{
+ "defaultToManualMode": false,
+ "defaultScenarioKey": "divided",
+ "combinationOperationTimeoutSeconds": 30,
+ "roomKeys": ["roomA", "roomB"]
+}
+```
+
+Operational note:
+- When an operation reaches a terminal state (Completed, Failed, TimedOut), the operation timeout timer is stopped.
+
## Support
* Check out our [Discord Server](https://discord.gg/rWyeRH3K)
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 235fee48..ab9f2732 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -1,6 +1,6 @@
- 2.42.1-local
+ 2.36.6-local
$(Version)
PepperDash Technology
PepperDash Technology
diff --git a/src/PepperDash.Core/Comm/GenericSshClient.cs b/src/PepperDash.Core/Comm/GenericSshClient.cs
index 546a2a67..df44ab51 100644
--- a/src/PepperDash.Core/Comm/GenericSshClient.cs
+++ b/src/PepperDash.Core/Comm/GenericSshClient.cs
@@ -151,8 +151,6 @@ namespace PepperDash.Core
// Thread-safety lock for state changes
private readonly object _stateLock = new object();
- private volatile bool _isProgramStopping;
-
private bool disconnectLogged = false;
///
@@ -209,9 +207,11 @@ namespace PepperDash.Core
{
if (programEventType == eProgramStatusEventType.Stopping)
{
- _isProgramStopping = true;
- this.LogDebug("Program stopping. Closing connection");
- Disconnect();
+ if (client != null)
+ {
+ this.LogDebug("Program stopping. Closing connection");
+ Disconnect();
+ }
}
}
@@ -228,12 +228,6 @@ namespace PepperDash.Core
return;
}
- if (_isProgramStopping)
- {
- this.LogDebug("Skipping connect because program is stopping");
- return;
- }
-
ConnectEnabled = true;
try
@@ -293,7 +287,13 @@ namespace PepperDash.Core
}
catch (SshConnectionException e)
{
- var ie = e.InnerException; // The details are inside, when present - remote can close the connection with no inner exception at all
+ var ie = e.InnerException; // The details are inside!!
+
+ if (ie is SocketException)
+ {
+ this.LogError("CONNECTION failure: Cannot reach host");
+ this.LogVerbose(ie, "Exception details: ");
+ }
if (ie is System.Net.Sockets.SocketException socketException)
{
@@ -301,20 +301,20 @@ namespace PepperDash.Core
Hostname, Port);
this.LogVerbose(socketException, "SocketException details: ");
}
- else if (ie is SshAuthenticationException)
+ if (ie is SshAuthenticationException)
{
this.LogError("Authentication failure for username {userName}", Username);
this.LogVerbose(ie, "AuthenticationException details: ");
}
else
{
- this.LogError("Error on connect: {error}", ie?.Message ?? e.Message);
- this.LogVerbose(ie ?? e, "Exception details: ");
+ this.LogError("Error on connect: {error}", ie.Message);
+ this.LogVerbose(ie, "Exception details: ");
}
disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
- if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
+ if (AutoReconnect)
{
this.LogDebug("Checking autoreconnect: {autoReconnect}, {autoReconnectInterval}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer();
@@ -326,7 +326,7 @@ namespace PepperDash.Core
disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
- if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
+ if (AutoReconnect)
{
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer();
@@ -338,7 +338,7 @@ namespace PepperDash.Core
this.LogVerbose(e, "Exception details: ");
disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
- if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
+ if (AutoReconnect)
{
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer();
@@ -473,7 +473,7 @@ namespace PepperDash.Core
{
connectLock.Release();
}
- if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
+ if (AutoReconnect && ConnectEnabled)
{
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer();
@@ -516,10 +516,7 @@ namespace PepperDash.Core
this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim());
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
- if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
- {
- StartReconnectTimer();
- }
+ StartReconnectTimer();
}
catch (Exception ex)
{
@@ -552,10 +549,7 @@ namespace PepperDash.Core
this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes));
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
- if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
- {
- StartReconnectTimer();
- }
+ StartReconnectTimer();
}
catch (Exception ex)
{
diff --git a/src/PepperDash.Core/PepperDash.Core.csproj b/src/PepperDash.Core/PepperDash.Core.csproj
index 747aefb6..daa5c6da 100644
--- a/src/PepperDash.Core/PepperDash.Core.csproj
+++ b/src/PepperDash.Core/PepperDash.Core.csproj
@@ -43,7 +43,7 @@
-
+
diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs
index 6cd3835e..3e683ac5 100644
--- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs
+++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs
@@ -1,5 +1,5 @@
using System;
-
+using PepperDash.Core;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{
///
diff --git a/src/PepperDash.Essentials.Core/PartitionSensor/EssentialsPartitionController.cs b/src/PepperDash.Essentials.Core/PartitionSensor/EssentialsPartitionController.cs
index ee20a22a..3db61ebd 100644
--- a/src/PepperDash.Essentials.Core/PartitionSensor/EssentialsPartitionController.cs
+++ b/src/PepperDash.Essentials.Core/PartitionSensor/EssentialsPartitionController.cs
@@ -30,7 +30,7 @@ namespace PepperDash.Essentials.Core
{
if (IsInAutoMode)
{
- return _partitionSensor.PartitionPresentFeedback.BoolValue;
+ return _partitionSensor?.PartitionPresentFeedback?.BoolValue ?? _partitionPresent;
}
return _partitionPresent;
diff --git a/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj b/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj
index b7f333bd..251ba316 100644
--- a/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj
+++ b/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj
@@ -25,7 +25,7 @@
bin\$(Configuration)\PepperDash_Essentials_Core.xml
-
+
diff --git a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs
index a45adb27..7ca54c7a 100644
--- a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs
+++ b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs
@@ -5,6 +5,7 @@ using Serilog.Events;
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@@ -17,7 +18,7 @@ namespace PepperDash.Essentials.Core
/// combinations based on partition states and predefined scenarios. It supports both automatic and manual modes
/// for managing room combinations. In automatic mode, the device determines the current room combination scenario
/// based on partition sensor states. In manual mode, scenarios can be set explicitly by the user.
- public class EssentialsRoomCombiner : EssentialsDevice, IEssentialsRoomCombiner
+ public class EssentialsRoomCombiner : EssentialsDevice, IEssentialsRoomCombinerWithOperationStatus
{
private EssentialsRoomCombinerPropertiesConfig _propertiesConfig;
@@ -75,10 +76,29 @@ namespace PepperDash.Essentials.Core
private CTimer _scenarioChangeDebounceTimer;
+ private CTimer _combinationOperationTimeoutTimer;
+
private int _scenarioChangeDebounceTimeSeconds = 10; // default to 10s
+ private const int DefaultCombinationOperationTimeoutSeconds = 300;
+
+ private int _combinationOperationTimeoutSeconds = DefaultCombinationOperationTimeoutSeconds;
+
private Mutex _scenarioChange = new Mutex();
+ private readonly object _combinationOperationLock = new object();
+
+ private readonly List _operationStatusProviderDevices = new List();
+
+ private string _pendingCompletionOperationId;
+
+ private string _pendingCompletionScenarioKey;
+
+ private CombinationOperationStatus _combinationOperation = new CombinationOperationStatus
+ {
+ State = CombinationOperationState.Idle
+ };
+
///
/// Initializes a new instance of the class, which manages room combination
/// scenarios and partition states.
@@ -105,6 +125,12 @@ namespace PepperDash.Essentials.Core
_scenarioChangeDebounceTimeSeconds = _propertiesConfig.ScenarioChangeDebounceTimeSeconds;
}
+ if (_propertiesConfig.CombinationOperationTimeoutSeconds.HasValue
+ && _propertiesConfig.CombinationOperationTimeoutSeconds.Value > 0)
+ {
+ _combinationOperationTimeoutSeconds = _propertiesConfig.CombinationOperationTimeoutSeconds.Value;
+ }
+
IsInAutoModeFeedback = new BoolFeedback(() => _isInAutoMode);
// default to auto mode
@@ -133,6 +159,8 @@ namespace PepperDash.Essentials.Core
// connected and initialized
DeviceManager.AllDevicesInitialized += (o, a) =>
{
+ InitializeOperationStatusProviders();
+
if (IsInAutoMode)
{
DetermineRoomCombinationScenario();
@@ -256,13 +284,19 @@ namespace PepperDash.Essentials.Core
private async Task ChangeScenario(IRoomCombinationScenario newScenario)
{
-
+ if (newScenario == _currentScenario)
+ {
+ return;
+ }
- if (newScenario == _currentScenario)
- {
- return;
- }
+ var operationId = SetCombinationOperationStatus(
+ CombinationOperationState.InProgress,
+ newScenario != null ? newScenario.Key : null,
+ null,
+ true);
+ try
+ {
// Deactivate the old scenario first
if (_currentScenario != null)
{
@@ -281,7 +315,20 @@ namespace PepperDash.Essentials.Core
RoomCombinationScenarioChanged?.Invoke(this, new EventArgs());
-
+ TryCompleteCombinationOperationIfReady(
+ operationId,
+ _currentScenario != null ? _currentScenario.Key : null);
+ }
+ catch (Exception ex)
+ {
+ this.LogException(ex, "Error changing room combination scenario");
+
+ TrySetCombinationOperationTerminalStatus(
+ operationId,
+ CombinationOperationState.Failed,
+ newScenario != null ? newScenario.Key : null,
+ "Combination operation failed");
+ }
}
#region IEssentialsRoomCombiner Members
@@ -293,6 +340,11 @@ namespace PepperDash.Essentials.Core
/// changes. Subscribers can use this event to update their logic or UI based on the new scenario.
public event EventHandler RoomCombinationScenarioChanged;
+ ///
+ /// Occurs when the room combination operation status changes.
+ ///
+ public event EventHandler CombinationOperationStatusChanged;
+
///
/// Gets the current room combination scenario.
///
@@ -304,6 +356,20 @@ namespace PepperDash.Essentials.Core
}
}
+ ///
+ /// Gets the current room combination operation status.
+ ///
+ public CombinationOperationStatus CombinationOperation
+ {
+ get
+ {
+ lock (_combinationOperationLock)
+ {
+ return CloneCombinationOperationStatus(_combinationOperation);
+ }
+ }
+ }
+
///
/// Gets or sets the IsInAutoModeFeedback
///
@@ -455,6 +521,299 @@ namespace PepperDash.Essentials.Core
}
#endregion
+
+ private string SetCombinationOperationStatus(
+ CombinationOperationState state,
+ string scenarioKey,
+ string message,
+ bool resetStartedUtc)
+ {
+ string operationId;
+
+ lock (_combinationOperationLock)
+ {
+ if (resetStartedUtc)
+ {
+ _combinationOperation = new CombinationOperationStatus
+ {
+ OperationId = Guid.NewGuid().ToString(),
+ ScenarioKey = scenarioKey,
+ StartedUtc = DateTime.UtcNow.ToString("o"),
+ State = state,
+ Message = message
+ };
+ }
+ else
+ {
+ _combinationOperation.ScenarioKey = scenarioKey ?? _combinationOperation.ScenarioKey;
+ _combinationOperation.State = state;
+ _combinationOperation.Message = message;
+ }
+
+ if (state == CombinationOperationState.InProgress)
+ {
+ _pendingCompletionOperationId = null;
+ _pendingCompletionScenarioKey = null;
+ }
+
+ operationId = _combinationOperation.OperationId;
+ }
+
+ if (state == CombinationOperationState.InProgress)
+ {
+ StartCombinationOperationTimeout(operationId);
+ }
+ else if (state == CombinationOperationState.Completed
+ || state == CombinationOperationState.Failed
+ || state == CombinationOperationState.TimedOut
+ || state == CombinationOperationState.Idle)
+ {
+ StopCombinationOperationTimeout();
+ }
+
+ CombinationOperationStatusChanged?.Invoke(this, EventArgs.Empty);
+
+ return operationId;
+ }
+
+ private void TrySetCombinationOperationTerminalStatus(
+ string operationId,
+ CombinationOperationState state,
+ string scenarioKey,
+ string message)
+ {
+ var statusUpdated = false;
+
+ lock (_combinationOperationLock)
+ {
+ if (_combinationOperation == null
+ || !string.Equals(_combinationOperation.OperationId, operationId, StringComparison.Ordinal)
+ || _combinationOperation.State != CombinationOperationState.InProgress)
+ {
+ return;
+ }
+
+ _combinationOperation.ScenarioKey = scenarioKey ?? _combinationOperation.ScenarioKey;
+ _combinationOperation.State = state;
+ _combinationOperation.Message = message;
+ _pendingCompletionOperationId = null;
+ _pendingCompletionScenarioKey = null;
+ statusUpdated = true;
+ }
+
+ if (!statusUpdated)
+ {
+ return;
+ }
+
+ StopCombinationOperationTimeout();
+ CombinationOperationStatusChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void StartCombinationOperationTimeout(string operationId)
+ {
+ StopCombinationOperationTimeout();
+
+ if (_combinationOperationTimeoutSeconds <= 0 || string.IsNullOrEmpty(operationId))
+ {
+ return;
+ }
+
+ _combinationOperationTimeoutTimer = new CTimer(
+ _ => HandleCombinationOperationTimeout(operationId),
+ _combinationOperationTimeoutSeconds * 1000);
+ }
+
+ private void StopCombinationOperationTimeout()
+ {
+ if (_combinationOperationTimeoutTimer == null)
+ {
+ return;
+ }
+
+ _combinationOperationTimeoutTimer.Dispose();
+ _combinationOperationTimeoutTimer = null;
+ }
+
+ private void HandleCombinationOperationTimeout(string operationId)
+ {
+ TrySetCombinationOperationTerminalStatus(
+ operationId,
+ CombinationOperationState.TimedOut,
+ null,
+ "Combination operation timed out");
+ }
+
+ private void InitializeOperationStatusProviders()
+ {
+ _operationStatusProviderDevices.Clear();
+
+ foreach (var device in DeviceManager.AllDevices)
+ {
+ if (IsOperationStatusProviderDevice(device))
+ {
+ _operationStatusProviderDevices.Add(device);
+ SubscribeToOperationStatusProviderChanged(device);
+ }
+ }
+
+ this.LogDebug("Room combiner {combinerKey} found {providerCount} post-combination status provider(s)", Key, _operationStatusProviderDevices.Count);
+ }
+
+ private static bool IsOperationStatusProviderDevice(object device)
+ {
+ if (device == null)
+ {
+ return false;
+ }
+
+ var type = device.GetType();
+
+ var roomCombinerKeyProperty = type.GetProperty("RoomCombinerKey", BindingFlags.Instance | BindingFlags.Public);
+ var scenarioReconciledProperty = type.GetProperty("ScenarioReconciled", BindingFlags.Instance | BindingFlags.Public);
+ var scenarioReconciledScenarioKeyProperty = type.GetProperty("ScenarioReconciledScenarioKey", BindingFlags.Instance | BindingFlags.Public);
+
+ return roomCombinerKeyProperty != null
+ && roomCombinerKeyProperty.PropertyType == typeof(string)
+ && roomCombinerKeyProperty.CanRead
+ && scenarioReconciledProperty != null
+ && scenarioReconciledProperty.PropertyType == typeof(bool)
+ && scenarioReconciledProperty.CanRead
+ && scenarioReconciledScenarioKeyProperty != null
+ && scenarioReconciledScenarioKeyProperty.PropertyType == typeof(string)
+ && scenarioReconciledScenarioKeyProperty.CanRead;
+ }
+
+ private void SubscribeToOperationStatusProviderChanged(object device)
+ {
+ var eventInfo = device.GetType().GetEvent("ScenarioReconciledChanged", BindingFlags.Instance | BindingFlags.Public);
+ if (eventInfo == null)
+ {
+ return;
+ }
+
+ if (eventInfo.EventHandlerType != typeof(EventHandler))
+ {
+ this.LogDebug("Room combiner {combinerKey} skipping provider event subscription for {providerType}: unsupported event type {eventType}", Key, device.GetType().Name, eventInfo.EventHandlerType);
+ return;
+ }
+
+ eventInfo.AddEventHandler(device, new EventHandler(OperationStatusProvider_ScenarioReconciledChanged));
+ }
+
+ private void OperationStatusProvider_ScenarioReconciledChanged(object sender, EventArgs e)
+ {
+ TryCompletePendingCombinationOperation();
+ }
+
+ private void TryCompletePendingCombinationOperation()
+ {
+ string operationId;
+ string scenarioKey;
+
+ lock (_combinationOperationLock)
+ {
+ operationId = _pendingCompletionOperationId;
+ scenarioKey = _pendingCompletionScenarioKey;
+ }
+
+ if (string.IsNullOrEmpty(operationId))
+ {
+ return;
+ }
+
+ TryCompleteCombinationOperationIfReady(operationId, scenarioKey);
+ }
+
+ private void TryCompleteCombinationOperationIfReady(string operationId, string scenarioKey)
+ {
+ if (string.IsNullOrEmpty(operationId))
+ {
+ return;
+ }
+
+ if (!AreOperationStatusProvidersSatisfied(scenarioKey))
+ {
+ lock (_combinationOperationLock)
+ {
+ if (_combinationOperation != null
+ && string.Equals(_combinationOperation.OperationId, operationId, StringComparison.Ordinal)
+ && _combinationOperation.State == CombinationOperationState.InProgress)
+ {
+ _pendingCompletionOperationId = operationId;
+ _pendingCompletionScenarioKey = scenarioKey;
+ }
+ }
+
+ return;
+ }
+
+ TrySetCombinationOperationTerminalStatus(
+ operationId,
+ CombinationOperationState.Completed,
+ scenarioKey,
+ null);
+ }
+
+ private bool AreOperationStatusProvidersSatisfied(string scenarioKey)
+ {
+ var matchingProviders = _operationStatusProviderDevices
+ .Where(d => string.Equals(GetStringPropertyValue(d, "RoomCombinerKey"), Key, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (!matchingProviders.Any())
+ {
+ return true;
+ }
+
+ foreach (var provider in matchingProviders)
+ {
+ var providerScenarioReconciled = GetBoolPropertyValue(provider, "ScenarioReconciled");
+ var providerScenarioKey = GetStringPropertyValue(provider, "ScenarioReconciledScenarioKey");
+
+ if (!providerScenarioReconciled
+ || !string.Equals(providerScenarioKey, scenarioKey, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static string GetStringPropertyValue(object target, string propertyName)
+ {
+ var propertyInfo = target.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
+ return propertyInfo != null ? propertyInfo.GetValue(target, null) as string : null;
+ }
+
+ private static bool GetBoolPropertyValue(object target, string propertyName)
+ {
+ var propertyInfo = target.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
+ if (propertyInfo == null || propertyInfo.PropertyType != typeof(bool))
+ {
+ return false;
+ }
+
+ return (bool)propertyInfo.GetValue(target, null);
+ }
+
+ private static CombinationOperationStatus CloneCombinationOperationStatus(CombinationOperationStatus status)
+ {
+ if (status == null)
+ {
+ return null;
+ }
+
+ return new CombinationOperationStatus
+ {
+ OperationId = status.OperationId,
+ ScenarioKey = status.ScenarioKey,
+ StartedUtc = status.StartedUtc,
+ State = status.State,
+ Message = status.Message
+ };
+ }
}
///
diff --git a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs
index 868f3992..4a7328a9 100644
--- a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs
+++ b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs
@@ -11,10 +11,10 @@ namespace PepperDash.Essentials.Core
///
public class EssentialsRoomCombinerPropertiesConfig
{
- ///
- /// Gets or sets a value indicating whether the system operates in automatic mode.
- /// Some systems don't have partitions sensors, and show shouldn't allow auto mode to be turned on. When this is true in the configuration,
- /// auto mode won't be allowed to be turned on.
+ ///
+ /// Gets or sets a value indicating whether the system operates in automatic mode.
+ /// Some systems don't have partitions sensors, and shouldn't allow auto mode to be turned on. When this is true in the configuration,
+ /// auto mode won't be allowed to be turned on.
///
[JsonProperty("disableAutoMode")]
public bool DisableAutoMode { get; set; }
@@ -49,11 +49,18 @@ namespace PepperDash.Essentials.Core
[JsonProperty("defaultScenarioKey")]
public string defaultScenarioKey { get; set; }
- ///
- /// Gets or sets the debounce time, in seconds, for scenario changes.
+ ///
+ /// Gets or sets the debounce time, in seconds, for scenario changes.
///
[JsonProperty("scenarioChangeDebounceTimeSeconds")]
public int ScenarioChangeDebounceTimeSeconds { get; set; }
+
+ ///
+ /// Gets or sets the timeout, in seconds, for room combination operations.
+ /// When null or less than or equal to zero, the default timeout is used.
+ ///
+ [JsonProperty("combinationOperationTimeoutSeconds", NullValueHandling = NullValueHandling.Ignore)]
+ public int? CombinationOperationTimeoutSeconds { get; set; }
}
///
@@ -61,14 +68,14 @@ namespace PepperDash.Essentials.Core
///
public class PartitionConfig : IKeyName
{
- ///
- /// Gets or sets the unique key associated with the object.
+ ///
+ /// Gets or sets the unique key associated with the object.
///
[JsonProperty("key")]
public string Key { get; set; }
- ///
- /// Gets or sets the name associated with the object.
+ ///
+ /// Gets or sets the name associated with the object.
///
[JsonProperty("name")]
public string Name { get; set; }
@@ -91,26 +98,26 @@ namespace PepperDash.Essentials.Core
///
public class RoomCombinationScenarioConfig : IKeyName
{
- ///
- /// Gets or sets the key associated with the object.
+ ///
+ /// Gets or sets the key associated with the object.
///
[JsonProperty("key")]
public string Key { get; set; }
- ///
- /// Gets or sets the name associated with the object.
+ ///
+ /// Gets or sets the name associated with the object.
///
[JsonProperty("name")]
public string Name { get; set; }
- ///
- /// Gets or sets a value indicating whether to hide this scenario in the UI.
+ ///
+ /// Gets or sets a value indicating whether to hide this scenario in the UI.
///
[JsonProperty("hideInUi", NullValueHandling = NullValueHandling.Ignore)]
- public bool HideInUi { get; set; }
-
- ///
- /// Gets or sets the collection of partition states.
+ public bool HideInUi { get; set; }
+
+ ///
+ /// Gets or sets the collection of partition states.
///
[JsonProperty("partitionStates")]
public List PartitionStates { get; set; }
@@ -121,14 +128,14 @@ namespace PepperDash.Essentials.Core
[JsonProperty("uiMap")]
public Dictionary UiMap { get; set; }
- ///
- /// Gets or sets the list of actions to be performed during device activation.
+ ///
+ /// Gets or sets the list of actions to be performed during device activation.
///
[JsonProperty("activationActions")]
public List ActivationActions { get; set; }
- ///
- /// Gets or sets the list of actions to be performed when a device is deactivated.
+ ///
+ /// Gets or sets the list of actions to be performed when a device is deactivated.
///
[JsonProperty("deactivationActions")]
public List DeactivationActions { get; set; }
@@ -139,14 +146,14 @@ namespace PepperDash.Essentials.Core
///
public class PartitionState
{
- ///
- /// Gets or sets the partition key used to group and organize data within a storage system.
+ ///
+ /// Gets or sets the partition key used to group and organize data within a storage system.
///
[JsonProperty("partitionKey")]
public string PartitionKey { get; set; }
- ///
- /// Gets or sets a value indicating whether a partition is currently present.
+ ///
+ /// Gets or sets a value indicating whether a partition is currently present.
///
[JsonProperty("partitionSensedState")]
public bool PartitionPresent { get; set; }
diff --git a/src/PepperDash.Essentials.Core/Room/Combining/IEssentialsRoomCombiner.cs b/src/PepperDash.Essentials.Core/Room/Combining/IEssentialsRoomCombiner.cs
index 34d0a0a0..641993ea 100644
--- a/src/PepperDash.Essentials.Core/Room/Combining/IEssentialsRoomCombiner.cs
+++ b/src/PepperDash.Essentials.Core/Room/Combining/IEssentialsRoomCombiner.cs
@@ -16,12 +16,14 @@ namespace PepperDash.Essentials.Core
///
event EventHandler RoomCombinationScenarioChanged;
+
///
/// The current room combination scenario
///
[JsonProperty("currentScenario")]
IRoomCombinationScenario CurrentScenario { get; }
+
///
/// When true, indicates the current mode is auto mode
///
@@ -85,6 +87,56 @@ namespace PepperDash.Essentials.Core
void SetRoomCombinationScenario(string scenarioKey);
}
+ ///
+ /// Optional extension for room combiners that provide operation lifecycle status.
+ ///
+ public interface IEssentialsRoomCombinerWithOperationStatus : IEssentialsRoomCombiner
+ {
+ ///
+ /// Indicates that the room combination operation status has changed.
+ ///
+ event EventHandler CombinationOperationStatusChanged;
+
+ ///
+ /// Gets the current room combination operation status.
+ ///
+ [JsonProperty("combinationOperation")]
+ CombinationOperationStatus CombinationOperation { get; }
+ }
+
+ ///
+ /// Defines lifecycle states for a room combination operation.
+ ///
+ public enum CombinationOperationState
+ {
+ Idle,
+ InProgress,
+ Completed,
+ Failed,
+ TimedOut
+ }
+
+ ///
+ /// Represents room combination operation status details.
+ ///
+ public class CombinationOperationStatus
+ {
+ [JsonProperty("operationId", NullValueHandling = NullValueHandling.Ignore)]
+ public string OperationId { get; set; }
+
+ [JsonProperty("scenarioKey", NullValueHandling = NullValueHandling.Ignore)]
+ public string ScenarioKey { get; set; }
+
+ [JsonProperty("startedUtc", NullValueHandling = NullValueHandling.Ignore)]
+ public string StartedUtc { get; set; }
+
+ [JsonProperty("state")]
+ public CombinationOperationState State { get; set; }
+
+ [JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)]
+ public string Message { get; set; }
+ }
+
///
/// Represents a scenario for combining rooms, including activation, deactivation, and associated state.
///
diff --git a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs
index ac4e80d8..4998a179 100644
--- a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs
+++ b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs
@@ -368,7 +368,6 @@ namespace PepperDash.Essentials.Core
var key = args[1];
- provider.SetSecret(key, "");
response = provider.SetSecret(key, "")
? $"Secret successfully deleted for {provider.Key}:{key}"
: $"Unable to delete secret for {provider.Key}:{key}";
diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs
index 59c662dc..b258632f 100644
--- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs
+++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs
@@ -187,11 +187,12 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
_portForwardTimeoutTimer?.Dispose();
_portForwardTimeoutTimer = new CTimer(_ =>
{
- if (Debug.WebsocketSink.HasActiveConnections)
- {
- Debug.LogMessage(LogEventLevel.Debug, "Debug websocket has active connections; keeping port forward");
- return;
- }
+ if (Debug.WebsocketSink.HasActiveConnections)
+ {
+ Debug.LogMessage(LogEventLevel.Debug, "Debug websocket has active connections; keeping port forward");
+ StartPortForwardTimeout(port, csIp);
+ return;
+ }
Debug.LogMessage(LogEventLevel.Information, "No debug websocket connection within 30 seconds; removing port forward for port {0}", port);
diff --git a/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj b/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj
index 317fa2d3..7be4372d 100644
--- a/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj
+++ b/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj
@@ -29,6 +29,6 @@
-
+
\ No newline at end of file
diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs
index 966d8d77..65a279f2 100644
--- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs
+++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs
@@ -21,6 +21,8 @@ namespace PepperDash.Essentials.AppServer.Messengers
{
private readonly IEssentialsRoomCombiner _roomCombiner;
+ private readonly IEssentialsRoomCombinerWithOperationStatus _roomCombinerWithOperationStatus;
+
///
/// Initializes a new instance of the class, which facilitates
/// messaging for an instance.
@@ -35,6 +37,7 @@ namespace PepperDash.Essentials.AppServer.Messengers
: base(key, messagePath, roomCombiner as IKeyName)
{
_roomCombiner = roomCombiner;
+ _roomCombinerWithOperationStatus = roomCombiner as IEssentialsRoomCombinerWithOperationStatus;
}
///
@@ -98,6 +101,19 @@ namespace PepperDash.Essentials.AppServer.Messengers
SendFullStatus();
};
+ if (_roomCombinerWithOperationStatus != null)
+ {
+ _roomCombinerWithOperationStatus.CombinationOperationStatusChanged += (sender, args) =>
+ {
+ var message = new
+ {
+ combinationOperation = _roomCombinerWithOperationStatus.CombinationOperation
+ };
+
+ PostStatusMessage(JToken.FromObject(message));
+ };
+ }
+
_roomCombiner.IsInAutoModeFeedback.OutputChange += (sender, args) =>
{
var message = new
@@ -138,6 +154,7 @@ namespace PepperDash.Essentials.AppServer.Messengers
DisableAutoMode = _roomCombiner.DisableAutoMode,
IsInAutoMode = _roomCombiner.IsInAutoMode,
CurrentScenario = _roomCombiner.CurrentScenario,
+ CombinationOperation = _roomCombinerWithOperationStatus != null ? _roomCombinerWithOperationStatus.CombinationOperation : null,
Rooms = rooms,
RoomCombinationScenarios = _roomCombiner.RoomCombinationScenarios,
Partitions = _roomCombiner.Partitions
@@ -194,6 +211,12 @@ namespace PepperDash.Essentials.AppServer.Messengers
[JsonProperty("currentScenario", NullValueHandling = NullValueHandling.Ignore)]
public IRoomCombinationScenario CurrentScenario { get; set; }
+ ///
+ /// Gets or sets the room combination operation status.
+ ///
+ [JsonProperty("combinationOperation", NullValueHandling = NullValueHandling.Ignore)]
+ public CombinationOperationStatus CombinationOperation { get; set; }
+
///
/// Gets or sets the collection of rooms associated with the entity.
///
diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj b/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj
index a9223061..d13d1a09 100644
--- a/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj
+++ b/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj
@@ -33,7 +33,7 @@
-
+
diff --git a/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj b/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj
index b0e2dd9f..235e0899 100644
--- a/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj
+++ b/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj
@@ -38,7 +38,7 @@
-
+
diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs
index 262cb34e..5830782d 100644
--- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs
+++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs
@@ -25,28 +25,11 @@ namespace PepperDash.Essentials.Touchpanel
/// Mobile Control touchpanel controller that provides app control, Zoom integration,
/// and mobile control functionality for Crestron touchpanels.
///
- public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme, ICommunicationMonitor
+ public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme
{
private readonly MobileControlTouchpanelProperties localConfig;
private IMobileControlRoomMessenger _bridge;
- ///
- /// Gets the CommunicationMonitor tracking the panel's online/offline state
- ///
- public StatusMonitorBase CommunicationMonitor { get; private set; }
-
- private sealed class NullCommunicationMonitor : StatusMonitorBase
- {
- public NullCommunicationMonitor(IKeyed parent) : base(parent, 120000, 300000)
- {
- Status = MonitorStatus.InError;
- Message = "Panel is not initialized";
- }
-
- public override void Start() { }
- public override void Stop() { }
- }
-
private string _appUrl;
///
@@ -145,11 +128,6 @@ namespace PepperDash.Essentials.Touchpanel
{
localConfig = config;
- if (panel != null)
- {
- CommunicationMonitor = new CrestronGenericBaseCommunicationMonitor(this, panel, 120000, 300000);
- }
-
AddPostActivationAction(SubscribeForMobileControlUpdates);
ThemeFeedback = new StringFeedback($"{Key}-theme", () => Theme);
@@ -388,8 +366,6 @@ namespace PepperDash.Essentials.Touchpanel
///
public override bool CustomActivate()
{
- CommunicationMonitor?.Start();
-
var appMessenger = new ITswAppControlMessenger($"appControlMessenger-{Key}", $"/device/{Key}", this);
var zoomMessenger = new ITswZoomControlMessenger($"zoomControlMessenger-{Key}", $"/device/{Key}", this);
@@ -417,17 +393,6 @@ namespace PepperDash.Essentials.Touchpanel
return base.CustomActivate();
}
- ///
- /// Stops the CommunicationMonitor on deactivation.
- ///
- /// True if deactivation was successful; otherwise, false.
- public override bool Deactivate()
- {
- CommunicationMonitor?.Stop();
-
- return base.Deactivate();
- }
-
///
/// Handles device extender signal changes for system reserved signals.
///
@@ -554,8 +519,15 @@ namespace PepperDash.Essentials.Touchpanel
return false;
}) ? csIpAddress.ToString() : processorIp;
- // replace the host but preserve whatever scheme (http/https) is already present in the URL
- var updatedUrl = Regex.Replace(url, @"^(https?)://[^:/]+", $"$1://{ip}");
+ var match = Regex.Match(url, @"^http://([^:/]+):\d+/mc/app\?token=.+$");
+ if (match.Success)
+ {
+ string ipa = match.Groups[1].Value;
+ // ip will be "192.168.1.100"
+ }
+
+ // replace ipa with ip but leave the rest of the string intact
+ var updatedUrl = Regex.Replace(url, @"^http://[^:/]+", $"http://{ip}");
this.LogVerbose("Updated URL: {updatedUrl}", updatedUrl);
@@ -768,7 +740,7 @@ namespace PepperDash.Essentials.Touchpanel
///
public MobileControlTouchpanelControllerFactory()
{
- TypeNames = new List() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mctsw1080", "mcts1080", "mcxpanel", "mcdge1000" };
+ TypeNames = new List() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mcxpanel", "mcdge1000" };
MinimumEssentialsFrameworkVersion = "2.0.0";
factories = new Dictionary>
@@ -793,8 +765,6 @@ namespace PepperDash.Essentials.Touchpanel
{"ts770", (id, controlSystem, projectName) => new Ts770(id, controlSystem)},
{"tsw1070", (id, controlSystem, projectName) => new Tsw1070(id, controlSystem)},
{"ts1070", (id, controlSystem, projectName) => new Ts1070(id, controlSystem)},
- {"tsw1080", (id, controlSystem, projectName) => new Tsw1080(id, controlSystem)},
- {"ts1080", (id, controlSystem, projectName) => new Ts1080(id, controlSystem)},
{"dge1000", (id, controlSystem, projectName) => new Dge1000(id, controlSystem)}
};
}
diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs
index c96dc116..1c9ed37a 100644
--- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs
+++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs
@@ -127,16 +127,6 @@ namespace PepperDash.Essentials.WebSocketServer
///
public int Port { get; private set; }
- ///
- /// Gets the HTTP scheme to use for generated URLs, based on whether the direct server is configured as secure
- ///
- private string HttpScheme => _parent.Config.DirectServer.Secure ? "https" : "http";
-
- ///
- /// Gets the WebSocket scheme to use for generated URLs, based on whether the direct server is configured as secure
- ///
- private string WsScheme => _parent.Config.DirectServer.Secure ? "wss" : "ws";
-
///
/// Gets the user app URL prefix
///
@@ -144,8 +134,7 @@ namespace PepperDash.Essentials.WebSocketServer
{
get
{
- return string.Format("{0}://{1}:{2}{3}?token=",
- HttpScheme,
+ return string.Format("http://{0}:{1}{2}?token=",
CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0),
Port,
_userAppBaseHref);
@@ -284,7 +273,7 @@ namespace PepperDash.Essentials.WebSocketServer
{
base.Initialize();
- _server = new HttpServer(Port, _parent.Config.DirectServer.Secure);
+ _server = new HttpServer(Port, false);
_server.OnGet += Server_OnGet;
@@ -302,7 +291,7 @@ namespace PepperDash.Essentials.WebSocketServer
{
ClientCertificateRequired = false,
CheckCertificateRevocation = false,
- EnabledSslProtocols = SslProtocols.Tls12
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11
};
}
@@ -414,11 +403,11 @@ namespace PepperDash.Essentials.WebSocketServer
ip = csIpAddress.ToString();
}
- var appUrl = $"{HttpScheme}://{ip}:{Port}/mc/app?token={touchpanel.Key}";
+ var appUrl = $"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}";
this.LogVerbose("Sending URL {appUrl} to touchpanel {touchpanelKey}", appUrl, touchpanel.Touchpanel.Key);
- touchpanel.Touchpanel.SetAppUrl(appUrl);
+ touchpanel.Touchpanel.SetAppUrl($"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}");
}
}
@@ -498,7 +487,7 @@ namespace PepperDash.Essentials.WebSocketServer
{
var config = new MobileControlApplicationConfig
{
- ApiPath = string.Format("{0}://{1}:{2}/mc/api", HttpScheme, processorIp, Port),
+ ApiPath = string.Format("http://{0}:{1}/mc/api", processorIp, _parent.Config.DirectServer.Port),
GatewayAppPath = "",
LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png",
EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false,
@@ -1109,7 +1098,6 @@ namespace PepperDash.Essentials.WebSocketServer
res.StatusCode = 200;
res.Close();
- // remote log collector has no dedicated secure flag; keep it on http regardless of DirectServer.Secure
var logRequest = new HttpRequestMessage(HttpMethod.Post, $"http://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
@@ -1162,11 +1150,6 @@ namespace PepperDash.Essentials.WebSocketServer
var qp = req.QueryString;
var token = qp["token"];
- // Each join mints a single-use clientId; the panel webview must never replay a cached
- // response, or it reconnects forever with an already-consumed id (1008 loop).
- res.Headers.Add("Cache-Control", "no-store");
- res.Headers.Add("Pragma", "no-cache");
-
this.LogVerbose("Join Room Request with token: {token}", token);
byte[] body;
@@ -1230,7 +1213,8 @@ namespace PepperDash.Essentials.WebSocketServer
this.LogVerbose("Assigning ClientId: {clientId} for token: {token} at {timestamp}", clientId, token, now);
// Construct WebSocket URL with clientId query parameter
- var wsUrl = $"{WsScheme}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}";
+ var wsProtocol = "ws";
+ var wsUrl = $"{wsProtocol}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}";
// Construct the response object
JoinResponse jRes = new JoinResponse
@@ -1242,8 +1226,7 @@ namespace PepperDash.Essentials.WebSocketServer
Config = _parent.GetConfigWithPluginVersion(),
CodeExpires = new DateTime().AddYears(1),
UserCode = bridge.UserCode,
- UserAppUrl = string.Format("{0}://{1}:{2}/mc/app",
- HttpScheme,
+ UserAppUrl = string.Format("http://{0}:{1}/mc/app",
CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0),
Port),
WebSocketUrl = wsUrl,
@@ -1268,8 +1251,6 @@ namespace PepperDash.Essentials.WebSocketServer
{
res.StatusCode = 200;
res.ContentType = "application/json";
- res.Headers.Add("Cache-Control", "no-store");
- res.Headers.Add("Pragma", "no-cache");
var version = new Version() { ServerVersion = _parent.GetConfigWithPluginVersion().RuntimeInfo.PluginVersion };
var message = JsonConvert.SerializeObject(version);
this.LogVerbose("{message}", message);
diff --git a/src/PepperDash.Essentials/PepperDash.Essentials.csproj b/src/PepperDash.Essentials/PepperDash.Essentials.csproj
index cb83bf84..20a42ffd 100644
--- a/src/PepperDash.Essentials/PepperDash.Essentials.csproj
+++ b/src/PepperDash.Essentials/PepperDash.Essentials.csproj
@@ -48,7 +48,7 @@
-
+