This commit is contained in:
equinoy 2026-07-27 18:48:14 +00:00 committed by GitHub
commit 484dca52b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 516 additions and 43 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).
## Room Combiner Operation Lifecycle
Essentials room combiner now exposes an operation lifecycle that clients can use to show progress and terminal status during scenario changes.
Lifecycle states:
- Idle
- InProgress
- Completed
- Failed
- TimedOut
The lifecycle is exposed on room combiners that implement the operation-status extension and is included in app server combiner messages under the combinationOperation payload.
Configuration:
- combinationOperationTimeoutSeconds (optional): timeout in seconds for an in-progress operation.
- Default when omitted: 300 seconds.
- Values less than or equal to 0 are treated as invalid and fall back to the default.
Example room combiner properties:
```json
{
"defaultToManualMode": false,
"defaultScenarioKey": "divided",
"combinationOperationTimeoutSeconds": 30,
"roomKeys": ["roomA", "roomB"]
}
```
Operational note:
- When an operation reaches a terminal state (Completed, Failed, TimedOut), the operation timeout timer is stopped.
## Support
* Check out our [Discord Server](https://discord.gg/rWyeRH3K)

View file

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

View file

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

View file

@ -5,6 +5,7 @@ using Serilog.Events;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@ -17,7 +18,7 @@ namespace PepperDash.Essentials.Core
/// combinations based on partition states and predefined scenarios. It supports both automatic and manual modes
/// for managing room combinations. In automatic mode, the device determines the current room combination scenario
/// based on partition sensor states. In manual mode, scenarios can be set explicitly by the user.</remarks>
public class EssentialsRoomCombiner : EssentialsDevice, IEssentialsRoomCombiner
public class EssentialsRoomCombiner : EssentialsDevice, IEssentialsRoomCombinerWithOperationStatus
{
private EssentialsRoomCombinerPropertiesConfig _propertiesConfig;
@ -75,10 +76,29 @@ namespace PepperDash.Essentials.Core
private CTimer _scenarioChangeDebounceTimer;
private CTimer _combinationOperationTimeoutTimer;
private int _scenarioChangeDebounceTimeSeconds = 10; // default to 10s
private const int DefaultCombinationOperationTimeoutSeconds = 300;
private int _combinationOperationTimeoutSeconds = DefaultCombinationOperationTimeoutSeconds;
private Mutex _scenarioChange = new Mutex();
private readonly object _combinationOperationLock = new object();
private readonly List<IKeyed> _operationStatusProviderDevices = new List<IKeyed>();
private string _pendingCompletionOperationId;
private string _pendingCompletionScenarioKey;
private CombinationOperationStatus _combinationOperation = new CombinationOperationStatus
{
State = CombinationOperationState.Idle
};
/// <summary>
/// Initializes a new instance of the <see cref="EssentialsRoomCombiner"/> class, which manages room combination
/// scenarios and partition states.
@ -105,6 +125,12 @@ namespace PepperDash.Essentials.Core
_scenarioChangeDebounceTimeSeconds = _propertiesConfig.ScenarioChangeDebounceTimeSeconds;
}
if (_propertiesConfig.CombinationOperationTimeoutSeconds.HasValue
&& _propertiesConfig.CombinationOperationTimeoutSeconds.Value > 0)
{
_combinationOperationTimeoutSeconds = _propertiesConfig.CombinationOperationTimeoutSeconds.Value;
}
IsInAutoModeFeedback = new BoolFeedback(() => _isInAutoMode);
// default to auto mode
@ -133,6 +159,8 @@ namespace PepperDash.Essentials.Core
// connected and initialized
DeviceManager.AllDevicesInitialized += (o, a) =>
{
InitializeOperationStatusProviders();
if (IsInAutoMode)
{
DetermineRoomCombinationScenario();
@ -256,13 +284,19 @@ namespace PepperDash.Essentials.Core
private async Task ChangeScenario(IRoomCombinationScenario newScenario)
{
if (newScenario == _currentScenario)
{
return;
}
if (newScenario == _currentScenario)
{
return;
}
var operationId = SetCombinationOperationStatus(
CombinationOperationState.InProgress,
newScenario != null ? newScenario.Key : null,
null,
true);
try
{
// Deactivate the old scenario first
if (_currentScenario != null)
{
@ -281,7 +315,20 @@ namespace PepperDash.Essentials.Core
RoomCombinationScenarioChanged?.Invoke(this, new EventArgs());
TryCompleteCombinationOperationIfReady(
operationId,
_currentScenario != null ? _currentScenario.Key : null);
}
catch (Exception ex)
{
this.LogException(ex, "Error changing room combination scenario");
TrySetCombinationOperationTerminalStatus(
operationId,
CombinationOperationState.Failed,
newScenario != null ? newScenario.Key : null,
"Combination operation failed");
}
}
#region IEssentialsRoomCombiner Members
@ -293,6 +340,11 @@ namespace PepperDash.Essentials.Core
/// changes. Subscribers can use this event to update their logic or UI based on the new scenario.</remarks>
public event EventHandler<EventArgs> RoomCombinationScenarioChanged;
/// <summary>
/// Occurs when the room combination operation status changes.
/// </summary>
public event EventHandler<EventArgs> CombinationOperationStatusChanged;
/// <summary>
/// Gets the current room combination scenario.
/// </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>
/// Gets or sets the IsInAutoModeFeedback
/// </summary>
@ -455,6 +521,299 @@ namespace PepperDash.Essentials.Core
}
#endregion
private string SetCombinationOperationStatus(
CombinationOperationState state,
string scenarioKey,
string message,
bool resetStartedUtc)
{
string operationId;
lock (_combinationOperationLock)
{
if (resetStartedUtc)
{
_combinationOperation = new CombinationOperationStatus
{
OperationId = Guid.NewGuid().ToString(),
ScenarioKey = scenarioKey,
StartedUtc = DateTime.UtcNow.ToString("o"),
State = state,
Message = message
};
}
else
{
_combinationOperation.ScenarioKey = scenarioKey ?? _combinationOperation.ScenarioKey;
_combinationOperation.State = state;
_combinationOperation.Message = message;
}
if (state == CombinationOperationState.InProgress)
{
_pendingCompletionOperationId = null;
_pendingCompletionScenarioKey = null;
}
operationId = _combinationOperation.OperationId;
}
if (state == CombinationOperationState.InProgress)
{
StartCombinationOperationTimeout(operationId);
}
else if (state == CombinationOperationState.Completed
|| state == CombinationOperationState.Failed
|| state == CombinationOperationState.TimedOut
|| state == CombinationOperationState.Idle)
{
StopCombinationOperationTimeout();
}
CombinationOperationStatusChanged?.Invoke(this, EventArgs.Empty);
return operationId;
}
private void TrySetCombinationOperationTerminalStatus(
string operationId,
CombinationOperationState state,
string scenarioKey,
string message)
{
var statusUpdated = false;
lock (_combinationOperationLock)
{
if (_combinationOperation == null
|| !string.Equals(_combinationOperation.OperationId, operationId, StringComparison.Ordinal)
|| _combinationOperation.State != CombinationOperationState.InProgress)
{
return;
}
_combinationOperation.ScenarioKey = scenarioKey ?? _combinationOperation.ScenarioKey;
_combinationOperation.State = state;
_combinationOperation.Message = message;
_pendingCompletionOperationId = null;
_pendingCompletionScenarioKey = null;
statusUpdated = true;
}
if (!statusUpdated)
{
return;
}
StopCombinationOperationTimeout();
CombinationOperationStatusChanged?.Invoke(this, EventArgs.Empty);
}
private void StartCombinationOperationTimeout(string operationId)
{
StopCombinationOperationTimeout();
if (_combinationOperationTimeoutSeconds <= 0 || string.IsNullOrEmpty(operationId))
{
return;
}
_combinationOperationTimeoutTimer = new CTimer(
_ => HandleCombinationOperationTimeout(operationId),
_combinationOperationTimeoutSeconds * 1000);
}
private void StopCombinationOperationTimeout()
{
if (_combinationOperationTimeoutTimer == null)
{
return;
}
_combinationOperationTimeoutTimer.Dispose();
_combinationOperationTimeoutTimer = null;
}
private void HandleCombinationOperationTimeout(string operationId)
{
TrySetCombinationOperationTerminalStatus(
operationId,
CombinationOperationState.TimedOut,
null,
"Combination operation timed out");
}
private void InitializeOperationStatusProviders()
{
_operationStatusProviderDevices.Clear();
foreach (var device in DeviceManager.AllDevices)
{
if (IsOperationStatusProviderDevice(device))
{
_operationStatusProviderDevices.Add(device);
SubscribeToOperationStatusProviderChanged(device);
}
}
this.LogDebug("Room combiner {combinerKey} found {providerCount} post-combination status provider(s)", Key, _operationStatusProviderDevices.Count);
}
private static bool IsOperationStatusProviderDevice(object device)
{
if (device == null)
{
return false;
}
var type = device.GetType();
var roomCombinerKeyProperty = type.GetProperty("RoomCombinerKey", BindingFlags.Instance | BindingFlags.Public);
var scenarioReconciledProperty = type.GetProperty("ScenarioReconciled", BindingFlags.Instance | BindingFlags.Public);
var scenarioReconciledScenarioKeyProperty = type.GetProperty("ScenarioReconciledScenarioKey", BindingFlags.Instance | BindingFlags.Public);
return roomCombinerKeyProperty != null
&& roomCombinerKeyProperty.PropertyType == typeof(string)
&& roomCombinerKeyProperty.CanRead
&& scenarioReconciledProperty != null
&& scenarioReconciledProperty.PropertyType == typeof(bool)
&& scenarioReconciledProperty.CanRead
&& scenarioReconciledScenarioKeyProperty != null
&& scenarioReconciledScenarioKeyProperty.PropertyType == typeof(string)
&& scenarioReconciledScenarioKeyProperty.CanRead;
}
private void SubscribeToOperationStatusProviderChanged(object device)
{
var eventInfo = device.GetType().GetEvent("ScenarioReconciledChanged", BindingFlags.Instance | BindingFlags.Public);
if (eventInfo == null)
{
return;
}
if (eventInfo.EventHandlerType != typeof(EventHandler<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>

View file

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

View file

@ -16,12 +16,14 @@ namespace PepperDash.Essentials.Core
/// </summary>
event EventHandler<EventArgs> RoomCombinationScenarioChanged;
/// <summary>
/// The current room combination scenario
/// </summary>
[JsonProperty("currentScenario")]
IRoomCombinationScenario CurrentScenario { get; }
/// <summary>
/// When true, indicates the current mode is auto mode
/// </summary>
@ -85,6 +87,56 @@ namespace PepperDash.Essentials.Core
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>
/// Represents a scenario for combining rooms, including activation, deactivation, and associated state.
/// </summary>

View file

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

View file

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

View file

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