Compare commits

...

15 commits

Author SHA1 Message Date
equinoy
707195b8c5 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-14 19:48:32 -05:00
equinoy
b499097448 feat: gate combiner completion on provider reconciliation 2026-07-07 12:18:11 -05:00
equinoy
8e9dbb6f44 feat: add optional room combiner operation timeout setting 2026-07-07 12:18:11 -05:00
equinoy
e623865c2a feat: add optional room combiner operation status lifecycle 2026-07-07 12:18:11 -05:00
Andrew Welker
f85343d8fd fix: string formatting for console responses was incorrect in some cases and causing exceptions 2026-07-07 12:18:11 -05:00
anthropic-code-agent[bot]
fb424ed5ad refactor: marked mobile control subscription items as obsolete
Co-authored-by: ndorin <18535240+ndorin@users.noreply.github.com>
2026-07-07 12:18:11 -05:00
Neil Dorin
9eacc508c2 Clarify summary for EnableMessengerSubscriptions property
Updated the summary comment for EnableMessengerSubscriptions property to clarify its purpose.
2026-07-07 12:18:11 -05:00
copilot-swe-agent[bot]
13a4f8101e Update XML summary for EnableMessengerSubscriptions to reflect obsolete status 2026-07-07 12:18:10 -05:00
Neil Dorin
493cd91daa Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-07 12:18:10 -05:00
anthropic-code-agent[bot]
8a9c8b5535 Mark IMobileControlMessengerWithSubscriptions and EnableMessengerSubscriptions as obsolete
All messengers are now subscription based in v3.x, making these
constructs no longer necessary.

Closes #1435

Agent-Logs-Url: https://github.com/PepperDash/Essentials/sessions/bda64c9c-5343-412b-801f-5e60816bc38d

Co-authored-by: ndorin <18535240+ndorin@users.noreply.github.com>
2026-07-07 12:18:10 -05:00
anthropic-code-agent[bot]
0896327dfe Initial plan 2026-07-07 12:18:10 -05:00
Neil Dorin
ee971c4ce1 fix: add port forward timeout handling in DebugSessionRequestHandler 2026-07-07 12:18:10 -05:00
Neil Dorin
7ff4f29c11 fix: improve CS LAN IP handling and update fallback debug session URL in DebugSessionRequestHandler 2026-07-07 12:18:09 -05:00
Neil Dorin
e4cd3617a1 fix: add csIp handling and update debug session URL in DebugSessionRequestHandler 2026-07-07 12:18:09 -05:00
Neil Dorin
6f2231a8d4 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-07 12:18:09 -05:00
11 changed files with 609 additions and 90 deletions

View file

@ -72,6 +72,20 @@ namespace PepperDash.Core
/// </summary> /// </summary>
public bool IsRunning { get => _httpsServer?.IsListening ?? false; } public bool IsRunning { get => _httpsServer?.IsListening ?? false; }
/// <summary>
/// Gets a value indicating whether there are active WebSocket connections.
/// </summary>
public bool HasActiveConnections
{
get
{
if (_httpsServer == null || !_httpsServer.IsListening) return false;
var service = _httpsServer.WebSocketServices[_path];
if (service == null) return false;
return service.Sessions.Count > 0;
}
}
private readonly ITextFormatter _textFormatter; private readonly ITextFormatter _textFormatter;
@ -217,6 +231,8 @@ namespace PepperDash.Core
{ {
Debug.LogInformation("Starting Websocket Server on port: {0}", port); Debug.LogInformation("Starting Websocket Server on port: {0}", port);
Start(port, CertPath, _certificatePassword); Start(port, CertPath, _certificatePassword);
} }

View file

