Compare commits

..

11 commits

Author SHA1 Message Date
equinoy
690db6beb8 fix: potential fix for pull request finding 2026-07-27 13:47:44 -05:00
equinoy
3e9f32c42e Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-27 13:47:44 -05:00
equinoy
2bc3e32301 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-27 13:47:44 -05:00
equinoy
528373aa61 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-27 13:47:44 -05:00
equinoy
eaaf5ecce5 docs(readme): document room combiner operation lifecycle and combinationOperationTimeoutSeconds default behavior 2026-07-27 13:47:44 -05:00
equinoy
5a30461eaf fix: guard PartitionPresent getter against null sensor feedback
EssentialsPartitionController.PartitionPresent threw a NullReferenceException in Auto mode when the partition sensor's PartitionPresentFeedback was not yet initialized, breaking combiner fullStatus serialization to MC clients. Fall back to the last-known _partitionPresent value instead of dereferencing a null sensor feedback.
2026-07-27 13:47:44 -05:00
equinoy
0ec6de3d66 feat: gate combiner completion on provider reconciliation 2026-07-27 13:47:43 -05:00
equinoy
35b05f2631 feat: add optional room combiner operation timeout setting 2026-07-27 13:47:43 -05:00
equinoy
4e8514c6cc feat: add optional room combiner operation status lifecycle 2026-07-27 13:47:43 -05:00
anthropic-code-agent[bot]
23ffc83dea refactor: marked mobile control subscription items as obsolete
Co-authored-by: ndorin <18535240+ndorin@users.noreply.github.com>
2026-07-27 13:47:43 -05:00
anthropic-code-agent[bot]
f7a44df69f Initial plan 2026-07-27 13:47:43 -05:00
19 changed files with 564 additions and 146 deletions

View file

