diff --git a/App.config b/App.config new file mode 100644 index 0000000..56efbc7 --- /dev/null +++ b/App.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/HomeAssistantEntityStateUpdate.cs b/HomeAssistantEntityStateUpdate.cs new file mode 100644 index 0000000..0810bcb --- /dev/null +++ b/HomeAssistantEntityStateUpdate.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace TeamsPresence +{ + public class HomeAssistantEntityStateUpdate + { + [JsonProperty(PropertyName = "state")] + public string State { get; set; } + [JsonProperty(PropertyName = "attributes")] + public HomeAssistantEntityStateUpdateAttributes Attributes { get; set; } + } +} diff --git a/HomeAssistantEntityStateUpdateAttributes.cs b/HomeAssistantEntityStateUpdateAttributes.cs new file mode 100644 index 0000000..fd6b73a --- /dev/null +++ b/HomeAssistantEntityStateUpdateAttributes.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace TeamsPresence +{ + public class HomeAssistantEntityStateUpdateAttributes + { + [JsonProperty(PropertyName = "friendly_name")] + public string FriendlyName { get; set; } + [JsonProperty(PropertyName = "state")] + public string Icon { get; set; } + } +} diff --git a/HomeAssistantService.cs b/HomeAssistantService.cs new file mode 100644 index 0000000..8e10827 --- /dev/null +++ b/HomeAssistantService.cs @@ -0,0 +1,41 @@ +using RestSharp; +using RestSharp.Serializers.NewtonsoftJson; + +namespace TeamsPresence +{ + public class HomeAssistantService + { + private string Token { get; set; } + private string Url { get; set; } + private RestClient Client { get; set; } + + public HomeAssistantService(string url, string token) + { + Token = token; + Url = url; + Client = new RestClient(url); + + Client.AddDefaultHeader("Authorization", $"Bearer {Token}"); + Client.UseNewtonsoftJson(); + } + + public void UpdateEntity(string entity, string state, string stateFriendlyName, string icon) + { + var update = new HomeAssistantEntityStateUpdate() + { + State = state, + Attributes = new HomeAssistantEntityStateUpdateAttributes() + { + FriendlyName = stateFriendlyName, + Icon = icon + } + }; + + var request = new RestRequest($"api/states/{entity}", Method.POST, DataFormat.Json); + + request.AddJsonBody(update); + + Client.Execute(request); + } + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..77b0b1b --- /dev/null +++ b/Program.cs @@ -0,0 +1,117 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace TeamsPresence +{ + class Program + { + private static HomeAssistantService HomeAssistantService; + private static TeamsLogService TeamsLogService; + private static TeamsPresenceConfig Config; + + static void Main(string[] args) + { + var configFile = "config.json"; + + if (File.Exists(configFile)) + { + Console.WriteLine("Config file found!"); + + try + { + Config = JsonConvert.DeserializeObject(File.ReadAllText(configFile)); + } + catch + { + Console.WriteLine("Config file could not be used. Either fix it or delete it and restart this application to scaffold a new one."); + } + } + else + { + Console.WriteLine("Config file doesn't exist. Creating..."); + + Config = new TeamsPresenceConfig() + { + HomeAssistantUrl = "https://yourha.duckdns.org", + HomeAssistantToken = "eyJ0eXAiOiJKV1...", + AppDataRoamingPath = "", + StatusEntity = "sensor.teams_status", + ActivityEntity = "sensor.teams_activity", + FriendlyStatusNames = new Dictionary() + { + { TeamsStatus.Available, "Available" }, + { TeamsStatus.Busy, "Busy" }, + { TeamsStatus.OnThePhone, "On the phone" }, + { TeamsStatus.Away, "Away" }, + { TeamsStatus.BeRightBack, "Be right back" }, + { TeamsStatus.DoNotDisturb, "Do not disturb" }, + { TeamsStatus.Presenting, "Presenting" }, + { TeamsStatus.Focusing, "Focusing" }, + { TeamsStatus.InAMeeting, "In a meeting" }, + { TeamsStatus.Offline, "Offline" }, + { TeamsStatus.Unknown, "Unknown" } + }, + FriendlyActivityNames = new Dictionary() + { + { TeamsActivity.InACall, "In a call" }, + { TeamsActivity.NotInACall, "Not in a call" }, + { TeamsActivity.Unknown, "Unknown" } + }, + ActivityIcons = new Dictionary() + { + { TeamsActivity.InACall, "mdi:phone-in-talk-outline" }, + { TeamsActivity.NotInACall, "mdi:phone-off" }, + { TeamsActivity.Unknown, "mdi:phone-cancel" } + } + }; + + File.WriteAllText(configFile, JsonConvert.SerializeObject(Config, new JsonSerializerSettings() + { + Formatting = Formatting.Indented + })); + + Console.WriteLine("Done. Fill out the config file and restart this application."); + + return; + } + + if (!String.IsNullOrWhiteSpace(Config.AppDataRoamingPath)) + { + TeamsLogService = new TeamsLogService(Config.AppDataRoamingPath); + } + else + { + TeamsLogService = new TeamsLogService(); + } + + HomeAssistantService = new HomeAssistantService(Config.HomeAssistantUrl, Config.HomeAssistantToken); + + TeamsLogService.StatusChanged += Service_StatusChanged; + TeamsLogService.ActivityChanged += Service_ActivityChanged; + + Console.WriteLine("Service started. Waiting for Teams updates..."); + + TeamsLogService.Start(); + } + + private static void Service_StatusChanged(object sender, TeamsStatus status) + { + HomeAssistantService.UpdateEntity(Config.StatusEntity, status.ToString(), Config.FriendlyStatusNames[status], "mdi:microsoft-teams"); + + Console.WriteLine($"Updated status to {Config.FriendlyStatusNames[status]} ({status})"); + } + + private static void Service_ActivityChanged(object sender, TeamsActivity activity) + { + HomeAssistantService.UpdateEntity(Config.ActivityEntity, activity.ToString(), Config.FriendlyActivityNames[activity], "mdi:microsoft-teams"); + + Console.WriteLine($"Updated activity to {Config.FriendlyActivityNames[activity]} ({activity})"); + } + } +} diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..622d794 --- /dev/null +++ b/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Teams Presence")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Teams Presence")] +[assembly: AssemblyCopyright("Copyright © 2021")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("7373d528-f767-4685-bc5f-5894ce155859")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/README.md b/README.md index 03d071f..973cdfa 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,35 @@ # TeamsPresence A .NET console application that will update entities in Home Assistant based on Microsoft Teams status + +## Initial Configuration + +When you run the application for the first time it will create a sample `config.json` file. To get up an running right away, update the `HomeAssistantUrl` and `HomeAssistantToken` values. + +**Note:** You can set the `AppDataRoamingPath` to hard code which user profile is used for `%appdata%` + +Some changes to Home Assistant's config is also needed. Add the following to your `configuration.yaml`: + +```yaml +sensor: + - platform: template + sensors: + teams_status: + friendly_name: "Microsoft Teams status" + value_template: "{{states('input_text.teams_status')}}" + icon_template: "{{state_attr('input_text.teams_status','icon')}}" + unique_id: sensor.teams_status + teams_activity: + friendly_name: "Microsoft Teams activity" + value_template: "{{states('input_text.teams_activity')}}" + unique_id: sensor.teams_activity + +input_text: + teams_status: + name: Microsoft Teams Status + icon: mdi:microsoft-teams + teams_activity: + name: Microsoft Teams Activity + icon: mdi:phone-off +``` + +Once these steps are completed, you should be able to start the application and see changes to your Teams status and call activity get updated both in the console and in Home Assistant. \ No newline at end of file diff --git a/TeamsEnums.cs b/TeamsEnums.cs new file mode 100644 index 0000000..9b50fca --- /dev/null +++ b/TeamsEnums.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TeamsPresence +{ + public enum TeamsStatus + { + Unknown, + Available, + Busy, + OnThePhone, + Away, + BeRightBack, + DoNotDisturb, + Presenting, + Focusing, + InAMeeting, + Offline + } + + public enum TeamsActivity + { + Unknown, + NotInACall, + InACall + } +} diff --git a/TeamsLogService.cs b/TeamsLogService.cs new file mode 100644 index 0000000..52245ee --- /dev/null +++ b/TeamsLogService.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace TeamsPresence +{ + public class TeamsLogService + { + public event EventHandler StatusChanged; + public event EventHandler ActivityChanged; + + private Stopwatch Stopwatch { get; set; } + private string LogPath { get; set; } + + public TeamsLogService() + { + LogPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Microsoft", "Teams"); + Stopwatch = new Stopwatch(); + } + + public TeamsLogService(string logPath) + { + LogPath = logPath; + Stopwatch = new Stopwatch(); + } + + public void Start() + { + Stopwatch.Start(); + + var lockMe = new object(); + using (var latch = new ManualResetEvent(true)) + using (var fs = new FileStream(Path.Combine(LogPath, "logs.txt"), FileMode.OpenOrCreate, FileAccess.Read, FileShare.ReadWrite)) + using (var fsw = new FileSystemWatcher(LogPath)) + { + fsw.Changed += (s, e) => + { + lock (lockMe) + { + if (e.FullPath != Path.Combine(LogPath, "logs.txt")) return; + latch.Set(); + } + }; + + using (var sr = new StreamReader(fs)) + { + while (true) + { + latch.WaitOne(); + lock (lockMe) + { + String line; + while ((line = sr.ReadLine()) != null) + { + if (Stopwatch.Elapsed.TotalSeconds > 2) + { + Stopwatch.Stop(); + } + + if (!Stopwatch.IsRunning) + LogFileChanged(line); + } + latch.Set(); + } + } + } + } + } + + private void LogFileChanged(string line) + { + + string statusPattern = @"StatusIndicatorStateService: Added (\w+)"; + string activityPattern = @"name: desktop_call_state_change_send, isOngoing: (\w+)"; + + RegexOptions options = RegexOptions.Multiline; + + TeamsStatus status = TeamsStatus.Unknown; + TeamsActivity activity = TeamsActivity.Unknown; + + foreach (Match m in Regex.Matches(line, statusPattern, options)) + { + if (m.Groups[1].Value != "NewActivity") + { + Enum.TryParse(m.Groups[1].Value, out status); + StatusChanged?.Invoke(this, status); + } + } + + foreach (Match m in Regex.Matches(line, activityPattern, options)) + { + activity = m.Groups[1].Value == "true" ? TeamsActivity.InACall : TeamsActivity.NotInACall; + ActivityChanged?.Invoke(this, activity); + } + } + } +} diff --git a/TeamsPresence.csproj b/TeamsPresence.csproj new file mode 100644 index 0000000..5c3dfdb --- /dev/null +++ b/TeamsPresence.csproj @@ -0,0 +1,70 @@ + + + + + Debug + AnyCPU + {7373D528-F767-4685-BC5F-5894CE155859} + Exe + Teams_Presence + Teams Presence + v4.7.2 + 512 + true + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + packages\Newtonsoft.Json.13.0.1\lib\net45\Newtonsoft.Json.dll + + + packages\RestSharp.106.12.0\lib\net452\RestSharp.dll + + + packages\RestSharp.Serializers.NewtonsoftJson.106.12.0\lib\net452\RestSharp.Serializers.NewtonsoftJson.dll + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TeamsPresence.sln b/TeamsPresence.sln new file mode 100644 index 0000000..66fc4b1 --- /dev/null +++ b/TeamsPresence.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.31515.178 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsPresence", "TeamsPresence.csproj", "{7373D528-F767-4685-BC5F-5894CE155859}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7373D528-F767-4685-BC5F-5894CE155859}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7373D528-F767-4685-BC5F-5894CE155859}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7373D528-F767-4685-BC5F-5894CE155859}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7373D528-F767-4685-BC5F-5894CE155859}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7E865EFD-4227-414C-B2F2-0A8C6FDA29B4} + EndGlobalSection +EndGlobal diff --git a/TeamsPresenceConfig.cs b/TeamsPresenceConfig.cs new file mode 100644 index 0000000..824002e --- /dev/null +++ b/TeamsPresenceConfig.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TeamsPresence +{ + public class TeamsPresenceConfig + { + public string HomeAssistantUrl { get; set; } + public string HomeAssistantToken { get; set; } + public string AppDataRoamingPath { get; set; } + + public string StatusEntity { get; set; } + public string ActivityEntity { get; set; } + + public Dictionary FriendlyStatusNames { get; set; } + public Dictionary FriendlyActivityNames { get; set; } + + public Dictionary ActivityIcons { get; set; } + } +} diff --git a/packages.config b/packages.config new file mode 100644 index 0000000..31bcad5 --- /dev/null +++ b/packages.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file