@ -1,10 +1,12 @@
using System;
using PepperDash.Core; using PepperDash.Core;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{ {
/// <summary> /// <summary>
/// Defines the contract for IMobileControlMessenger /// Obsolete: messengers are subscription based by default; use IMobileControlMessenger instead.
/// </summary> /// </summary>
[Obsolete("This interface is obsolete and will be removed in a future version. All messengers are now subscription based.")]
public interface IMobileControlMessengerWithSubscriptions : IMobileControlMessenger public interface IMobileControlMessengerWithSubscriptions : IMobileControlMessenger
{ {
/// <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

@ -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 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.</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

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using Crestron.SimplSharp; using Crestron.SimplSharp;
using PepperDash.Core; using PepperDash.Core;
using PepperDash.Core.Logging;
using Serilog.Events; using Serilog.Events;
namespace PepperDash.Essentials.Core namespace PepperDash.Essentials.Core
@ -296,19 +297,14 @@ namespace PepperDash.Essentials.Core
{ {
var secretPresent = provider.TestSecret(key); var secretPresent = provider.TestSecret(key);
Debug.LogMessage(LogEventLevel.Verbose, provider, "SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key); provider.LogVerbose("SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key);
if (!secretPresent) if (!secretPresent)
return return
String.Format( $"Unable to update secret for {provider.Key}:{key} - Please use the 'SetSecret' command to modify it";
"Unable to update secret for {0}:{1} - Please use the 'SetSecret' command to modify it");
var response = provider.SetSecret(key, secret) var response = provider.SetSecret(key, secret)
? String.Format( ? $"Secret successfully set for {provider.Key}:{key}"
"Secret successfully set for {0}:{1}", : $"Unable to set secret for {provider.Key}:{key}";
provider.Key, key)
: String.Format(
"Unable to set secret for {0}:{1}",
provider.Key, key);
return response; return response;
} }
@ -316,19 +312,14 @@ namespace PepperDash.Essentials.Core
{ {
var secretPresent = provider.TestSecret(key); var secretPresent = provider.TestSecret(key);
Debug.LogMessage(LogEventLevel.Verbose, provider, "SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key); provider.LogVerbose("SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key);
if (secretPresent) if (secretPresent)
return return
String.Format( $"Unable to set secret for {provider.Key}:{key} - Please use the 'UpdateSecret' command to modify it";
"Unable to set secret for {0}:{1} - Please use the 'UpdateSecret' command to modify it");
var response = provider.SetSecret(key, secret) var response = provider.SetSecret(key, secret)
? String.Format( ? $"Secret successfully set for {provider.Key}:{key}"
"Secret successfully set for {0}:{1}", : $"Unable to set secret for {provider.Key}:{key}";
provider.Key, key)
: String.Format(
"Unable to set secret for {0}:{1}",
provider.Key, key);
return response; return response;
} }
@ -377,15 +368,10 @@ namespace PepperDash.Essentials.Core
var key = args[1]; var key = args[1];
provider.SetSecret(key, ""); provider.SetSecret(key, "");
response = provider.SetSecret(key, "") response = provider.SetSecret(key, "")
? String.Format( ? $"Secret successfully deleted for {provider.Key}:{key}"
"Secret successfully deleted for {0}:{1}", : $"Unable to delete secret for {provider.Key}:{key}";
provider.Key, key)
: String.Format(
"Unable to delete secret for {0}:{1}",
provider.Key, key);
CrestronConsole.ConsoleCommandResponse(response); CrestronConsole.ConsoleCommandResponse(response);
return; return;

View file

@ -17,7 +17,10 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
/// Represents a DebugSessionRequestHandler /// Represents a DebugSessionRequestHandler
/// </summary> /// </summary>
public class DebugSessionRequestHandler : WebApiBaseRequestHandler public class DebugSessionRequestHandler : WebApiBaseRequestHandler
{ {
private CTimer _portForwardTimeoutTimer;
private readonly object _timerLock = new object();
/// <summary> /// <summary>
/// Constructor /// Constructor
/// </summary> /// </summary>
@ -48,6 +51,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0);
var port = 0; var port = 0;
string csIp = null;
if (!Debug.WebsocketSink.IsRunning) if (!Debug.WebsocketSink.IsRunning)
{ {
@ -57,15 +61,18 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
// Start the WS Server // Start the WS Server
Debug.WebsocketSink.StartServerAndSetPort(port); Debug.WebsocketSink.StartServerAndSetPort(port);
Debug.SetWebSocketMinimumDebugLevel(Serilog.Events.LogEventLevel.Verbose); Debug.SetWebSocketMinimumDebugLevel(Serilog.Events.LogEventLevel.Verbose);
}
// Attempt to forward the port to the CS LAN // Attempt to get the CS LAN IP and forward the port
try try
{
var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(
EthernetAdapterType.EthernetCSAdapter);
csIp = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
if (port > 0)
{ {
var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(
EthernetAdapterType.EthernetCSAdapter);
var csIp = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
var result = CrestronEthernetHelper.AddPortForwarding( var result = CrestronEthernetHelper.AddPortForwarding(
(ushort)port, (ushort)port, csIp, (ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP); CrestronEthernetHelper.ePortMapTransport.TCP);
@ -77,26 +84,29 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
else else
{ {
Debug.LogMessage(LogEventLevel.Information, "Port {0} forwarded to CS LAN for debug websocket", port); Debug.LogMessage(LogEventLevel.Information, "Port {0} forwarded to CS LAN for debug websocket", port);
StartPortForwardTimeout(port, csIp);
} }
} }
catch (ArgumentException) }
{ catch (ArgumentException)
Debug.LogMessage(LogEventLevel.Debug, "This processor does not have a CS LAN adapter; skipping port forwarding"); {
} Debug.LogMessage(LogEventLevel.Debug, "This processor does not have a CS LAN adapter; skipping port forwarding");
catch (Exception ex) }
{ catch (Exception ex)
Debug.LogMessage(LogEventLevel.Warning, "Error automatically forwarding debug websocket port to CS LAN: {0}", ex.Message); {
} Debug.LogMessage(LogEventLevel.Warning, "Error automatically forwarding debug websocket port to CS LAN: {0}", ex.Message);
} }
var url = Debug.WebsocketSink.Url; var url = Debug.WebsocketSink.Url;
object data = new var data = new
{ {
url = Debug.WebsocketSink.Url url = Debug.WebsocketSink.Url,
fallbackUrl = csIp != null ? url.Replace(csIp, ip) : null
}; };
Debug.LogMessage(LogEventLevel.Information, "Debug Session URL: {0}", url); Debug.LogMessage(LogEventLevel.Information, "Debug Session URL: {0}", url);
Debug.LogMessage(LogEventLevel.Information, "Fallback Debug Session URL: {0}", data.fallbackUrl);
// Return the port number with the full url of the WS Server // Return the port number with the full url of the WS Server
var res = JsonConvert.SerializeObject(data); var res = JsonConvert.SerializeObject(data);
@ -120,6 +130,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
/// <param name="context"></param> /// <param name="context"></param>
protected override void HandlePost(HttpCwsContext context) protected override void HandlePost(HttpCwsContext context)
{ {
CancelPortForwardTimeout();
var port = Debug.WebsocketSink.Port; var port = Debug.WebsocketSink.Port;
Debug.WebsocketSink.StopServer(); Debug.WebsocketSink.StopServer();
@ -132,17 +144,24 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
var csIp = CrestronEthernetHelper.GetEthernetParameter( var csIp = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId); CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
var result = CrestronEthernetHelper.RemovePortForwarding( if (port <= 0)
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{ {
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding for debug websocket: {0}", result); Debug.LogMessage(LogEventLevel.Debug, "Debug websocket port is not set; skipping port forwarding removal");
} }
else else
{ {
Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed", port); var result = CrestronEthernetHelper.RemovePortForwarding(
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding for debug websocket: {0}", result);
}
else
{
Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed", port);
}
} }
} }
catch (ArgumentException) catch (ArgumentException)
@ -161,5 +180,55 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
Debug.LogMessage(LogEventLevel.Information, "Websocket Debug Session Stopped"); Debug.LogMessage(LogEventLevel.Information, "Websocket Debug Session Stopped");
} }
private void StartPortForwardTimeout(int port, string csIp)
{
lock (_timerLock)
{
_portForwardTimeoutTimer?.Dispose();
_portForwardTimeoutTimer = new CTimer(_ =>
{
if (Debug.WebsocketSink.HasActiveConnections)
{
Debug.LogMessage(LogEventLevel.Debug, "Debug websocket has active connections; keeping port forward");
return;
}
Debug.LogMessage(LogEventLevel.Information, "No debug websocket connection within 30 seconds; removing port forward for port {0}", port);
try
{
var result = CrestronEthernetHelper.RemovePortForwarding(
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", result);
}
else
{
Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed due to timeout", port);
}
}
catch (Exception ex)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", ex.Message);
}
}, 30000);
}
}
/// <summary>
/// Cancels the port forward timeout timer if a session is being explicitly stopped.
/// </summary>
private void CancelPortForwardTimeout()
{
lock (_timerLock)
{
_portForwardTimeoutTimer?.Dispose();
_portForwardTimeoutTimer = null;
}
}
} }
} }

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

@ -264,6 +264,9 @@ namespace PepperDash.Essentials.AppServer.Messengers
message.Name = _device.Name; message.Name = _device.Name;
message.MessageBasePath = MessagePath;
var token = JToken.FromObject(message); var token = JToken.FromObject(message);
PostStatusMessage(token, MessagePath, clientId); PostStatusMessage(token, MessagePath, clientId);

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
@ -40,9 +41,10 @@ namespace PepperDash.Essentials
public bool EnableApiServer { get; set; } = true; public bool EnableApiServer { get; set; } = true;
/// <summary> /// <summary>
/// Enable subscriptions for Messengers /// Enables subscriptions for messengers
/// </summary> /// </summary>
[JsonProperty("enableMessengerSubscriptions")] [JsonProperty("enableMessengerSubscriptions")]
[Obsolete("This property is obsolete and will be removed in a future version. All messengers are now subscription based.")]
public bool EnableMessengerSubscriptions { get; set; } public bool EnableMessengerSubscriptions { get; set; }
} }
@ -288,4 +290,4 @@ namespace PepperDash.Essentials
/// </summary> /// </summary>
NEO NEO
} }
} }