using Crestron.SimplSharp; using PepperDash.Core; using PepperDash.Core.Logging; using Serilog.Events; using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace PepperDash.Essentials.Core { /// /// Represents a device that manages room combinations by controlling partitions and scenarios. /// /// The allows for dynamic configuration of room /// combinations based on partition states and predefined scenarios. It supports both automatic and manual modes /// for managing room combinations. In automatic mode, the device determines the current room combination scenario /// based on partition sensor states. In manual mode, scenarios can be set explicitly by the user. public class EssentialsRoomCombiner : EssentialsDevice, IEssentialsRoomCombiner { private EssentialsRoomCombinerPropertiesConfig _propertiesConfig; private IRoomCombinationScenario _currentScenario; private List _rooms; /// /// Gets a list of rooms represented as key-name pairs. /// public List Rooms { get { return _rooms.Cast().ToList(); } } private bool _isInAutoMode; /// /// Gets or sets a value indicating whether the system is operating in automatic mode. /// /// Changing this property triggers an update event via /// IsInAutoModeFeedback.FireUpdate(). Ensure that any event listeners are properly configured to handle /// this update. public bool IsInAutoMode { get { return _isInAutoMode; } set { if (value == _isInAutoMode) { return; } _isInAutoMode = value; IsInAutoModeFeedback.FireUpdate(); } } /// /// Gets a value indicating whether automatic mode is disabled. /// public bool DisableAutoMode { get { return _propertiesConfig.DisableAutoMode; } } private CTimer _scenarioChangeDebounceTimer; private int _scenarioChangeDebounceTimeSeconds = 10; // default to 10s private Mutex _scenarioChange = new Mutex(); /// /// Initializes a new instance of the class, which manages room combination /// scenarios and partition states. /// /// The class is designed to handle dynamic room /// combination scenarios based on partition states. It supports both automatic and manual modes for managing /// room combinations. By default, the instance starts in automatic mode unless the /// specifies otherwise. After activation, the room combiner initializes partition state providers and sets up /// the initial room configuration. Additionally, it subscribes to the event to ensure proper initialization of dependent devices /// before determining or setting the room combination scenario. /// The unique identifier for the room combiner instance. /// The configuration properties for the room combiner, including default settings and debounce times. public EssentialsRoomCombiner(string key, EssentialsRoomCombinerPropertiesConfig props) : base(key) { _propertiesConfig = props; Partitions = new List(); RoomCombinationScenarios = new List(); if (_propertiesConfig.ScenarioChangeDebounceTimeSeconds > 0) { _scenarioChangeDebounceTimeSeconds = _propertiesConfig.ScenarioChangeDebounceTimeSeconds; } IsInAutoModeFeedback = new BoolFeedback(() => _isInAutoMode); // default to auto mode IsInAutoMode = true; if (_propertiesConfig.defaultToManualMode) { IsInAutoMode = false; } IsInAutoModeFeedback.FireUpdate(); CreateScenarios(); AddPostActivationAction(() => { SetupPartitionStateProviders(); SetRooms(); }); // Subscribe to the AllDevicesInitialized event // We need to wait until all devices are initialized in case // any actions are dependent on 3rd party devices already being // connected and initialized DeviceManager.AllDevicesInitialized += (o, a) => { if (IsInAutoMode) { DetermineRoomCombinationScenario(); } else { SetRoomCombinationScenario(_propertiesConfig.defaultScenarioKey); } }; } private void CreateScenarios() { foreach (var scenarioConfig in _propertiesConfig.Scenarios) { var scenario = new RoomCombinationScenario(scenarioConfig); RoomCombinationScenarios.Add(scenario); } } private void SetRooms() { _rooms = new List(); foreach (var roomKey in _propertiesConfig.RoomKeys) { var room = DeviceManager.GetDeviceForKey(roomKey); if (DeviceManager.GetDeviceForKey(roomKey) is IEssentialsRoom essentialsRoom) { _rooms.Add(essentialsRoom); } } var rooms = DeviceManager.AllDevices.OfType().Cast(); foreach (var room in rooms) { room.Deactivate(); } } private void SetupPartitionStateProviders() { foreach (var pConfig in _propertiesConfig.Partitions) { var sensor = DeviceManager.GetDeviceForKey(pConfig.DeviceKey) as IPartitionStateProvider; var partition = new EssentialsPartitionController(pConfig.Key, pConfig.Name, sensor, _propertiesConfig.defaultToManualMode, pConfig.AdjacentRoomKeys); partition.PartitionPresentFeedback.OutputChange += PartitionPresentFeedback_OutputChange; Partitions.Add(partition); } } private void PartitionPresentFeedback_OutputChange(object sender, FeedbackEventArgs e) { StartDebounceTimer(); } private void StartDebounceTimer() { // default to 500ms for manual mode var time = 500; // if in auto mode, debounce the scenario change if (IsInAutoMode) time = _scenarioChangeDebounceTimeSeconds * 1000; if (_scenarioChangeDebounceTimer == null) { _scenarioChangeDebounceTimer = new CTimer((o) => DetermineRoomCombinationScenario(), time); } else { _scenarioChangeDebounceTimer.Reset(time); } } /// /// Determines the current room combination scenario based on the state of the partition sensors /// private void DetermineRoomCombinationScenario() { if (_scenarioChangeDebounceTimer != null) { _scenarioChangeDebounceTimer.Dispose(); _scenarioChangeDebounceTimer = null; } this.LogInformation("Determining Combination Scenario"); var currentScenario = RoomCombinationScenarios.FirstOrDefault((s) => { this.LogDebug("Checking scenario {scenarioKey}", s.Key); // iterate the partition states foreach (var partitionState in s.PartitionStates) { this.LogDebug("checking PartitionState {partitionStateKey}", partitionState.PartitionKey); // get the partition by key var partition = Partitions.FirstOrDefault((p) => p.Key.Equals(partitionState.PartitionKey)); this.LogDebug("Expected State: {partitionPresent} Actual State: {partitionState}", partitionState.PartitionPresent, partition.PartitionPresentFeedback.BoolValue); if (partition != null && partitionState.PartitionPresent != partition.PartitionPresentFeedback.BoolValue) { // the partition can't be found or the state doesn't match return false; } } // if it hasn't returned false by now we have the matching scenario return true; }); if (currentScenario != null) { this.LogInformation("Found combination Scenario {scenarioKey}", currentScenario.Key); ChangeScenario(currentScenario); } } private async Task ChangeScenario(IRoomCombinationScenario newScenario) { if (newScenario == _currentScenario) { return; } // Deactivate the old scenario first if (_currentScenario != null) { Debug.LogMessage(LogEventLevel.Information, "Deactivating scenario {currentScenario}", this, _currentScenario.Name); await _currentScenario.Deactivate(); } _currentScenario = newScenario; // Activate the new scenario if (_currentScenario != null) { Debug.LogMessage(LogEventLevel.Debug, $"Current Scenario: {_currentScenario.Name}", this); await _currentScenario.Activate(); } RoomCombinationScenarioChanged?.Invoke(this, new EventArgs()); } #region IEssentialsRoomCombiner Members /// /// Occurs when the room combination scenario changes. /// /// This event is triggered whenever the configuration or state of the room combination /// changes. Subscribers can use this event to update their logic or UI based on the new scenario. public event EventHandler RoomCombinationScenarioChanged; /// /// Gets the current room combination scenario. /// public IRoomCombinationScenario CurrentScenario { get { return _currentScenario; } } /// /// Gets the feedback indicating whether the system is currently in auto mode. /// public BoolFeedback IsInAutoModeFeedback { get; private set; } /// /// Enables auto mode for the room combiner and its partitions, allowing automatic room combination scenarios to /// be determined. /// /// Auto mode allows the room combiner to automatically adjust its configuration based on /// the state of its partitions. If auto mode is disabled in the configuration, this method logs a warning and /// does not enable auto mode. public void SetAutoMode() { if(_propertiesConfig.DisableAutoMode) { this.LogWarning("Auto mode is disabled for this room combiner. Cannot set to auto mode."); return; } IsInAutoMode = true; foreach (var partition in Partitions) { partition.SetAutoMode(); } DetermineRoomCombinationScenario(); } /// /// Switches the system to manual mode, disabling automatic operations. /// /// This method sets the system to manual mode by updating the mode state and propagates /// the change to all partitions. Once in manual mode, automatic operations are disabled for the system and its /// partitions. public void SetManualMode() { IsInAutoMode = false; foreach (var partition in Partitions) { partition.SetManualMode(); } } /// /// Toggles the current mode between automatic and manual. /// /// If the current mode is automatic, this method switches to manual mode. If the /// current mode is manual, it switches to automatic mode. public void ToggleMode() { if (IsInAutoMode) { SetManualMode(); } else { SetAutoMode(); } } /// /// Gets the collection of room combination scenarios. /// public List RoomCombinationScenarios { get; private set; } /// /// Gets the collection of partition controllers managed by this instance. /// public List Partitions { get; private set; } /// /// Toggles the state of the partition identified by the specified partition key. /// /// If no partition with the specified key exists, the method performs no /// action. /// The key of the partition whose state is to be toggled. This value cannot be null or empty. public void TogglePartitionState(string partitionKey) { var partition = Partitions.FirstOrDefault((p) => p.Key.Equals(partitionKey)); if (partition != null) { partition.ToggglePartitionState(); } } /// /// Sets the room combination scenario based on the specified scenario key. /// /// This method manually adjusts the partition states according to the specified /// scenario. If the application is in auto mode, the operation will not proceed, and a log message will be /// generated indicating that the mode must be set to manual first. If the specified scenario key does not /// match any existing scenario, a debug log message will be generated. For each partition state in the /// scenario, the corresponding partition will be updated to either "Present" or "Not Present" based on the /// scenario's configuration. If a partition key in the scenario cannot be found, a debug log message will be /// generated. /// The key identifying the room combination scenario to apply. This must match the key of an existing scenario. public void SetRoomCombinationScenario(string scenarioKey) { if (IsInAutoMode) { Debug.LogMessage(LogEventLevel.Information, this, "Cannot set room combination scenario when in auto mode. Set to auto mode first."); return; } // Get the scenario var scenario = RoomCombinationScenarios.FirstOrDefault((s) => s.Key.Equals(scenarioKey)); // Set the parition states from the scenario manually if (scenario != null) { Debug.LogMessage(LogEventLevel.Information, this, "Manually setting scenario to '{0}'", scenario.Key); foreach (var partitionState in scenario.PartitionStates) { var partition = Partitions.FirstOrDefault((p) => p.Key.Equals(partitionState.PartitionKey)); if (partition != null) { if (partitionState.PartitionPresent) { Debug.LogMessage(LogEventLevel.Information, this, "Manually setting state to Present for: '{0}'", partition.Key); partition.SetPartitionStatePresent(); } else { Debug.LogMessage(LogEventLevel.Information, this, "Manually setting state to Not Present for: '{0}'", partition.Key); partition.SetPartitionStateNotPresent(); } } else { Debug.LogMessage(LogEventLevel.Debug, this, "Unable to find partition with key: '{0}'", partitionState.PartitionKey); } } } else { Debug.LogMessage(LogEventLevel.Debug, this, "Unable to find scenario with key: '{0}'", scenarioKey); } } #endregion } /// /// Provides a factory for creating instances of devices. /// /// This factory is responsible for constructing devices /// based on the provided configuration. It supports the type name "essentialsroomcombiner" for device /// creation. public class EssentialsRoomCombinerFactory : EssentialsDeviceFactory { /// /// Initializes a new instance of the class. /// /// This factory is used to create instances of room combiners with the specified type /// names. By default, the factory includes the type name "essentialsroomcombiner". public EssentialsRoomCombinerFactory() { TypeNames = new List { "essentialsroomcombiner" }; } /// /// Creates and initializes a new instance of the device. /// /// This method uses the provided device configuration to extract the properties and /// create an device. Ensure that the configuration contains valid /// properties for the device to be created successfully. /// The device configuration containing the key and properties required to build the device. /// A new instance of initialized with the specified configuration. public override EssentialsDevice BuildDevice(PepperDash.Essentials.Core.Config.DeviceConfig dc) { Debug.LogMessage(LogEventLevel.Debug, "Factory Attempting to create new EssentialsRoomCombiner Device"); var props = dc.Properties.ToObject(); return new EssentialsRoomCombiner(dc.Key, props); } } }