@ -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). 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 ## Support
* Check out our [Discord Server](https://discord.gg/rWyeRH3K) * Check out our [Discord Server](https://discord.gg/rWyeRH3K)

View file

@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>2.42.1-local</Version> <Version>2.36.6-local</Version>
<InformationalVersion>$(Version)</InformationalVersion> <InformationalVersion>$(Version)</InformationalVersion>
<Authors>PepperDash Technology</Authors> <Authors>PepperDash Technology</Authors>
<Company>PepperDash Technology</Company> <Company>PepperDash Technology</Company>

View file

@ -151,8 +151,6 @@ namespace PepperDash.Core
// Thread-safety lock for state changes // Thread-safety lock for state changes
private readonly object _stateLock = new object(); private readonly object _stateLock = new object();
private volatile bool _isProgramStopping;
private bool disconnectLogged = false; private bool disconnectLogged = false;
/// <summary> /// <summary>
@ -209,9 +207,11 @@ namespace PepperDash.Core
{ {
if (programEventType == eProgramStatusEventType.Stopping) if (programEventType == eProgramStatusEventType.Stopping)
{ {
_isProgramStopping = true; if (client != null)
this.LogDebug("Program stopping. Closing connection"); {
Disconnect(); this.LogDebug("Program stopping. Closing connection");
Disconnect();
}
} }
} }
@ -228,12 +228,6 @@ namespace PepperDash.Core
return; return;
} }
if (_isProgramStopping)
{
this.LogDebug("Skipping connect because program is stopping");
return;
}
ConnectEnabled = true; ConnectEnabled = true;
try try
@ -293,7 +287,13 @@ namespace PepperDash.Core
} }
catch (SshConnectionException e) 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) if (ie is System.Net.Sockets.SocketException socketException)
{ {
@ -301,20 +301,20 @@ namespace PepperDash.Core
Hostname, Port); Hostname, Port);
this.LogVerbose(socketException, "SocketException details: "); this.LogVerbose(socketException, "SocketException details: ");
} }
else if (ie is SshAuthenticationException) if (ie is SshAuthenticationException)
{ {
this.LogError("Authentication failure for username {userName}", Username); this.LogError("Authentication failure for username {userName}", Username);
this.LogVerbose(ie, "AuthenticationException details: "); this.LogVerbose(ie, "AuthenticationException details: ");
} }
else else
{ {
this.LogError("Error on connect: {error}", ie?.Message ?? e.Message); this.LogError("Error on connect: {error}", ie.Message);
this.LogVerbose(ie ?? e, "Exception details: "); this.LogVerbose(ie, "Exception details: ");
} }
disconnectLogged = true; disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect && ConnectEnabled && !_isProgramStopping) if (AutoReconnect)
{ {
this.LogDebug("Checking autoreconnect: {autoReconnect}, {autoReconnectInterval}ms", AutoReconnect, AutoReconnectIntervalMs); this.LogDebug("Checking autoreconnect: {autoReconnect}, {autoReconnectInterval}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer(); StartReconnectTimer();
@ -326,7 +326,7 @@ namespace PepperDash.Core
disconnectLogged = true; disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect && ConnectEnabled && !_isProgramStopping) if (AutoReconnect)
{ {
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer(); StartReconnectTimer();
@ -338,7 +338,7 @@ namespace PepperDash.Core
this.LogVerbose(e, "Exception details: "); this.LogVerbose(e, "Exception details: ");
disconnectLogged = true; disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect && ConnectEnabled && !_isProgramStopping) if (AutoReconnect)
{ {
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer(); StartReconnectTimer();
@ -473,7 +473,7 @@ namespace PepperDash.Core
{ {
connectLock.Release(); connectLock.Release();
} }
if (AutoReconnect && ConnectEnabled && !_isProgramStopping) if (AutoReconnect && ConnectEnabled)
{ {
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer(); StartReconnectTimer();
@ -516,10 +516,7 @@ namespace PepperDash.Core
this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim()); this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim());
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect && ConnectEnabled && !_isProgramStopping) StartReconnectTimer();
{
StartReconnectTimer();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -552,10 +549,7 @@ namespace PepperDash.Core
this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes)); this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes));
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect && ConnectEnabled && !_isProgramStopping) StartReconnectTimer();
{
StartReconnectTimer();
}
} }
catch (Exception ex) catch (Exception ex)
{ {

View file

@ -43,7 +43,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" /> <PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
<PackageReference Include="Crestron.SimplSharp.SDK.Library" Version="2.21.274" /> <PackageReference Include="Crestron.SimplSharp.SDK.Library" Version="2.21.90" />
<PackageReference Include="Serilog" Version="3.1.1" /> <PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Serilog.Expressions" Version="4.0.0" /> <PackageReference Include="Serilog.Expressions" Version="4.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="2.0.0" /> <PackageReference Include="Serilog.Formatting.Compact" Version="2.0.0" />

View file

@ -1,5 +1,5 @@
using System; using System;
using PepperDash.Core;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{ {
/// <summary> /// <summary>

View file

@ -30,7 +30,7 @@ namespace PepperDash.Essentials.Core
{ {
if (IsInAutoMode) if (IsInAutoMode)
{ {
return _partitionSensor.PartitionPresentFeedback.BoolValue; return _partitionSensor?.PartitionPresentFeedback?.BoolValue ?? _partitionPresent;
} }
return _partitionPresent; return _partitionPresent;

View file

@ -25,7 +25,7 @@
<DocumentationFile>bin\$(Configuration)\PepperDash_Essentials_Core.xml</DocumentationFile> <DocumentationFile>bin\$(Configuration)\PepperDash_Essentials_Core.xml</DocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.274" /> <PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.90" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="Crestron\CrestronGenericBaseDevice.cs.orig" /> <None Include="Crestron\CrestronGenericBaseDevice.cs.orig" />

View file

@ -5,6 +5,7 @@ using Serilog.Events;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection;
using System.Threading; using System.Threading;
using System.Threading.Tasks; 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 /// 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 /// 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.</remarks> /// based on partition sensor states. In manual mode, scenarios can be set explicitly by the user.</remarks>
public class EssentialsRoomCombiner : EssentialsDevice, IEssentialsRoomCombiner public class EssentialsRoomCombiner : EssentialsDevice, IEssentialsRoomCombinerWithOperationStatus
{ {
private EssentialsRoomCombinerPropertiesConfig _propertiesConfig; private EssentialsRoomCombinerPropertiesConfig _propertiesConfig;
@ -75,10 +76,29 @@ namespace PepperDash.Essentials.Core
private CTimer _scenarioChangeDebounceTimer; private CTimer _scenarioChangeDebounceTimer;
private CTimer _combinationOperationTimeoutTimer;
private int _scenarioChangeDebounceTimeSeconds = 10; // default to 10s private int _scenarioChangeDebounceTimeSeconds = 10; // default to 10s
private const int DefaultCombinationOperationTimeoutSeconds = 300;
private int _combinationOperationTimeoutSeconds = DefaultCombinationOperationTimeoutSeconds;
private Mutex _scenarioChange = new Mutex(); private Mutex _scenarioChange = new Mutex();
private readonly object _combinationOperationLock = new object();
private readonly List<IKeyed> _operationStatusProviderDevices = new List<IKeyed>();
private string _pendingCompletionOperationId;
private string _pendingCompletionScenarioKey;
private CombinationOperationStatus _combinationOperation = new CombinationOperationStatus
{
State = CombinationOperationState.Idle
};
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="EssentialsRoomCombiner"/> class, which manages room combination /// Initializes a new instance of the <see cref="EssentialsRoomCombiner"/> class, which manages room combination
/// scenarios and partition states. /// scenarios and partition states.
@ -105,6 +125,12 @@ namespace PepperDash.Essentials.Core
_scenarioChangeDebounceTimeSeconds = _propertiesConfig.ScenarioChangeDebounceTimeSeconds; _scenarioChangeDebounceTimeSeconds = _propertiesConfig.ScenarioChangeDebounceTimeSeconds;
} }
if (_propertiesConfig.CombinationOperationTimeoutSeconds.HasValue
&& _propertiesConfig.CombinationOperationTimeoutSeconds.Value > 0)
{
_combinationOperationTimeoutSeconds = _propertiesConfig.CombinationOperationTimeoutSeconds.Value;
}
IsInAutoModeFeedback = new BoolFeedback(() => _isInAutoMode); IsInAutoModeFeedback = new BoolFeedback(() => _isInAutoMode);
// default to auto mode // default to auto mode
@ -133,6 +159,8 @@ namespace PepperDash.Essentials.Core
// connected and initialized // connected and initialized
DeviceManager.AllDevicesInitialized += (o, a) => DeviceManager.AllDevicesInitialized += (o, a) =>
{ {
InitializeOperationStatusProviders();
if (IsInAutoMode) if (IsInAutoMode)
{ {
DetermineRoomCombinationScenario(); DetermineRoomCombinationScenario();
@ -256,13 +284,19 @@ namespace PepperDash.Essentials.Core
private async Task ChangeScenario(IRoomCombinationScenario newScenario) private async Task ChangeScenario(IRoomCombinationScenario newScenario)
{ {
if (newScenario == _currentScenario)
{
return;
}
if (newScenario == _currentScenario) var operationId = SetCombinationOperationStatus(
{ CombinationOperationState.InProgress,
return; newScenario != null ? newScenario.Key : null,
} null,
true);
try
{
// Deactivate the old scenario first // Deactivate the old scenario first
if (_currentScenario != null) if (_currentScenario != null)
{ {
@ -281,7 +315,20 @@ namespace PepperDash.Essentials.Core
RoomCombinationScenarioChanged?.Invoke(this, new EventArgs()); 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 #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.</remarks> /// changes. Subscribers can use this event to update their logic or UI based on the new scenario.</remarks>
public event EventHandler<EventArgs> RoomCombinationScenarioChanged; public event EventHandler<EventArgs> RoomCombinationScenarioChanged;
/// <summary>
/// Occurs when the room combination operation status changes.
/// </summary>
public event EventHandler<EventArgs> CombinationOperationStatusChanged;
/// <summary> /// <summary>
/// Gets the current room combination scenario. /// Gets the current room combination scenario.
/// </summary> /// </summary>
@ -304,6 +356,20 @@ namespace PepperDash.Essentials.Core
} }
} }
/// <summary>
/// Gets the current room combination operation status.
/// </summary>
public CombinationOperationStatus CombinationOperation
{
get
{
lock (_combinationOperationLock)
{
return CloneCombinationOperationStatus(_combinationOperation);
}
}
}
/// <summary> /// <summary>
/// Gets or sets the IsInAutoModeFeedback /// Gets or sets the IsInAutoModeFeedback
/// </summary> /// </summary>
@ -455,6 +521,299 @@ namespace PepperDash.Essentials.Core
} }
#endregion #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<EventArgs>))
{
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<EventArgs>(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
};
}
} }
/// <summary> /// <summary>

View file

@ -11,10 +11,10 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
public class EssentialsRoomCombinerPropertiesConfig public class EssentialsRoomCombinerPropertiesConfig
{ {
/// <summary> /// <summary>
/// Gets or sets a value indicating whether the system operates in automatic mode. /// Gets or sets a value indicating whether the system operates in automatic mode.
/// <remarks>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, /// <remarks>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.</remarks> /// auto mode won't be allowed to be turned on.</remarks>
/// </summary> /// </summary>
[JsonProperty("disableAutoMode")] [JsonProperty("disableAutoMode")]
public bool DisableAutoMode { get; set; } public bool DisableAutoMode { get; set; }
@ -49,11 +49,18 @@ namespace PepperDash.Essentials.Core
[JsonProperty("defaultScenarioKey")] [JsonProperty("defaultScenarioKey")]
public string defaultScenarioKey { get; set; } public string defaultScenarioKey { get; set; }
/// <summary> /// <summary>
/// Gets or sets the debounce time, in seconds, for scenario changes. /// Gets or sets the debounce time, in seconds, for scenario changes.
/// </summary> /// </summary>
[JsonProperty("scenarioChangeDebounceTimeSeconds")] [JsonProperty("scenarioChangeDebounceTimeSeconds")]
public int ScenarioChangeDebounceTimeSeconds { get; set; } public int ScenarioChangeDebounceTimeSeconds { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonProperty("combinationOperationTimeoutSeconds", NullValueHandling = NullValueHandling.Ignore)]
public int? CombinationOperationTimeoutSeconds { get; set; }
} }
/// <summary> /// <summary>
@ -61,14 +68,14 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
public class PartitionConfig : IKeyName public class PartitionConfig : IKeyName
{ {
/// <summary> /// <summary>
/// Gets or sets the unique key associated with the object. /// Gets or sets the unique key associated with the object.
/// </summary> /// </summary>
[JsonProperty("key")] [JsonProperty("key")]
public string Key { get; set; } public string Key { get; set; }
/// <summary> /// <summary>
/// Gets or sets the name associated with the object. /// Gets or sets the name associated with the object.
/// </summary> /// </summary>
[JsonProperty("name")] [JsonProperty("name")]
public string Name { get; set; } public string Name { get; set; }
@ -91,26 +98,26 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
public class RoomCombinationScenarioConfig : IKeyName public class RoomCombinationScenarioConfig : IKeyName
{ {
/// <summary> /// <summary>
/// Gets or sets the key associated with the object. /// Gets or sets the key associated with the object.
/// </summary> /// </summary>
[JsonProperty("key")] [JsonProperty("key")]
public string Key { get; set; } public string Key { get; set; }
/// <summary> /// <summary>
/// Gets or sets the name associated with the object. /// Gets or sets the name associated with the object.
/// </summary> /// </summary>
[JsonProperty("name")] [JsonProperty("name")]
public string Name { get; set; } public string Name { get; set; }
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
[JsonProperty("hideInUi", NullValueHandling = NullValueHandling.Ignore)] [JsonProperty("hideInUi", NullValueHandling = NullValueHandling.Ignore)]
public bool HideInUi { get; set; } public bool HideInUi { get; set; }
/// <summary> /// <summary>
/// Gets or sets the collection of partition states. /// Gets or sets the collection of partition states.
/// </summary> /// </summary>
[JsonProperty("partitionStates")] [JsonProperty("partitionStates")]
public List<PartitionState> PartitionStates { get; set; } public List<PartitionState> PartitionStates { get; set; }
@ -121,14 +128,14 @@ namespace PepperDash.Essentials.Core
[JsonProperty("uiMap")] [JsonProperty("uiMap")]
public Dictionary<string, string> UiMap { get; set; } public Dictionary<string, string> UiMap { get; set; }
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
[JsonProperty("activationActions")] [JsonProperty("activationActions")]
public List<DeviceActionWrapper> ActivationActions { get; set; } public List<DeviceActionWrapper> ActivationActions { get; set; }
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
[JsonProperty("deactivationActions")] [JsonProperty("deactivationActions")]
public List<DeviceActionWrapper> DeactivationActions { get; set; } public List<DeviceActionWrapper> DeactivationActions { get; set; }
@ -139,14 +146,14 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
public class PartitionState public class PartitionState
{ {
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
[JsonProperty("partitionKey")] [JsonProperty("partitionKey")]
public string PartitionKey { get; set; } public string PartitionKey { get; set; }
/// <summary> /// <summary>
/// Gets or sets a value indicating whether a partition is currently present. /// Gets or sets a value indicating whether a partition is currently present.
/// </summary> /// </summary>
[JsonProperty("partitionSensedState")] [JsonProperty("partitionSensedState")]
public bool PartitionPresent { get; set; } public bool PartitionPresent { get; set; }

View file

@ -16,12 +16,14 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
event EventHandler<EventArgs> RoomCombinationScenarioChanged; event EventHandler<EventArgs> RoomCombinationScenarioChanged;
/// <summary> /// <summary>
/// The current room combination scenario /// The current room combination scenario
/// </summary> /// </summary>
[JsonProperty("currentScenario")] [JsonProperty("currentScenario")]
IRoomCombinationScenario CurrentScenario { get; } IRoomCombinationScenario CurrentScenario { get; }
/// <summary> /// <summary>
/// When true, indicates the current mode is auto mode /// When true, indicates the current mode is auto mode
/// </summary> /// </summary>
@ -85,6 +87,56 @@ namespace PepperDash.Essentials.Core
void SetRoomCombinationScenario(string scenarioKey); void SetRoomCombinationScenario(string scenarioKey);
} }
/// <summary>
/// Optional extension for room combiners that provide operation lifecycle status.
/// </summary>
public interface IEssentialsRoomCombinerWithOperationStatus : IEssentialsRoomCombiner
{
/// <summary>
/// Indicates that the room combination operation status has changed.
/// </summary>
event EventHandler<EventArgs> CombinationOperationStatusChanged;
/// <summary>
/// Gets the current room combination operation status.
/// </summary>
[JsonProperty("combinationOperation")]
CombinationOperationStatus CombinationOperation { get; }
}
/// <summary>
/// Defines lifecycle states for a room combination operation.
/// </summary>
public enum CombinationOperationState
{
Idle,
InProgress,
Completed,
Failed,
TimedOut
}
/// <summary>
/// Represents room combination operation status details.
/// </summary>
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; }
}
/// <summary> /// <summary>
/// Represents a scenario for combining rooms, including activation, deactivation, and associated state. /// Represents a scenario for combining rooms, including activation, deactivation, and associated state.
/// </summary> /// </summary>

View file

@ -368,7 +368,6 @@ namespace PepperDash.Essentials.Core
var key = args[1]; var key = args[1];
provider.SetSecret(key, "");
response = provider.SetSecret(key, "") response = provider.SetSecret(key, "")
? $"Secret successfully deleted for {provider.Key}:{key}" ? $"Secret successfully deleted for {provider.Key}:{key}"
: $"Unable to delete secret for {provider.Key}:{key}"; : $"Unable to delete secret for {provider.Key}:{key}";

View file

@ -187,11 +187,12 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
_portForwardTimeoutTimer?.Dispose(); _portForwardTimeoutTimer?.Dispose();
_portForwardTimeoutTimer = new CTimer(_ => _portForwardTimeoutTimer = new CTimer(_ =>
{ {
if (Debug.WebsocketSink.HasActiveConnections) if (Debug.WebsocketSink.HasActiveConnections)
{ {
Debug.LogMessage(LogEventLevel.Debug, "Debug websocket has active connections; keeping port forward"); Debug.LogMessage(LogEventLevel.Debug, "Debug websocket has active connections; keeping port forward");
return; StartPortForwardTimeout(port, csIp);
} return;
}
Debug.LogMessage(LogEventLevel.Information, "No debug websocket connection within 30 seconds; removing port forward for port {0}", port); Debug.LogMessage(LogEventLevel.Information, "No debug websocket connection within 30 seconds; removing port forward for port {0}", port);

View file

@ -29,6 +29,6 @@
<ProjectReference Include="..\PepperDash.Essentials.Core\PepperDash.Essentials.Core.csproj" /> <ProjectReference Include="..\PepperDash.Essentials.Core\PepperDash.Essentials.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.274" /> <PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.90" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -21,6 +21,8 @@ namespace PepperDash.Essentials.AppServer.Messengers
{ {
private readonly IEssentialsRoomCombiner _roomCombiner; private readonly IEssentialsRoomCombiner _roomCombiner;
private readonly IEssentialsRoomCombinerWithOperationStatus _roomCombinerWithOperationStatus;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="IEssentialsRoomCombinerMessenger"/> class, which facilitates /// Initializes a new instance of the <see cref="IEssentialsRoomCombinerMessenger"/> class, which facilitates
/// messaging for an <see cref="IEssentialsRoomCombiner"/> instance. /// messaging for an <see cref="IEssentialsRoomCombiner"/> instance.
@ -35,6 +37,7 @@ namespace PepperDash.Essentials.AppServer.Messengers
: base(key, messagePath, roomCombiner as IKeyName) : base(key, messagePath, roomCombiner as IKeyName)
{ {
_roomCombiner = roomCombiner; _roomCombiner = roomCombiner;
_roomCombinerWithOperationStatus = roomCombiner as IEssentialsRoomCombinerWithOperationStatus;
} }
/// <summary> /// <summary>
@ -98,6 +101,19 @@ namespace PepperDash.Essentials.AppServer.Messengers
SendFullStatus(); SendFullStatus();
}; };
if (_roomCombinerWithOperationStatus != null)
{
_roomCombinerWithOperationStatus.CombinationOperationStatusChanged += (sender, args) =>
{
var message = new
{
combinationOperation = _roomCombinerWithOperationStatus.CombinationOperation
};
PostStatusMessage(JToken.FromObject(message));
};
}
_roomCombiner.IsInAutoModeFeedback.OutputChange += (sender, args) => _roomCombiner.IsInAutoModeFeedback.OutputChange += (sender, args) =>
{ {
var message = new var message = new
@ -138,6 +154,7 @@ namespace PepperDash.Essentials.AppServer.Messengers
DisableAutoMode = _roomCombiner.DisableAutoMode, DisableAutoMode = _roomCombiner.DisableAutoMode,
IsInAutoMode = _roomCombiner.IsInAutoMode, IsInAutoMode = _roomCombiner.IsInAutoMode,
CurrentScenario = _roomCombiner.CurrentScenario, CurrentScenario = _roomCombiner.CurrentScenario,
CombinationOperation = _roomCombinerWithOperationStatus != null ? _roomCombinerWithOperationStatus.CombinationOperation : null,
Rooms = rooms, Rooms = rooms,
RoomCombinationScenarios = _roomCombiner.RoomCombinationScenarios, RoomCombinationScenarios = _roomCombiner.RoomCombinationScenarios,
Partitions = _roomCombiner.Partitions Partitions = _roomCombiner.Partitions
@ -194,6 +211,12 @@ namespace PepperDash.Essentials.AppServer.Messengers
[JsonProperty("currentScenario", NullValueHandling = NullValueHandling.Ignore)] [JsonProperty("currentScenario", NullValueHandling = NullValueHandling.Ignore)]
public IRoomCombinationScenario CurrentScenario { get; set; } public IRoomCombinationScenario CurrentScenario { get; set; }
/// <summary>
/// Gets or sets the room combination operation status.
/// </summary>
[JsonProperty("combinationOperation", NullValueHandling = NullValueHandling.Ignore)]
public CombinationOperationStatus CombinationOperation { get; set; }
/// <summary> /// <summary>
/// Gets or sets the collection of rooms associated with the entity. /// Gets or sets the collection of rooms associated with the entity.
/// </summary> /// </summary>

View file

@ -33,7 +33,7 @@
<Compile Remove="Messengers\SIMPLVtcMessenger.cs" /> <Compile Remove="Messengers\SIMPLVtcMessenger.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.274" /> <PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.90" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PepperDash.Core\PepperDash.Core.csproj" /> <ProjectReference Include="..\PepperDash.Core\PepperDash.Core.csproj" />

View file

@ -38,7 +38,7 @@
<Compile Remove="RoomBridges\SourceDeviceMapDictionary.cs" /> <Compile Remove="RoomBridges\SourceDeviceMapDictionary.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.274" /> <PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.90" />
<PackageReference Include="WebSocketSharp-netstandard" Version="1.0.1" /> <PackageReference Include="WebSocketSharp-netstandard" Version="1.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -25,28 +25,11 @@ namespace PepperDash.Essentials.Touchpanel
/// Mobile Control touchpanel controller that provides app control, Zoom integration, /// Mobile Control touchpanel controller that provides app control, Zoom integration,
/// and mobile control functionality for Crestron touchpanels. /// and mobile control functionality for Crestron touchpanels.
/// </summary> /// </summary>
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 readonly MobileControlTouchpanelProperties localConfig;
private IMobileControlRoomMessenger _bridge; private IMobileControlRoomMessenger _bridge;
/// <summary>
/// Gets the CommunicationMonitor tracking the panel's online/offline state
/// </summary>
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; private string _appUrl;
/// <summary> /// <summary>
@ -145,11 +128,6 @@ namespace PepperDash.Essentials.Touchpanel
{ {
localConfig = config; localConfig = config;
if (panel != null)
{
CommunicationMonitor = new CrestronGenericBaseCommunicationMonitor(this, panel, 120000, 300000);
}
AddPostActivationAction(SubscribeForMobileControlUpdates); AddPostActivationAction(SubscribeForMobileControlUpdates);
ThemeFeedback = new StringFeedback($"{Key}-theme", () => Theme); ThemeFeedback = new StringFeedback($"{Key}-theme", () => Theme);
@ -388,8 +366,6 @@ namespace PepperDash.Essentials.Touchpanel
/// </summary> /// </summary>
public override bool CustomActivate() public override bool CustomActivate()
{ {
CommunicationMonitor?.Start();
var appMessenger = new ITswAppControlMessenger($"appControlMessenger-{Key}", $"/device/{Key}", this); var appMessenger = new ITswAppControlMessenger($"appControlMessenger-{Key}", $"/device/{Key}", this);
var zoomMessenger = new ITswZoomControlMessenger($"zoomControlMessenger-{Key}", $"/device/{Key}", this); var zoomMessenger = new ITswZoomControlMessenger($"zoomControlMessenger-{Key}", $"/device/{Key}", this);
@ -417,17 +393,6 @@ namespace PepperDash.Essentials.Touchpanel
return base.CustomActivate(); return base.CustomActivate();
} }
/// <summary>
/// Stops the CommunicationMonitor on deactivation.
/// </summary>
/// <returns>True if deactivation was successful; otherwise, false.</returns>
public override bool Deactivate()
{
CommunicationMonitor?.Stop();
return base.Deactivate();
}
/// <summary> /// <summary>
/// Handles device extender signal changes for system reserved signals. /// Handles device extender signal changes for system reserved signals.
/// </summary> /// </summary>
@ -554,8 +519,15 @@ namespace PepperDash.Essentials.Touchpanel
return false; return false;
}) ? csIpAddress.ToString() : processorIp; }) ? csIpAddress.ToString() : processorIp;
// replace the host but preserve whatever scheme (http/https) is already present in the URL var match = Regex.Match(url, @"^http://([^:/]+):\d+/mc/app\?token=.+$");
var updatedUrl = Regex.Replace(url, @"^(https?)://[^:/]+", $"$1://{ip}"); 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); this.LogVerbose("Updated URL: {updatedUrl}", updatedUrl);
@ -768,7 +740,7 @@ namespace PepperDash.Essentials.Touchpanel
/// </summary> /// </summary>
public MobileControlTouchpanelControllerFactory() public MobileControlTouchpanelControllerFactory()
{ {
TypeNames = new List<string>() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mctsw1080", "mcts1080", "mcxpanel", "mcdge1000" }; TypeNames = new List<string>() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mcxpanel", "mcdge1000" };
MinimumEssentialsFrameworkVersion = "2.0.0"; MinimumEssentialsFrameworkVersion = "2.0.0";
factories = new Dictionary<string, Func<uint, CrestronControlSystem, string, BasicTriListWithSmartObject>> factories = new Dictionary<string, Func<uint, CrestronControlSystem, string, BasicTriListWithSmartObject>>
@ -793,8 +765,6 @@ namespace PepperDash.Essentials.Touchpanel
{"ts770", (id, controlSystem, projectName) => new Ts770(id, controlSystem)}, {"ts770", (id, controlSystem, projectName) => new Ts770(id, controlSystem)},
{"tsw1070", (id, controlSystem, projectName) => new Tsw1070(id, controlSystem)}, {"tsw1070", (id, controlSystem, projectName) => new Tsw1070(id, controlSystem)},
{"ts1070", (id, controlSystem, projectName) => new Ts1070(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)} {"dge1000", (id, controlSystem, projectName) => new Dge1000(id, controlSystem)}
}; };
} }

View file

@ -127,16 +127,6 @@ namespace PepperDash.Essentials.WebSocketServer
/// </summary> /// </summary>
public int Port { get; private set; } public int Port { get; private set; }
/// <summary>
/// Gets the HTTP scheme to use for generated URLs, based on whether the direct server is configured as secure
/// </summary>
private string HttpScheme => _parent.Config.DirectServer.Secure ? "https" : "http";
/// <summary>
/// Gets the WebSocket scheme to use for generated URLs, based on whether the direct server is configured as secure
/// </summary>
private string WsScheme => _parent.Config.DirectServer.Secure ? "wss" : "ws";
/// <summary> /// <summary>
/// Gets the user app URL prefix /// Gets the user app URL prefix
/// </summary> /// </summary>
@ -144,8 +134,7 @@ namespace PepperDash.Essentials.WebSocketServer
{ {
get get
{ {
return string.Format("{0}://{1}:{2}{3}?token=", return string.Format("http://{0}:{1}{2}?token=",
HttpScheme,
CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0),
Port, Port,
_userAppBaseHref); _userAppBaseHref);
@ -284,7 +273,7 @@ namespace PepperDash.Essentials.WebSocketServer
{ {
base.Initialize(); base.Initialize();
_server = new HttpServer(Port, _parent.Config.DirectServer.Secure); _server = new HttpServer(Port, false);
_server.OnGet += Server_OnGet; _server.OnGet += Server_OnGet;
@ -302,7 +291,7 @@ namespace PepperDash.Essentials.WebSocketServer
{ {
ClientCertificateRequired = false, ClientCertificateRequired = false,
CheckCertificateRevocation = false, CheckCertificateRevocation = false,
EnabledSslProtocols = SslProtocols.Tls12 EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11
}; };
} }
@ -414,11 +403,11 @@ namespace PepperDash.Essentials.WebSocketServer
ip = csIpAddress.ToString(); 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); 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 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 = "", GatewayAppPath = "",
LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png", LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png",
EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false, EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false,
@ -1109,7 +1098,6 @@ namespace PepperDash.Essentials.WebSocketServer
res.StatusCode = 200; res.StatusCode = 200;
res.Close(); 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") 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"), Content = new StringContent(body, Encoding.UTF8, "application/json"),
@ -1162,11 +1150,6 @@ namespace PepperDash.Essentials.WebSocketServer
var qp = req.QueryString; var qp = req.QueryString;
var token = qp["token"]; 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); this.LogVerbose("Join Room Request with token: {token}", token);
byte[] body; byte[] body;
@ -1230,7 +1213,8 @@ namespace PepperDash.Essentials.WebSocketServer
this.LogVerbose("Assigning ClientId: {clientId} for token: {token} at {timestamp}", clientId, token, now); this.LogVerbose("Assigning ClientId: {clientId} for token: {token} at {timestamp}", clientId, token, now);
// Construct WebSocket URL with clientId query parameter // 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 // Construct the response object
JoinResponse jRes = new JoinResponse JoinResponse jRes = new JoinResponse
@ -1242,8 +1226,7 @@ namespace PepperDash.Essentials.WebSocketServer
Config = _parent.GetConfigWithPluginVersion(), Config = _parent.GetConfigWithPluginVersion(),
CodeExpires = new DateTime().AddYears(1), CodeExpires = new DateTime().AddYears(1),
UserCode = bridge.UserCode, UserCode = bridge.UserCode,
UserAppUrl = string.Format("{0}://{1}:{2}/mc/app", UserAppUrl = string.Format("http://{0}:{1}/mc/app",
HttpScheme,
CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0),
Port), Port),
WebSocketUrl = wsUrl, WebSocketUrl = wsUrl,
@ -1268,8 +1251,6 @@ namespace PepperDash.Essentials.WebSocketServer
{ {
res.StatusCode = 200; res.StatusCode = 200;
res.ContentType = "application/json"; 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 version = new Version() { ServerVersion = _parent.GetConfigWithPluginVersion().RuntimeInfo.PluginVersion };
var message = JsonConvert.SerializeObject(version); var message = JsonConvert.SerializeObject(version);
this.LogVerbose("{message}", message); this.LogVerbose("{message}", message);

View file

@ -48,7 +48,7 @@
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.Program" Version="2.21.274" /> <PackageReference Include="Crestron.SimplSharp.SDK.Program" Version="2.21.90" />
<PackageReference Include="System.IO.Compression" Version="4.0.0" /> <PackageReference Include="System.IO.Compression" Version="4.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>