diff --git a/API/MqttService.cs b/API/MqttService.cs index daa953d..d3e5d70 100644 --- a/API/MqttService.cs +++ b/API/MqttService.cs @@ -1,200 +1,395 @@ using MQTTnet; using MQTTnet.Client; +using MQTTnet.Extensions.ManagedClient; +using MQTTnet.Packets; using MQTTnet.Protocol; +using MQTTnet.Server; +using Newtonsoft.Json; using Serilog; using System; using System.Collections.Generic; +using System.Configuration; using System.Diagnostics; -using System.Threading.Tasks; -using System.Threading; -using System.Timers; -using Newtonsoft.Json; +using System.Security.Authentication; using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using TEAMS2HA.Properties; +using TEAMS2HA.Utils; namespace TEAMS2HA.API { public class MqttService { - #region Private Fields - - private const int MaxConnectionRetries = 2; - private const int RetryDelayMilliseconds = 1000; + private static readonly Lazy _instance = new Lazy(() => new MqttService()); + private IManagedMqttClient _mqttClient; + private MqttClientOptionsBuilder _mqttClientOptionsBuilder; + private AppSettings _settings; private string _deviceId; - private bool _isAttemptingConnection = false; - private MqttClient _mqttClient; - private bool _mqttClientsubscribed = false; - private MqttClientOptions _mqttOptions; private Dictionary _previousSensorStates; private List _sensorNames; - private AppSettings _settings; + private ProcessWatcher processWatcher; + private bool _isInitialized = false; + private dynamic _deviceInfo; + public static MqttService Instance => _instance.Value; private HashSet _subscribedTopics = new HashSet(); - private System.Timers.Timer mqttPublishTimer; - private bool mqttPublishTimerset = false; + public event Func MessageReceived; + public delegate Task CommandToTeamsHandler(string jsonMessage); + public event CommandToTeamsHandler CommandToTeams; - #endregion Private Fields + public void Initialize(AppSettings settings, string deviceId, List sensorNames) + { + if (!_isInitialized) + { + _settings = settings; + //add some null checks incase its first run - #region Public Constructors + if (string.IsNullOrEmpty(deviceId)) + { + //set it to the computer name + deviceId = Environment.MachineName.ToLower(); + + }else + { + deviceId = deviceId.ToLower(); + } + + _sensorNames = sensorNames; + _isInitialized = true; + //_mqttClient.ApplicationMessageReceivedAsync += OnMessageReceivedAsync; - public MqttService(AppSettings settings, string deviceId, List sensorNames) + } + else + { + // Optionally handle re-initialization if needed + } + } + public bool IsConnected => _mqttClient?.IsConnected ?? false; + private MqttService() { - _settings = settings; - _deviceId = deviceId; - _sensorNames = sensorNames; + ProcessWatcher processWatcher = new ProcessWatcher(); _previousSensorStates = new Dictionary(); + var factory = new MqttFactory(); + _mqttClient = factory.CreateManagedMqttClient(); + _deviceId = AppSettings.Instance.SensorPrefix.ToLower(); + _deviceInfo = new + { + ids = new[] { $"teams2ha_{_deviceId}" }, + mf = "Jimmy White", + mdl = "Teams2HA Device", + name = _deviceId, + sw = "v1.0" + }; + _mqttClient.ConnectedAsync += async e => + { + Log.Information("Connected to MQTT broker."); + _mqttClient.ApplicationMessageReceivedAsync += OnMessageReceivedAsync; + Application.Current.Dispatcher.Invoke(() => + { + var mainWindow = Application.Current.MainWindow as MainWindow; + if (mainWindow != null) + { + mainWindow.UpdateMqttStatus(true); + } + }); + - InitializeClient(); - InitializeMqttPublishTimer(); - } - - #endregion Public Constructors - - #region Public Delegates - - public delegate Task CommandToTeamsHandler(string jsonMessage); + await Task.CompletedTask; - #endregion Public Delegates + }; - #region Public Events + _mqttClient.DisconnectedAsync += async e => + { + Log.Information("Disconnected from MQTT broker."); + _mqttClient.ApplicationMessageReceivedAsync -= OnMessageReceivedAsync; + + await Task.CompletedTask; + }; + Log.Information("MQTT client created."); + // _mqttClient.ApplicationMessageReceivedAsync += OnMessageReceivedAsync; - public event CommandToTeamsHandler CommandToTeams; + } + public async Task SubscribeToReactionButtonsAsync() + { + _deviceId = AppSettings.Instance.SensorPrefix.ToLower(); + var reactions = new List { "like", "love", "applause", "wow", "laugh" }; + foreach (var reaction in reactions) + { + string commandTopic = $"homeassistant/button/{_deviceId.ToLower()}/{reaction}/set"; + try + { + await SubscribeAsync(commandTopic, MqttQualityOfServiceLevel.AtLeastOnce); + } + catch (Exception ex) + { + Log.Information($"Error during reaction button MQTT subscribe: {ex.Message}"); + } + } + } + private Task OnMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs e) //triggered when a message is received from MQTT + { + if (e.ApplicationMessage.Payload == null) + { + Log.Information($"Received message on topic {e.ApplicationMessage.Topic}"); + } + else + { + Log.Information($"Received message on topic {e.ApplicationMessage.Topic}: {e.ApplicationMessage.ConvertPayloadToString()}"); + } + Log.Information($"Received message on topic {e.ApplicationMessage.Topic}: {e.ApplicationMessage.ConvertPayloadToString()}"); + if (MessageReceived != null) + { + return MessageReceived(e); + } + string topic = e.ApplicationMessage.Topic; + string payload = Encoding.UTF8.GetString(e.ApplicationMessage.Payload); - public event Action ConnectionAttempting; + if (topic.StartsWith($"homeassistant/button/{_deviceId.ToLower()}/") && payload == "press") + { + var parts = topic.Split('/'); + if (parts.Length > 3) + { + var reaction = parts[3]; // Extract the reaction type from the topic - public event Action ConnectionStatusChanged; + // Construct the JSON message for the reaction + var reactionPayload = new + { + action = "send-reaction", + parameters = new { type = reaction }, + requestId = 1 + }; - public event Func MessageReceived; + string reactionPayloadJson = JsonConvert.SerializeObject(reactionPayload); - public event Action StatusUpdated; + // Invoke the command to send the reaction to Teams + CommandToTeams?.Invoke(reactionPayloadJson); + } + } - #endregion Public Events + // Assuming the format is homeassistant/switch/{deviceId}/{switchName}/set Validate the + // topic format and extract the switchName + var topicParts = topic.Split('/'); //not sure this is required + //topicParts = topic.Split('/'); + if (topicParts.Length == 5 && topicParts[0].Equals("homeassistant") && topicParts[1].Equals("switch") && topicParts[4].EndsWith("set")) + { + // Extract the action and switch name from the topic + string switchName = topicParts[3]; + string command = payload; // command should be ON or OFF based on the payload - #region Public Properties + // Now call the handle method + HandleSwitchCommand(topic, command); + } - public bool IsAttemptingConnection - { - get { return _isAttemptingConnection; } - private set { _isAttemptingConnection = value; } + return Task.CompletedTask; } + private void HandleSwitchCommand(string topic, string command) + { + // Determine which switch is being controlled based on the topic + string switchName = topic.Split('/')[3]; // Assuming topic format is "homeassistant/switch/{switchName}/set" + int underscoreIndex = switchName.IndexOf('_'); + if (underscoreIndex != -1 && underscoreIndex < switchName.Length - 1) + { + switchName = switchName.Substring(underscoreIndex + 1); + } + string jsonMessage = ""; + switch (switchName) + { + case "ismuted": + jsonMessage = $"{{\"apiVersion\":\"1.0.0\",\"service\":\"toggle-mute\",\"action\":\"toggle-mute\",\"manufacturer\":\"Jimmy White\",\"device\":\"THFHA\",\"timestamp\":{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()},\"requestId\":1}}"; + break; - public bool IsConnected => _mqttClient.IsConnected; + case "isvideoon": + jsonMessage = $"{{\"apiVersion\":\"1.0.0\",\"service\":\"toggle-video\",\"action\":\"toggle-video\",\"manufacturer\":\"Jimmy White\",\"device\":\"THFHA\",\"timestamp\":{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()},\"requestId\":1}}"; + break; - #endregion Public Properties + case "isbackgroundblurred": + jsonMessage = $"{{\"apiVersion\":\"1.0.0\",\"service\":\"background-blur\",\"action\":\"toggle-background-blur\",\"manufacturer\":\"Jimmy White\",\"device\":\"THFHA\",\"timestamp\":{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()},\"requestId\":1}}"; + break; - #region Public Methods + case "ishandraised": + jsonMessage = $"{{\"apiVersion\":\"1.0.0\",\"service\":\"raise-hand\",\"action\":\"toggle-hand\",\"manufacturer\":\"Jimmy White\",\"device\":\"THFHA\",\"timestamp\":{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()},\"requestId\":1}}"; + break; - public static List GetEntityNames(string deviceId) - { - var entityNames = new List - { - $"switch.{deviceId}_ismuted", - $"switch.{deviceId}_isvideoon", - $"switch.{deviceId}_ishandraised", - $"binary_sensor.{deviceId}_isrecordingon", - $"binary_sensor.{deviceId}_isinmeeting", - $"binary_sensor.{deviceId}_issharing", - $"binary_sensor.{deviceId}_hasunreadmessages", - $"switch.{deviceId}_isbackgroundblurred", - $"binary_sensor.{deviceId}_teamsRunning" - }; + // Add other cases as needed + } - return entityNames; + if (!string.IsNullOrEmpty(jsonMessage)) + { + // Raise the event + CommandToTeams?.Invoke(jsonMessage); + } } - - public async Task ConnectAsync() + public async Task ConnectAsync(AppSettings settings) { - // Check if MQTT client is already connected or connection attempt is in progress - if (_mqttClient.IsConnected || _isAttemptingConnection) + _settings = settings ?? throw new ArgumentNullException(nameof(settings), "MQTT settings must be provided."); + if (!_isInitialized) { - Log.Information("MQTT client is already connected or connection attempt is in progress."); - return; + throw new InvalidOperationException("MqttService must be initialized before connecting."); + } + if (_mqttClient.IsConnected || _mqttClient.IsStarted) + { + await _mqttClient.StopAsync(); + Log.Information("Existing MQTT client stopped successfully."); } - _isAttemptingConnection = true; - ConnectionAttempting?.Invoke("MQTT Status: Connecting..."); - int retryCount = 0; - - // Retry connecting to MQTT broker up to a maximum number of times - while (retryCount < MaxConnectionRetries && !_mqttClient.IsConnected) + var mqttClientOptionsBuilder = new MqttClientOptionsBuilder() + .WithClientId("TEAMS2HA") + .WithCredentials(settings.MqttUsername, settings.MqttPassword); + if (settings.UseWebsockets && !settings.UseTLS) { - try - { - Log.Information($"Attempting to connect to MQTT (Attempt {retryCount + 1}/{MaxConnectionRetries})"); - await _mqttClient.ConnectAsync(_mqttOptions); - Log.Information("Connected to MQTT broker."); - if (_mqttClient.IsConnected) - { - ConnectionStatusChanged?.Invoke("MQTT Status: Connected"); - break; // Exit the loop if successfully connected - } - } - catch (Exception ex) - { - Log.Debug($"Failed to connect to MQTT broker: {ex.Message}"); - ConnectionStatusChanged?.Invoke($"MQTT Status: Disconnected (Retry {retryCount + 1}) {ex.Message}"); - retryCount++; - await Task.Delay(RetryDelayMilliseconds); // Delay before retrying - } + mqttClientOptionsBuilder.WithWebSocketServer($"ws://{settings.MqttAddress}:{settings.MqttPort}"); + Log.Information($"WebSocket server set to ws://{settings.MqttAddress}:{settings.MqttPort}"); + } + else if (settings.UseWebsockets && settings.UseTLS) + { + mqttClientOptionsBuilder.WithWebSocketServer($"wss://{settings.MqttAddress}:{settings.MqttPort}"); + Log.Information($"WebSocket server set to wss://{settings.MqttAddress}:{settings.MqttPort}"); + } + else + { + mqttClientOptionsBuilder.WithTcpServer(settings.MqttAddress, Convert.ToInt32(settings.MqttPort)); + Log.Information($"TCP server set to {settings.MqttAddress}:{settings.MqttPort}"); } - _isAttemptingConnection = false; - // Notify if failed to connect after all retry attempts - if (!_mqttClient.IsConnected) + if (settings.UseTLS) { - ConnectionStatusChanged?.Invoke("MQTT Status: Disconnected (Failed to connect)"); - Log.Error("Failed to connect to MQTT broker after several attempts."); + mqttClientOptionsBuilder.WithTlsOptions(o => + { + o.WithSslProtocols(SslProtocols.Tls12); + Log.Information("TLS is enabled."); + }); } - } - public async Task DisconnectAsync() - { - if (!_mqttClient.IsConnected) + if (settings.IgnoreCertificateErrors) { - Log.Debug("MQTTClient is not connected"); - ConnectionStatusChanged?.Invoke("MQTTClient is not connected"); - return; + mqttClientOptionsBuilder.WithTlsOptions(o => + { + // The used public broker sometimes has invalid certificates. This sample + // accepts all certificates. This should not be used in live environments. + o.WithCertificateValidationHandler(_ => + { + Log.Warning("Certificate validation is disabled; this is not recommended for production."); + return true; + }); + }); } + var options = new ManagedMqttClientOptionsBuilder() + .WithAutoReconnectDelay(TimeSpan.FromSeconds(5)) + + .WithClientOptions(mqttClientOptionsBuilder.Build()) + .Build(); + try { - await _mqttClient.DisconnectAsync(); - Log.Information("MQTT Disconnected"); - ConnectionStatusChanged?.Invoke("MQTTClient is not connected"); + Log.Information($"Starting MQTT client...{options}"); + await _mqttClient.StartAsync(options); + + + Log.Information($"MQTT client connected with new settings. {_mqttClient.IsStarted}"); + await PublishPermissionSensorsAsync(); + await PublishReactionButtonsAsync(); + //if mqtt is connected, lets subsctribed to incominfg messages + await SetupSubscriptionsAsync(); + Log.Information("Subscribed to incoming messages."); } catch (Exception ex) { - Debug.WriteLine($"Failed to disconnect from MQTT broker: {ex.Message}"); + Log.Error($"Failed to start MQTT client: {ex.Message}"); } } - - public void Dispose() + public async Task PublishReactionButtonsAsync() { - if (_mqttClient != null) + var reactions = new List { "like", "love", "applause", "wow", "laugh" }; + var deviceInfo = new { - _ = _mqttClient.DisconnectAsync(); // Disconnect asynchronously - _mqttClient.Dispose(); - Log.Information("MQTT Client Disposed"); + ids = new[] { $"teams2ha_{_deviceId}" }, + mf = "Jimmy White", + mdl = "Teams2HA Device", + name = _deviceId, + sw = "v1.0" + }; + foreach (var reaction in reactions) + { + string configTopic = $"homeassistant/button/{_deviceId}/{reaction}/config"; + var payload = new + { + name = reaction, + unique_id = $"{_deviceId}_{reaction}_reaction", + icon = GetIconForReaction(reaction), + device = deviceInfo, // Include the device information + command_topic = $"homeassistant/button/{_deviceId}/{reaction}/set", + payload_press = "press" + // Notice there's no state_topic or payload_on/off as it's a button, not a switch + }; + + var message = new MqttApplicationMessageBuilder() + .WithTopic(configTopic) + .WithPayload(JsonConvert.SerializeObject(payload)) + .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) + .WithRetainFlag(true) + .Build(); + if (_mqttClient.IsConnected) + { + await PublishAsync(message); + } + } } + private string GetIconForReaction(string reaction) + { + return reaction switch + { + "like" => "mdi:thumb-up-outline", + "love" => "mdi:heart-outline", + "applause" => "mdi:hand-clap", + "wow" => "mdi:emoticon-excited-outline", + "laugh" => "mdi:emoticon-happy-outline", + _ => "mdi:hand-okay" // Default icon + }; + } - public void InitializeMqttPublishTimer() + public bool IsTeamsRunning() { - mqttPublishTimer = new System.Timers.Timer(60000); // Set the interval to 60 seconds - if (mqttPublishTimerset == false) + return Process.GetProcessesByName("ms-teams").Length > 0; + } + public async Task SetupSubscriptionsAsync() + { + // Subscribe to necessary topics + // await SubscribeAsync($"homeassistant/switch/{_settings.SensorPrefix}/+/set", MqttQualityOfServiceLevel.AtLeastOnce, true); + await SubscribeToReactionButtonsAsync(); + // Add any other necessary subscriptions here + } + public async Task SubscribeAsync(string topic, MqttQualityOfServiceLevel qos) + { + if (_subscribedTopics.Contains(topic)) { - mqttPublishTimer.Elapsed += OnMqttPublishTimerElapsed; - mqttPublishTimer.AutoReset = true; // Reset the timer after it elapses - mqttPublishTimer.Enabled = true; // Enable the timer - mqttPublishTimerset = true; - Log.Debug("InitializeMqttPublishTimer: MQTT Publish Timer Initialized"); + Log.Information($"Already subscribed to {topic}."); + return; } - else + + try { - Log.Debug("InitializeMqttPublishTimer: MQTT Publish Timer already set"); + var topicFilter = new MqttTopicFilterBuilder() + .WithTopic(topic) + .WithQualityOfServiceLevel(qos) + .Build(); + + Log.Debug($"Attempting to subscribe to {topic} with QoS {qos}."); + await _mqttClient.SubscribeAsync(new List { topicFilter }); + _subscribedTopics.Add(topic); // Track the subscription + Log.Information("Subscribed to " + topic); } - //mqttPublishTimer.Elapsed += OnMqttPublishTimerElapsed; - //mqttPublishTimer.AutoReset = true; // Reset the timer after it elapses - //mqttPublishTimer.Enabled = true; // Enable the timer - //Log.Debug("InitializeMqttPublishTimer: MQTT Publish Timer Initialized"); + catch (Exception ex) + { + Log.Error($"Error during MQTT subscribe for {topic}: {ex.Message}"); + } + } + public void Dispose() + { + _mqttClient?.Dispose(); + Log.Information("MQTT Client disposed."); } public async Task UnsubscribeAsync(string topic) { @@ -206,56 +401,89 @@ public async Task UnsubscribeAsync(string topic) try { - // Create the unsubscribe options, similar to how subscription options were created - var unsubscribeOptions = new MqttClientUnsubscribeOptionsBuilder() - .WithTopicFilter(topic) // Add the topic from which to unsubscribe - .Build(); - - // Perform the unsubscribe operation - await _mqttClient.UnsubscribeAsync(unsubscribeOptions); - - // Remove the topic from the local tracking set + await _mqttClient.UnsubscribeAsync(new List { topic }); _subscribedTopics.Remove(topic); - Log.Information($"Successfully unsubscribed from {topic}."); } catch (Exception ex) { Log.Information($"Error during MQTT unsubscribe: {ex.Message}"); - // Depending on your error handling strategy, you might want to handle this differently - // For example, you might want to throw the exception to let the caller know the unsubscribe failed } } - - public async Task PublishAsync(MqttApplicationMessage message) + public async Task PublishPermissionSensorsAsync() { - try - { - await _mqttClient.PublishAsync(message, CancellationToken.None); // Note: Add using System.Threading; if CancellationToken is undefined - Log.Information("Publish successful." + message.Topic); - } - catch (Exception ex) + var permissions = new Dictionary + { + { "canToggleMute", State.Instance.CanToggleMute }, + { "canToggleVideo", State.Instance.CanToggleVideo }, + { "canToggleHand", State.Instance.CanToggleHand }, + { "canToggleBlur", State.Instance.CanToggleBlur }, + { "canLeave", State.Instance.CanLeave }, + { "canReact", State.Instance.CanReact}, + { "canToggleShareTray", State.Instance.CanToggleShareTray }, + { "canToggleChat", State.Instance.CanToggleChat }, + { "canStopSharing", State.Instance.CanStopSharing }, + { "canPair", State.Instance.CanPair} + // Add other permissions here + }; + + foreach (var permission in permissions) { - Log.Information($"Error during MQTT publish: {ex.Message}"); + + bool isAllowed = permission.Value; + _deviceId = _settings.SensorPrefix.ToLower(); + string sensorName = permission.Key.ToLower(); + string configTopic = $"homeassistant/binary_sensor/{_deviceId.ToLower()}/{sensorName}/config"; + var configPayload = new + { + name = sensorName, + unique_id = $"{_deviceId}_{sensorName}", + device = _deviceInfo, + icon = "mdi:eye", // You can customize the icon based on the sensor + state_topic = $"homeassistant/binary_sensor/{_deviceId.ToLower()}/{sensorName}/state", + payload_on = "true", + payload_off = "false" + }; + + var configMessage = new MqttApplicationMessageBuilder() + .WithTopic(configTopic) + .WithPayload(JsonConvert.SerializeObject(configPayload)) + .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) + .WithRetainFlag(true) + .Build(); + await PublishAsync(configMessage); + + // State topic and message + string stateTopic = $"homeassistant/binary_sensor/{_deviceId.ToLower()}/{sensorName}/state"; + string statePayload = isAllowed ? "true" : "false"; // Adjust based on your true/false representation + var stateMessage = new MqttApplicationMessageBuilder() + .WithTopic(stateTopic) + .WithPayload(statePayload) + .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) + .WithRetainFlag(true) + .Build(); + await PublishAsync(stateMessage); } } - public async Task PublishConfigurations(MeetingUpdate meetingUpdate, AppSettings settings, bool forcePublish = false) { + _settings = settings; if (_mqttClient == null) { Log.Debug("MQTT Client Wrapper is not initialized."); return; } + _deviceId = settings.SensorPrefix; // Define common device information for all entities. var deviceInfo = new { - ids = new[] { "teams2ha_" + _deviceId }, // Unique device identifier + ids = new[] { "teams2ha_" + _deviceId.ToLower() }, // Unique device identifier mf = "Jimmy White", // Manufacturer name mdl = "Teams2HA Device", // Model - name = _deviceId, // Device name + name = _deviceId.ToLower(), // Device name sw = "v1.0" // Software version }; + if (meetingUpdate == null) { meetingUpdate = new MeetingUpdate @@ -270,13 +498,14 @@ public async Task PublishConfigurations(MeetingUpdate meetingUpdate, AppSettings IsBackgroundBlurred = false, IsSharing = false, HasUnreadMessages = false, - teamsRunning = false - } + teamsRunning = IsTeamsRunning() + } }; } + foreach (var binary_sensor in _sensorNames) { - string sensorKey = $"{_deviceId}_{binary_sensor}"; + string sensorKey = $"{_deviceId.ToLower()}_{binary_sensor}"; string sensorName = $"{binary_sensor}".ToLower().Replace(" ", "_"); string deviceClass = DetermineDeviceClass(binary_sensor); string icon = DetermineIcon(binary_sensor, meetingUpdate.MeetingState); @@ -284,26 +513,26 @@ public async Task PublishConfigurations(MeetingUpdate meetingUpdate, AppSettings string uniqueId = $"{_deviceId}_{binary_sensor}"; string configTopic; if (forcePublish || !_previousSensorStates.TryGetValue(sensorKey, out var previousState) || previousState != stateValue) - + { Log.Information($"Force Publishing configuration for {sensorName} with state {stateValue}."); _previousSensorStates[sensorKey] = stateValue; // Update the stored state - if(forcePublish) + if (forcePublish) { Log.Information($"Forced publish of {sensorName} state: {stateValue} Due to change in broker"); } if (deviceClass == "switch") { - configTopic = $"homeassistant/switch/{sensorName}/config"; + configTopic = $"homeassistant/switch/{_deviceId.ToLower()}/{sensorName}/config"; var switchConfig = new { name = sensorName, unique_id = uniqueId, device = deviceInfo, icon = icon, - command_topic = $"homeassistant/switch/{sensorName}/set", - state_topic = $"homeassistant/switch/{sensorName}/state", + command_topic = $"homeassistant/switch/{_deviceId.ToLower()}/{sensorName}/set", + state_topic = $"homeassistant/switch/{_deviceId.ToLower()}/{sensorName}/state", payload_on = "ON", payload_off = "OFF" }; @@ -327,14 +556,14 @@ public async Task PublishConfigurations(MeetingUpdate meetingUpdate, AppSettings } else if (deviceClass == "binary_sensor") { - configTopic = $"homeassistant/binary_sensor/{sensorName}/config"; + configTopic = $"homeassistant/binary_sensor/{_deviceId.ToLower()}/{sensorName}/config"; var binarySensorConfig = new { name = sensorName, unique_id = uniqueId, device = deviceInfo, icon = icon, - state_topic = $"homeassistant/binary_sensor/{sensorName}/state", + state_topic = $"homeassistant/binary_sensor/{_deviceId.ToLower()}/{sensorName}/state", payload_on = "true", // Assuming "True" states map to "ON" payload_off = "false" // Assuming "False" states map to "OFF" }; @@ -355,122 +584,12 @@ public async Task PublishConfigurations(MeetingUpdate meetingUpdate, AppSettings .Build(); await PublishAsync(binarySensorStateMessage); + await PublishPermissionSensorsAsync(); + await PublishReactionButtonsAsync(); } } } } - - public async Task ReconnectAsync() - { - // Consolidated reconnection logic - } - - public async Task SetupMqttSensors() - { - // Create a dummy MeetingUpdate with default values - var dummyMeetingUpdate = new MeetingUpdate - { - MeetingState = new MeetingState - { - IsMuted = false, - IsVideoOn = false, - IsHandRaised = false, - IsInMeeting = false, - IsRecordingOn = false, - IsBackgroundBlurred = false, - IsSharing = false, - HasUnreadMessages = false, - teamsRunning = false - } - }; - - // Call PublishConfigurations with the dummy MeetingUpdate - await PublishConfigurations(dummyMeetingUpdate, _settings); - } - - public async Task SubscribeAsync(string topic, MqttQualityOfServiceLevel qos) - { - // Check if already subscribed - if (_subscribedTopics.Contains(topic)) - { - Log.Information($"Already subscribed to {topic}."); - return; - } - - var subscribeOptions = new MqttClientSubscribeOptionsBuilder() - .WithTopicFilter(f => f.WithTopic(topic).WithQualityOfServiceLevel(qos)) - .Build(); - - try - { - await _mqttClient.SubscribeAsync(subscribeOptions); - _subscribedTopics.Add(topic); // Track the subscription - Log.Information("Subscribed to " + topic); - } - catch (Exception ex) - { - Log.Information($"Error during MQTT subscribe: {ex.Message}"); - } - } - - public async Task UpdateClientOptionsAndReconnect() - { - InitializeClientOptions(); // Method to reinitialize client options with updated settings - await DisconnectAsync(); - await ConnectAsync(); - } - - public void UpdateConnectionStatus(string status) //could be obsolete - { - OnConnectionStatusChanged(status); - } - - public async Task UpdateSettingsAsync(AppSettings newSettings) - { - _settings = newSettings; - _deviceId = _settings.SensorPrefix; - InitializeClientOptions(); // Reinitialize MQTT client options - - if (IsConnected) - { - await DisconnectAsync(); - await ConnectAsync(); - } - } - - #endregion Public Methods - - #region Protected Methods - - protected virtual void OnConnectionStatusChanged(string status) //could be obsolete - { - ConnectionStatusChanged?.Invoke(status); - } - - #endregion Protected Methods - - #region Private Methods - - private string DetermineDeviceClass(string sensor) - { - switch (sensor) - { - case "IsMuted": - case "IsVideoOn": - case "IsHandRaised": - case "IsBackgroundBlurred": - return "switch"; // These are ON/OFF switches - case "IsInMeeting": - case "HasUnreadMessages": - case "IsRecordingOn": - case "IsSharing": - case "teamsRunning": - return "binary_sensor"; // These are true/false sensors - default: - return "unknown"; // Or a default device class if appropriate - } - } - private string DetermineIcon(string sensor, MeetingState state) { return sensor switch @@ -549,188 +668,82 @@ private string GetStateValue(string sensor, MeetingUpdate meetingUpdate) return "unknown"; } } - - private void HandleSwitchCommand(string topic, string command) + private string DetermineDeviceClass(string sensor) { - // Determine which switch is being controlled based on the topic - string switchName = topic.Split('/')[2]; // Assuming topic format is "homeassistant/switch/{switchName}/set" - int underscoreIndex = switchName.IndexOf('_'); - if (underscoreIndex != -1 && underscoreIndex < switchName.Length - 1) - { - switchName = switchName.Substring(underscoreIndex + 1); - } - string jsonMessage = ""; - switch (switchName) - { - case "ismuted": - jsonMessage = $"{{\"apiVersion\":\"1.0.0\",\"service\":\"toggle-mute\",\"action\":\"toggle-mute\",\"manufacturer\":\"Jimmy White\",\"device\":\"THFHA\",\"timestamp\":{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()},\"requestId\":1}}"; - break; - - case "isvideoon": - jsonMessage = $"{{\"apiVersion\":\"1.0.0\",\"service\":\"toggle-video\",\"action\":\"toggle-video\",\"manufacturer\":\"Jimmy White\",\"device\":\"THFHA\",\"timestamp\":{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()},\"requestId\":1}}"; - break; - - case "isbackgroundblurred": - jsonMessage = $"{{\"apiVersion\":\"1.0.0\",\"service\":\"background-blur\",\"action\":\"toggle-background-blur\",\"manufacturer\":\"Jimmy White\",\"device\":\"THFHA\",\"timestamp\":{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()},\"requestId\":1}}"; - break; - - case "ishandraised": - jsonMessage = $"{{\"apiVersion\":\"1.0.0\",\"service\":\"raise-hand\",\"action\":\"toggle-hand\",\"manufacturer\":\"Jimmy White\",\"device\":\"THFHA\",\"timestamp\":{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()},\"requestId\":1}}"; - break; - - // Add other cases as needed - } - - if (!string.IsNullOrEmpty(jsonMessage)) + switch (sensor) { - // Raise the event - CommandToTeams?.Invoke(jsonMessage); + case "IsMuted": + case "IsVideoOn": + case "IsHandRaised": + case "IsBackgroundBlurred": + return "switch"; // These are ON/OFF switches + case "IsInMeeting": + case "HasUnreadMessages": + case "IsRecordingOn": + case "IsSharing": + case "teamsRunning": + return "binary_sensor"; // These are true/false sensors + default: + return "unknown"; // Or a default device class if appropriate } } - - private void InitializeClient() + public async Task SetupMqttSensors() { - if (_mqttClient == null) + // Create a dummy MeetingUpdate with default values + var dummyMeetingUpdate = new MeetingUpdate { - var factory = new MqttFactory(); - _mqttClient = (MqttClient?)factory.CreateMqttClient(); // This creates an IMqttClient, not a MqttClient. - - InitializeClientOptions(); // Ensure options are initialized with current settings - if (_mqttClientsubscribed == false) + MeetingState = new MeetingState { - _mqttClient.ApplicationMessageReceivedAsync += OnMessageReceivedAsync; - _mqttClientsubscribed = true; + IsMuted = false, + IsVideoOn = false, + IsHandRaised = false, + IsInMeeting = false, + IsRecordingOn = false, + IsBackgroundBlurred = false, + IsSharing = false, + HasUnreadMessages = false, + teamsRunning = false } - } - } + }; - private void InitializeClientOptions() + // Call PublishConfigurations with the dummy MeetingUpdate + await PublishConfigurations(dummyMeetingUpdate, _settings); + } + public static List GetEntityNames(string deviceId) { - try - { - var factory = new MqttFactory(); - _mqttClient = (MqttClient?)factory.CreateMqttClient(); - - if (!int.TryParse(_settings.MqttPort, out int mqttportInt)) - { - mqttportInt = 1883; // Default MQTT port - Log.Warning($"Invalid MQTT port provided, defaulting to {mqttportInt}"); - } - - var mqttClientOptionsBuilder = new MqttClientOptionsBuilder() - .WithClientId($"Teams2HA_{_deviceId}") - .WithCredentials(_settings.MqttUsername, _settings.MqttPassword) - .WithCleanSession() - .WithTimeout(TimeSpan.FromSeconds(5)); - - string protocol = _settings.UseWebsockets ? "ws" : "tcp"; - string connectionType = _settings.UseTLS ? "with TLS" : "without TLS"; - - if (_settings.UseWebsockets) - { - string websocketUri = _settings.UseTLS ? $"wss://{_settings.MqttAddress}:{mqttportInt}" : $"ws://{_settings.MqttAddress}:{mqttportInt}"; - mqttClientOptionsBuilder.WithWebSocketServer(websocketUri); - Log.Information($"Configuring MQTT client for WebSocket {connectionType} connection to {websocketUri}"); - } - else - { - mqttClientOptionsBuilder.WithTcpServer(_settings.MqttAddress, mqttportInt); - Log.Information($"Configuring MQTT client for TCP {connectionType} connection to {_settings.MqttAddress}:{mqttportInt}"); - } - - if (_settings.UseTLS) + var entityNames = new List { - // Create TLS parameters - var tlsParameters = new MqttClientOptionsBuilderTlsParameters - { - AllowUntrustedCertificates = _settings.IgnoreCertificateErrors, - IgnoreCertificateChainErrors = _settings.IgnoreCertificateErrors, - IgnoreCertificateRevocationErrors = _settings.IgnoreCertificateErrors, - UseTls = true - }; - - // If you need to validate the server certificate, you can set the CertificateValidationHandler. - // Note: Be cautious with bypassing certificate checks in production code!! - if (!_settings.IgnoreCertificateErrors) - { - tlsParameters.CertificateValidationHandler = context => - { - // Log the SSL policy errors - Log.Debug($"SSL policy errors: {context.SslPolicyErrors}"); - - // Return true if there are no SSL policy errors, or if ignoring - // certificate errors is allowed - return context.SslPolicyErrors == System.Net.Security.SslPolicyErrors.None; - }; - } - - // Apply the TLS parameters to the options builder - mqttClientOptionsBuilder.WithTls(tlsParameters); - } + $"switch.{deviceId.ToLower()}_ismuted", + $"switch.{deviceId.ToLower()}_isvideoon", + $"switch.{deviceId.ToLower()}_ishandraised", + $"binary_sensor.{deviceId.ToLower()}_isrecordingon", + $"binary_sensor.{deviceId.ToLower()}_isinmeeting", + $"binary_sensor.{deviceId.ToLower()}_issharing", + $"binary_sensor.{deviceId.ToLower()}_hasunreadmessages", + $"switch.{deviceId.ToLower()}_isbackgroundblurred", + $"binary_sensor.{deviceId.ToLower()}_teamsRunning" + }; - _mqttOptions = mqttClientOptionsBuilder.Build(); - if (_mqttClient != null) - { - if (_mqttClientsubscribed == false) - { - _mqttClient.ApplicationMessageReceivedAsync += OnMessageReceivedAsync; - _mqttClientsubscribed = true; - } - } - } - catch (Exception ex) - { - Log.Error(ex, "Failed to initialize MqttClientWrapper"); - throw; // Rethrowing the exception to handle it outside or log it as fatal depending on your error handling strategy. - } + return entityNames; } - - private Task OnMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs e) + public event Action StatusUpdated; + public async Task DisconnectAsync() { - Log.Information($"Received message on topic {e.ApplicationMessage.Topic}: {e.ApplicationMessage.ConvertPayloadToString()}"); - string topic = e.ApplicationMessage.Topic; - string payload = Encoding.UTF8.GetString(e.ApplicationMessage.Payload); - - // Assuming the format is homeassistant/switch/{deviceId}/{switchName}/set Validate the - // topic format and extract the switchName - var topicParts = topic.Split(','); //not sure this is required - topicParts = topic.Split('/'); - if (topicParts.Length == 4 && topicParts[0].Equals("homeassistant") && topicParts[1].Equals("switch") && topicParts[3].EndsWith("set")) - { - // Extract the action and switch name from the topic - string switchName = topicParts[2]; - string command = payload; // command should be ON or OFF based on the payload - - // Now call the handle method - HandleSwitchCommand(topic, command); - } - - return Task.CompletedTask; + Log.Information("Disconnecting from MQTT broker..."); + await _mqttClient.StopAsync(); } - private void OnMqttPublishTimerElapsed(object sender, ElapsedEventArgs e) + public async Task PublishAsync(MqttApplicationMessage message) { - if (_mqttClient != null && _mqttClient.IsConnected) + try { - // Example: Publish a keep-alive message - string keepAliveTopic = "TEAMS2HA/keepalive"; - string keepAliveMessage = "alive"; - - // Create the MQTT message - var message = new MqttApplicationMessageBuilder() - .WithTopic(keepAliveTopic) - .WithPayload(keepAliveMessage) - .WithQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce) // Or another QoS level if required - .Build(); - - // Publish the message asynchronously - _ = _mqttClient.PublishAsync(message); - - Log.Debug("OnMqttPublishTimerElapsed: MQTT Keep Alive Message Published"); + await _mqttClient.EnqueueAsync(message); // Note: Add using System.Threading; if CancellationToken is undefined + Log.Information("Publish successful." + message.Topic); + } + catch (Exception ex) + { + Log.Information($"Error during MQTT publish: {ex.Message}"); } } - - #endregion Private Methods - } -} \ No newline at end of file +} diff --git a/API/State.cs b/API/State.cs index 73cb681..9532c6b 100644 --- a/API/State.cs +++ b/API/State.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace TEAMS2HA.API { @@ -27,6 +23,16 @@ public class State private string _status = ""; private bool _teamsRunning = false; + private bool canToggleMute = false; + private bool canToggleVideo = false; + private bool canToggleHand = false; + private bool canToggleBlur = false; + private bool canLeave = false; + private bool canReact = false; + private bool canToggleShareTray = false; + private bool canToggleChat = false; + private bool canStopSharing = false; + private bool canPair = false; #endregion Private Fields @@ -162,6 +168,16 @@ public string Status } public bool teamsRunning { get; set; } + public bool CanToggleMute { get; set; } + public bool CanToggleVideo { get; set; } + public bool CanToggleHand { get; set; } + public bool CanToggleBlur { get; set; } + public bool CanLeave { get; set; } + public bool CanReact { get; set; } + public bool CanToggleShareTray { get; set; } + public bool CanToggleChat { get; set; } + public bool CanStopSharing { get; set; } + public bool CanPair { get; set; } #endregion Public Properties diff --git a/API/Teams.cs b/API/Teams.cs deleted file mode 100644 index fbcbfb0..0000000 --- a/API/Teams.cs +++ /dev/null @@ -1,491 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Serilog; -using System; -using System.Collections.Generic; -using System.IO; -using System.Net.WebSockets; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using System.Windows; -using TEAMS2HA.Properties; - -namespace TEAMS2HA.API -{ - public class WebSocketClient - { - #region Private Fields - - private readonly string _settingsFilePath; - private readonly State _state; - private readonly Action _updateTokenAction; - private ClientWebSocket _clientWebSocket; - private Uri _currentUri; - private bool _isConnected; - private TaskCompletionSource _pairingResponseTaskSource; - - private Dictionary meetingState = new Dictionary() - { - { "isMuted", false }, - { "isCameraOn", false }, - { "isHandRaised", false }, - { "isInMeeting", "Not in a meeting" }, - { "isRecordingOn", false }, - { "isBackgroundBlurred", false }, - }; - - #endregion Private Fields - - #region Public Constructors - - public WebSocketClient(Uri uri, State state, string settingsFilePath, Action updateTokenAction) - { - _clientWebSocket = new ClientWebSocket(); - _state = state; - _settingsFilePath = settingsFilePath; - - // Task.Run(() => ConnectAsync(uri)); - Log.Debug("Websocket Client Started"); - // Subscribe to the MessageReceived event - MessageReceived += OnMessageReceived; - _updateTokenAction = updateTokenAction; - } - - #endregion Public Constructors - - #region Public Events - - public event Action ConnectionStatusChanged; - - public event EventHandler MessageReceived; - - public event EventHandler TeamsUpdateReceived; - - #endregion Public Events - - #region Public Properties - - public bool IsConnected - { - get => _isConnected; - private set - { - if (_isConnected != value) - { - _isConnected = value; - ConnectionStatusChanged?.Invoke(_isConnected); - Log.Debug($"Teams Connection Status Changed: {_isConnected}"); - } - } - } - - #endregion Public Properties - - #region Public Methods - - public async Task ConnectAsync(Uri uri) - { - _currentUri = uri; - try - { - // Check if the WebSocket is already connecting or connected - if (_clientWebSocket.State != WebSocketState.None && - _clientWebSocket.State != WebSocketState.Closed) - { - Log.Debug("ConnectAsync: WebSocket is already connecting or connected."); - return; - } - - string token = AppSettings.Instance.PlainTeamsToken; - - if (!string.IsNullOrEmpty(token)) - { - Log.Debug($"Token: {token}"); - var builder = new UriBuilder(uri) { Query = $"token={token}&{uri.Query.TrimStart('?')}" }; - uri = builder.Uri; - } - - await _clientWebSocket.ConnectAsync(uri, CancellationToken.None); - Log.Debug($"Connected to {uri}"); - IsConnected = _clientWebSocket.State == WebSocketState.Open; - Log.Debug($"IsConnected: {IsConnected}"); - } - catch (Exception ex) - { - IsConnected = false; - Log.Error(ex, "ConnectAsync: Error connecting to WebSocket"); - await ReconnectAsync(); - } - - // Start receiving messages - await ReceiveLoopAsync(); - } - - public async Task PairWithTeamsAsync() - { - if (_isConnected) - { - _pairingResponseTaskSource = new TaskCompletionSource(); - - string pairingCommand = "{\"action\":\"pair\",\"parameters\":{},\"requestId\":1}"; - await SendMessageAsync(pairingCommand); - - var responseTask = await Task.WhenAny(_pairingResponseTaskSource.Task, Task.Delay(TimeSpan.FromSeconds(30))); - - if (responseTask == _pairingResponseTaskSource.Task) - { - var response = await _pairingResponseTaskSource.Task; - var newToken = JsonConvert.DeserializeObject(response).NewToken; - AppSettings.Instance.PlainTeamsToken = newToken; - AppSettings.Instance.SaveSettingsToFile(); - - _updateTokenAction?.Invoke(newToken); // Invoke the action to update UI - //subscribe to meeting updates - } - else - { - Log.Warning("Pairing response timed out."); - } - } - } - - public async Task SendMessageAsync(string message, CancellationToken cancellationToken = default) - { - byte[] messageBytes = Encoding.UTF8.GetBytes(message); - await _clientWebSocket.SendAsync(new ArraySegment(messageBytes), WebSocketMessageType.Text, true, cancellationToken); - Log.Debug($"Message Sent: {message}"); - } - - // Public method to initiate connection - public async Task StartConnectionAsync(Uri uri) - { - if (!_isConnected || _clientWebSocket.State != WebSocketState.Open) - { - await ConnectAsync(uri); - } - } - - public async Task StopAsync(CancellationToken cancellationToken = default) - { - try - { - MessageReceived -= OnMessageReceived; - if (_clientWebSocket.State != WebSocketState.Closed) - { - try - { - await _clientWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Connection closed by client", cancellationToken); - Log.Debug("Websocket Connection Closed"); - } - catch (Exception ex) - { - Log.Error(ex, "Error closing WebSocket"); - } - } - } - catch (Exception ex) - { - Log.Error(ex, "Error detaching event handler"); - } - } - - #endregion Public Methods - - #region Private Methods - - private void OnMessageReceived(object sender, string message) - { - Log.Debug($"Message Received: {message}"); - - if (message.Contains("tokenRefresh")) - { - _pairingResponseTaskSource?.SetResult(message); - Log.Information("Result Message {message}", message); - var tokenUpdate = JsonConvert.DeserializeObject(message); - AppSettings.Instance.PlainTeamsToken = tokenUpdate.NewToken; - AppSettings.Instance.SaveSettingsToFile(); - Log.Debug($"Token Updated: {AppSettings.Instance.PlainTeamsToken}"); - // Update the UI on the main thread - Application.Current.Dispatcher.Invoke(() => - { - _updateTokenAction?.Invoke(AppSettings.Instance.PlainTeamsToken); - }); - } - else if (message.Contains("meetingPermissions")) - { - // Update the Message property of the State class - var settings = new JsonSerializerSettings - { - Converters = new List { new MeetingUpdateConverter() } - }; - - MeetingUpdate meetingUpdate = JsonConvert.DeserializeObject(message, settings); - - if (meetingUpdate?.MeetingPermissions?.CanPair == true) - { - // The 'canPair' permission is true, initiate pairing - Log.Debug("Pairing with Teams"); - _ = PairWithTeamsAsync(); - } - // Update the meeting state dictionary - if (meetingUpdate.MeetingState != null) - { - meetingState["isMuted"] = meetingUpdate.MeetingState.IsMuted; - meetingState["isCameraOn"] = meetingUpdate.MeetingState.IsVideoOn; - meetingState["isHandRaised"] = meetingUpdate.MeetingState.IsHandRaised; - meetingState["isInMeeting"] = meetingUpdate.MeetingState.IsInMeeting; - meetingState["isRecordingOn"] = meetingUpdate.MeetingState.IsRecordingOn; - meetingState["isBackgroundBlurred"] = meetingUpdate.MeetingState.IsBackgroundBlurred; - meetingState["isSharing"] = meetingUpdate.MeetingState.IsSharing; - meetingUpdate.MeetingState.teamsRunning = IsConnected; - if (meetingUpdate.MeetingState.IsVideoOn) - { - State.Instance.Camera = "On"; - } - else - { - State.Instance.Camera = "Off"; - } - if (meetingUpdate.MeetingState.teamsRunning) - { - State.Instance.teamsRunning = true; - } - else - { - State.Instance.teamsRunning = false; - } - if (meetingUpdate.MeetingState.IsInMeeting) - { - State.Instance.Activity = "In a meeting"; - } - else - { - State.Instance.Activity = "Not in a Call"; - } - - if (meetingUpdate.MeetingState.IsMuted) - { - State.Instance.Microphone = "On"; - } - else - { - State.Instance.Microphone = "Off"; - } - - if (meetingUpdate.MeetingState.IsHandRaised) - { - State.Instance.Handup = "Raised"; - } - else - { - State.Instance.Handup = "Lowered"; - } - - if (meetingUpdate.MeetingState.IsRecordingOn) - { - State.Instance.Recording = "On"; - } - else - { - State.Instance.Recording = "Off"; - } - - if (meetingUpdate.MeetingState.IsBackgroundBlurred) - { - State.Instance.Blurred = "Blurred"; - } - else - { - State.Instance.Blurred = "Not Blurred"; - } - if (meetingUpdate.MeetingState.IsSharing) - { - State.Instance.issharing = "Sharing"; - } - else - { - State.Instance.issharing = "Not Sharing"; - } - try - { - TeamsUpdateReceived?.Invoke(this, new TeamsUpdateEventArgs { MeetingUpdate = meetingUpdate }); - } - catch (Exception ex) - { - Log.Error(ex, "Error in TeamsUpdateReceived"); - } - Log.Debug($"Meeting State Updated: {meetingState}"); - } - } - } - - private async Task ReceiveLoopAsync(CancellationToken cancellationToken = default) - { - const int bufferSize = 4096; // Starting buffer size - byte[] buffer = new byte[bufferSize]; - int totalBytesReceived = 0; - - while (_clientWebSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) - { - try - { - WebSocketReceiveResult result = await _clientWebSocket.ReceiveAsync(new ArraySegment(buffer, totalBytesReceived, buffer.Length - totalBytesReceived), cancellationToken); - totalBytesReceived += result.Count; - if (result.CloseStatus.HasValue) - { - Log.Debug($"WebSocket closed with status: {result.CloseStatus}"); - IsConnected = false; - break; // Exit the loop if the WebSocket is closed - } - if (result.EndOfMessage) - { - string messageReceived = Encoding.UTF8.GetString(buffer, 0, totalBytesReceived); - Log.Debug($"ReceiveLoopAsync: Message Received: {messageReceived}"); - - if (!cancellationToken.IsCancellationRequested && !string.IsNullOrEmpty(messageReceived)) - { - MessageReceived?.Invoke(this, messageReceived); - } - - // Reset buffer and totalBytesReceived for next message - buffer = new byte[bufferSize]; - totalBytesReceived = 0; - } - else if (totalBytesReceived == buffer.Length) // Resize buffer if it's too small - { - Array.Resize(ref buffer, buffer.Length + bufferSize); - } - } - catch (Exception ex) - { - Log.Error($"WebSocketException in ReceiveLoopAsync: {ex.Message}"); - IsConnected = false; - await ReconnectAsync(); - break; - } - } - IsConnected = _clientWebSocket.State == WebSocketState.Open; - Log.Debug($"IsConnected: {IsConnected}"); - } - - private async Task ReconnectAsync() - { - const int maxRetryCount = 5; - int retryDelay = 2000; // milliseconds - int retryCount = 0; - - while (retryCount < maxRetryCount) - { - try - { - Log.Debug($"Attempting reconnection, try {retryCount + 1} of {maxRetryCount}"); - _clientWebSocket = new ClientWebSocket(); // Create a new instance - await ConnectAsync(_currentUri); - if (IsConnected) - { - break; - } - } - catch (Exception ex) - { - Log.Error($"Reconnect attempt {retryCount + 1} failed: {ex.Message}"); - } - - retryCount++; - await Task.Delay(retryDelay); - } - - if (IsConnected) - { - Log.Information("Reconnected successfully."); - } - else - { - Log.Warning("Failed to reconnect after several attempts."); - } - } - - private void SaveAppSettings(AppSettings settings) - { - string json = JsonConvert.SerializeObject(settings, Formatting.Indented); - try - { - File.WriteAllText(_settingsFilePath, json); - } - catch (Exception ex) - { - Log.Error(ex, "Error saving settings to file"); - } - } - - #endregion Private Methods - - #region Public Classes - - public class MeetingUpdateConverter : JsonConverter - { - #region Public Methods - - public override MeetingUpdate ReadJson(JsonReader reader, Type objectType, MeetingUpdate? existingValue, bool hasExistingValue, JsonSerializer serializer) - { - JObject jsonObject = JObject.Load(reader); - MeetingState meetingState = null; - MeetingPermissions meetingPermissions = null; - - // Check if 'meetingUpdate' is present in JSON - JToken meetingUpdateToken = jsonObject["meetingUpdate"]; - if (meetingUpdateToken != null) - { - // Check if 'meetingState' is present in 'meetingUpdate' - JToken meetingStateToken = meetingUpdateToken["meetingState"]; - if (meetingStateToken != null) - { - meetingState = meetingStateToken.ToObject(); - } - - // Check if 'meetingPermissions' is present in 'meetingUpdate' - JToken meetingPermissionsToken = meetingUpdateToken["meetingPermissions"]; - if (meetingPermissionsToken != null) - { - meetingPermissions = meetingPermissionsToken.ToObject(); - } - } - - return new MeetingUpdate - { - MeetingState = meetingState, - MeetingPermissions = meetingPermissions - }; - } - - public override void WriteJson(JsonWriter writer, MeetingUpdate? value, JsonSerializer serializer) - { - throw new NotImplementedException(); - } - - #endregion Public Methods - } - - public class TeamsUpdateEventArgs : EventArgs - { - #region Public Properties - - public MeetingUpdate? MeetingUpdate { get; set; } - - #endregion Public Properties - } - - public class TokenUpdate - { - #region Public Properties - - [JsonProperty("tokenRefresh")] - public string? NewToken { get; set; } - - #endregion Public Properties - } - - #endregion Public Classes - } -} \ No newline at end of file diff --git a/API/WebSocketManager.cs b/API/WebSocketManager.cs new file mode 100644 index 0000000..20a0c64 --- /dev/null +++ b/API/WebSocketManager.cs @@ -0,0 +1,411 @@ +using System; +using System.Collections.Generic; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using Newtonsoft.Json; +using Serilog; + +using TEAMS2HA.Utils; + +namespace TEAMS2HA.API +{ + public class WebSocketManager + { + private static readonly Lazy _instance = new Lazy(() => new WebSocketManager()); + private ClientWebSocket _clientWebSocket; + private Uri _currentUri; + private bool _isConnected; + private bool _isConnecting; + private System.Timers.Timer _reconnectTimer; + private readonly TimeSpan _reconnectInterval = TimeSpan.FromSeconds(30); // Reconnect every 30 seconds + private readonly Action _updateTokenAction; + public event EventHandler TeamsUpdateReceived; + public event EventHandler MessageReceived; + private TaskCompletionSource _pairingResponseTaskSource; + private Dictionary meetingState = new Dictionary() + { + { "isMuted", false }, + { "isCameraOn", false }, + { "isHandRaised", false }, + { "isInMeeting", "Not in a meeting" }, + { "isRecordingOn", false }, + { "isBackgroundBlurred", false }, + }; + public static WebSocketManager Instance => _instance.Value; + + + private WebSocketManager() + { + _clientWebSocket = new ClientWebSocket(); + _isConnected = false; + _isConnecting = false; + MessageReceived += OnMessageReceived; + InitializeReconnectTimer(); + } + private void InitializeReconnectTimer() + { + _reconnectTimer = new System.Timers.Timer(_reconnectInterval.TotalMilliseconds); + _reconnectTimer.Elapsed += async (sender, args) => await EnsureConnectedAsync(); + _reconnectTimer.AutoReset = true; + _reconnectTimer.Enabled = true; + } + public bool IsConnected => _isConnected && _clientWebSocket.State == WebSocketState.Open; + + public async Task ConnectAsync(Uri uri) + { + if (_isConnecting || _clientWebSocket.State == WebSocketState.Open) + return; + + _isConnecting = true; + try + { + // Dispose of the old WebSocket and create a new one if it's not in the None state + if (_clientWebSocket.State != WebSocketState.None) + { + _clientWebSocket.Dispose(); + _clientWebSocket = new ClientWebSocket(); + } + + _currentUri = uri; + await _clientWebSocket.ConnectAsync(uri, CancellationToken.None); + _isConnected = true; + StartReceiving(); + } + catch (Exception ex) + { + Log.Error("Failed to connect: " + ex.Message); + _isConnected = false; + } + finally + { + _isConnecting = false; + } + } + + public async Task PairWithTeamsAsync(Action updateTokenCallback) + { + if (_isConnected) + { + _pairingResponseTaskSource = new TaskCompletionSource(); + + string pairingCommand = "{\"action\":\"pair\",\"parameters\":{},\"requestId\":1}"; + await SendMessageAsync(pairingCommand); + + var responseTask = await Task.WhenAny(_pairingResponseTaskSource.Task, Task.Delay(TimeSpan.FromSeconds(30))); + + if (responseTask == _pairingResponseTaskSource.Task) + { + var response = await _pairingResponseTaskSource.Task; + var newToken = JsonConvert.DeserializeObject(response).NewToken; + AppSettings.Instance.PlainTeamsToken = newToken; + AppSettings.Instance.SaveSettingsToFile(); + + _updateTokenAction?.Invoke(newToken); + Application.Current.Dispatcher.Invoke(() => + { + var mainWindow = Application.Current.MainWindow as MainWindow; + if (mainWindow != null) + { + mainWindow.UpdatePairingStatus(true); + } + }); + + } + else + { + Log.Warning("Pairing response timed out."); + } + } + } + private async void StartReceiving() + { + var buffer = new byte[4096]; + try + { + while (_clientWebSocket.State == WebSocketState.Open) + { + var result = await _clientWebSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) + { + await _clientWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None); + _isConnected = false; + Log.Information("WebSocket closed."); + } + else + { + var message = System.Text.Encoding.UTF8.GetString(buffer, 0, result.Count); + // Handle the message + Log.Information("Received message: " + message); + //handle the meesage + MessageReceived?.Invoke(this, message); + + } + } + } + catch (Exception ex) + { + Log.Error("Error in receiving loop: " + ex.Message); + _isConnected = false; + } + } + public async Task SendMessageAsync(string message, CancellationToken cancellationToken = default) + { + if (!IsConnected) + { + Log.Warning("WebSocket is not connected. Message not sent."); + return; + } + + byte[] messageBytes = Encoding.UTF8.GetBytes(message); + await _clientWebSocket.SendAsync(new ArraySegment(messageBytes), WebSocketMessageType.Text, true, cancellationToken); + Log.Debug($"Message Sent: {message}"); + } + public async Task EnsureConnectedAsync() + { + if (_clientWebSocket.State != WebSocketState.Open) + { + await ConnectAsync(_currentUri); + } + } + public async Task SendReactionToTeamsAsync(string reactionType) + { + // Construct the JSON payload for the reaction message + var reactionPayload = new + { + action = "send-reaction", + parameters = new { type = reactionType }, + requestId = new Random().Next(1, int.MaxValue) // Generate a random request ID + }; + + string message = JsonConvert.SerializeObject(reactionPayload); + + // Use the SendMessageAsync method to send the reaction message + await SendMessageAsync(message); + Log.Information($"Reaction '{reactionType}' sent to Teams."); + } + private void OnMessageReceived(object sender, string message) + { + Log.Debug($"Message Received: {message}"); + + if (message.Contains("tokenRefresh")) + { + _pairingResponseTaskSource?.SetResult(message); + Log.Information("Result Message {message}", message); + var tokenUpdate = JsonConvert.DeserializeObject(message); + AppSettings.Instance.PlainTeamsToken = tokenUpdate.NewToken; + AppSettings.Instance.SaveSettingsToFile(); + Log.Debug($"Token Updated: {AppSettings.Instance.PlainTeamsToken}"); + // Update the UI on the main thread + Application.Current.Dispatcher.Invoke(() => + { + _updateTokenAction?.Invoke(AppSettings.Instance.PlainTeamsToken); + }); + } + else if (message.Contains("meetingPermissions")) + { + // Update the Message property of the State class + var settings = new JsonSerializerSettings + { + Converters = new List { new MeetingUpdateConverter() } + }; + + MeetingUpdate meetingUpdate = JsonConvert.DeserializeObject(message, settings); + + if (meetingUpdate?.MeetingPermissions?.CanPair == true) + { + // The 'canPair' permission is true, initiate pairing + Log.Debug("Pairing with Teams"); + _ = PairWithTeamsAsync(newToken => + { + + + }); + } + // need to add in sensors for permissions + if (meetingUpdate?.MeetingPermissions?.CanToggleMute == true) + { + State.Instance.CanToggleMute = true; + } + else + { + State.Instance.CanToggleMute = false; + } + + if (meetingUpdate?.MeetingPermissions?.CanToggleVideo == true) + { + State.Instance.CanToggleVideo = true; + } + else + { + State.Instance.CanToggleVideo = false; + } + + if (meetingUpdate?.MeetingPermissions?.CanToggleHand == true) + { + State.Instance.CanToggleHand = true; + } + else + { + State.Instance.CanToggleHand = false; + } + + if (meetingUpdate?.MeetingPermissions?.CanToggleBlur == true) + { + State.Instance.CanToggleBlur = true; + } + else + { + State.Instance.CanToggleBlur = false; + } + + if (meetingUpdate?.MeetingPermissions?.CanLeave == true) + { + State.Instance.CanLeave = true; + } + else + { + State.Instance.CanLeave = false; + } + + if (meetingUpdate?.MeetingPermissions?.CanReact == true) + { + State.Instance.CanReact = true; + } + else + { + State.Instance.CanReact = false; + } + + if (meetingUpdate?.MeetingPermissions?.CanToggleShareTray == true) + { + State.Instance.CanToggleShareTray = true; + } + else + { + State.Instance.CanToggleShareTray = false; + } + + if (meetingUpdate?.MeetingPermissions?.CanToggleChat == true) + { + State.Instance.CanToggleChat = true; + } + else + { + State.Instance.CanToggleChat = false; + } + + if (meetingUpdate?.MeetingPermissions?.CanStopSharing == true) + { + State.Instance.CanStopSharing = true; + } + else + { + State.Instance.CanStopSharing = false; + } + + + // update the meeting state dictionary + if (meetingUpdate.MeetingState != null) + { + meetingState["isMuted"] = meetingUpdate.MeetingState.IsMuted; + meetingState["isCameraOn"] = meetingUpdate.MeetingState.IsVideoOn; + meetingState["isHandRaised"] = meetingUpdate.MeetingState.IsHandRaised; + meetingState["isInMeeting"] = meetingUpdate.MeetingState.IsInMeeting; + meetingState["isRecordingOn"] = meetingUpdate.MeetingState.IsRecordingOn; + meetingState["isBackgroundBlurred"] = meetingUpdate.MeetingState.IsBackgroundBlurred; + meetingState["isSharing"] = meetingUpdate.MeetingState.IsSharing; + meetingUpdate.MeetingState.teamsRunning = IsConnected; + if (meetingUpdate.MeetingState.IsVideoOn) + { + State.Instance.Camera = "On"; + } + else + { + State.Instance.Camera = "Off"; + } + if (meetingUpdate.MeetingState.teamsRunning) + { + State.Instance.teamsRunning = true; + } + else + { + State.Instance.teamsRunning = false; + } + if (meetingUpdate.MeetingState.IsInMeeting) + { + State.Instance.Activity = "In a meeting"; + } + else + { + State.Instance.Activity = "Not in a Call"; + } + + if (meetingUpdate.MeetingState.IsMuted) + { + State.Instance.Microphone = "On"; + } + else + { + State.Instance.Microphone = "Off"; + } + + if (meetingUpdate.MeetingState.IsHandRaised) + { + State.Instance.Handup = "Raised"; + } + else + { + State.Instance.Handup = "Lowered"; + } + + if (meetingUpdate.MeetingState.IsRecordingOn) + { + State.Instance.Recording = "On"; + } + else + { + State.Instance.Recording = "Off"; + } + + if (meetingUpdate.MeetingState.IsBackgroundBlurred) + { + State.Instance.Blurred = "Blurred"; + } + else + { + State.Instance.Blurred = "Not Blurred"; + } + if (meetingUpdate.MeetingState.IsSharing) + { + State.Instance.issharing = "Sharing"; + } + else + { + State.Instance.issharing = "Not Sharing"; + } + try + { + TeamsUpdateReceived?.Invoke(this, new TeamsUpdateEventArgs { MeetingUpdate = meetingUpdate }); + } + catch (Exception ex) + { + Log.Error(ex, "Error in TeamsUpdateReceived"); + } + Log.Debug($"Meeting State Updated: {meetingState}"); + } + } + } + public async Task DisconnectAsync() + { + if (_clientWebSocket.State == WebSocketState.Open) + { + await _clientWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client disconnect", CancellationToken.None); + _isConnected = false; + Log.Information("Disconnected from server."); + } + } + } +} diff --git a/MainWindow.xaml b/MainWindow.xaml index 4d87f4b..19b731a 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -3,93 +3,89 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:tb="http://www.hardcodet.net/taskbar" xmlns:av="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="av" x:Class="TEAMS2HA.MainWindow" - + Title="Teams2HA" Height="420" Width="809" - + xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" TextElement.Foreground="{DynamicResource MaterialDesign.Brush.Foreground}" Background="{DynamicResource MaterialDesign.Brush.Background}" TextElement.FontWeight="Regular" TextElement.FontSize="12" - FontFamily="{materialDesign:MaterialDesignFont}" - + FontFamily="{materialDesign:MaterialDesignFont}" + TextOptions.TextFormattingMode="Ideal" TextOptions.TextRenderingMode="Auto"> - - + - + - + - - - - - - - - - + + + + + + + + + - + - - - - -