From 678884d3c3bea3790389cbdb49a63cf0bf2dfac4 Mon Sep 17 00:00:00 2001 From: Jordan Date: Wed, 15 Dec 2021 11:33:50 -0800 Subject: [PATCH 01/13] Add MicrophoneProcessSensor --- UserInterface/Views/AddSensorDialog.axaml.cs | 43 +++--- documentation/Sensors.md | 6 +- .../InterProcessApi.cs | 9 +- .../ServiceContractModels.cs | 3 +- .../Data/ConfigurationService.cs | 5 +- .../Domain/Sensors/MicrophoneProcessSensor.cs | 130 ++++++++++++++++++ 6 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs diff --git a/UserInterface/Views/AddSensorDialog.axaml.cs b/UserInterface/Views/AddSensorDialog.axaml.cs index 9ab9bde..b75f2aa 100644 --- a/UserInterface/Views/AddSensorDialog.axaml.cs +++ b/UserInterface/Views/AddSensorDialog.axaml.cs @@ -102,7 +102,7 @@ namespace UserInterface.Views item.ShowWindowNameInput = false; item.UpdateInterval = 5; break; - + case AvailableSensors.DummySensor: item.Description = "This sensor spits out a random number every second. Useful for testing, maybe you'll find some other use for it."; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#dummysensor"; @@ -110,7 +110,7 @@ namespace UserInterface.Views item.ShowWindowNameInput = false; item.UpdateInterval = 1; break; - + case AvailableSensors.CPULoadSensor: item.Description = "This sensor checks the current CPU load. It averages the load on all logical cores every second and rounds the output to two decimals."; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#cpuloadsensor"; @@ -118,7 +118,7 @@ namespace UserInterface.Views item.ShowWindowNameInput = false; item.UpdateInterval = 5; break; - + case AvailableSensors.CurrentClockSpeedSensor: item.Description = "This sensor returns the BIOS configured baseclock for the processor."; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#currentclockspeedsensor"; @@ -126,7 +126,7 @@ namespace UserInterface.Views item.ShowWindowNameInput = false; item.UpdateInterval = 3600; break; - + case AvailableSensors.WMIQuerySensor: item.Description = "This advanced sensor executes a user defined WMI query and exposes the result. The query should return a single value."; item.MoreInfoLink = "https://github.com/sleevezipperhass-workstation-service/blob/master/documentation/WMIQuery.md#wmiquerysensor"; @@ -134,7 +134,7 @@ namespace UserInterface.Views item.ShowWindowNameInput = false; item.UpdateInterval = 10; break; - + case AvailableSensors.MemoryUsageSensor: item.Description = "This sensor calculates the percentage of used memory."; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#memoryusagesensorsensor"; @@ -142,7 +142,7 @@ namespace UserInterface.Views item.ShowWindowNameInput = false; item.UpdateInterval = 10; break; - + case AvailableSensors.ActiveWindowSensor: item.Description = "This sensor exposes the name of the currently active window."; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#activewindowsensor"; @@ -150,28 +150,35 @@ namespace UserInterface.Views item.ShowWindowNameInput = false; item.UpdateInterval = 5; break; - + case AvailableSensors.WebcamActiveSensor: item.Description = "This sensor shows if the webcam is currently being used."; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#webcamactivesensor"; item.ShowQueryInput = false; item.UpdateInterval = 10; break; - + case AvailableSensors.WebcamProcessSensor: item.Description = "This sensor shows which process is using the webcam."; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#webcamprocesssensor"; item.ShowQueryInput = false; item.UpdateInterval = 10; break; - + case AvailableSensors.MicrophoneActiveSensor: item.Description = "This sensor shows if the microphone is currently in use."; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#microphoneactivesensor"; item.ShowQueryInput = false; item.UpdateInterval = 10; break; - + + case AvailableSensors.MicrophoneProcessSensor: + item.Description = "This sensor shows which process is using the microphone."; + item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#microphoneprocesssensor"; + item.ShowQueryInput = false; + item.UpdateInterval = 10; + break; + case AvailableSensors.NamedWindowSensor: item.Description = "This sensor returns true if a window was found with the name you search for. "; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#namedwindowsensor"; @@ -179,7 +186,7 @@ namespace UserInterface.Views item.ShowWindowNameInput = true; item.UpdateInterval = 5; break; - + case AvailableSensors.LastActiveSensor: item.Description = "This sensor returns the date/time that the workstation was last active."; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#lastactivesensor"; @@ -187,7 +194,7 @@ namespace UserInterface.Views item.ShowWindowNameInput = false; item.UpdateInterval = 5; break; - + case AvailableSensors.LastBootSensor: item.Description = "This sensor returns the date/time that Windows was last booted"; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#lastbootsensor"; @@ -195,7 +202,7 @@ namespace UserInterface.Views item.ShowWindowNameInput = false; item.UpdateInterval = 5; break; - + case AvailableSensors.SessionStateSensor: item.Description = "This sensor returns the state of the Windows session."; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#sessionstatesensor"; @@ -203,7 +210,7 @@ namespace UserInterface.Views item.ShowWindowNameInput = false; item.UpdateInterval = 5; break; - + case AvailableSensors.CurrentVolumeSensor: item.Description = "This sensor returns the volume of currently playing audio."; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#currentvolumesensor"; @@ -211,7 +218,7 @@ namespace UserInterface.Views item.ShowWindowNameInput = false; item.UpdateInterval = 5; break; - + case AvailableSensors.MasterVolumeSensor: item.Description = "This sensor returns the master volume of the currently selected default audio device as a percentage value."; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#mastervolumesensor"; @@ -219,7 +226,7 @@ namespace UserInterface.Views item.ShowWindowNameInput = false; item.UpdateInterval = 5; break; - + case AvailableSensors.GPUTemperatureSensor: item.Description = "This sensor returns the current temperature of the GPU in °C."; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#gputemperaturesensor"; @@ -227,7 +234,7 @@ namespace UserInterface.Views item.ShowWindowNameInput = false; item.UpdateInterval = 5; break; - + case AvailableSensors.GPULoadSensor: item.Description = "This sensor returns the current GPU load."; item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/Sensors.md#gpuloadsensor"; @@ -235,7 +242,7 @@ namespace UserInterface.Views item.ShowWindowNameInput = false; item.UpdateInterval = 5; break; - + default: item.Description = null; item.MoreInfoLink = null; diff --git a/documentation/Sensors.md b/documentation/Sensors.md index 2938c7d..6d35695 100644 --- a/documentation/Sensors.md +++ b/documentation/Sensors.md @@ -24,7 +24,7 @@ This sensor returns the current volume of playing audio. **It does not return th This sensor returns the master volume for the currently selected default audio device. -### DummySensor +### DummySensor This sensor produces a random output every second, and is intended to test latency and connectivity. @@ -89,6 +89,10 @@ The webcam active sensor returns the status of the webcam. The webcam process sensor returns the process which is using the webcam. +### MicrophoneProcessSensor + +The microphone process sensor returns the process which is using the microphone. + ### WMIQuerySensor Please see the specific documentaion page [here](https://github.com/sleevezipper/hass-workstation-service/blob/master/documentation/WMIQuery.md#wmiquerysensor). diff --git a/hass-workstation-service/Communication/InterProcesCommunication/InterProcessApi.cs b/hass-workstation-service/Communication/InterProcesCommunication/InterProcessApi.cs index 9c9824e..bf4b3a4 100644 --- a/hass-workstation-service/Communication/InterProcesCommunication/InterProcessApi.cs +++ b/hass-workstation-service/Communication/InterProcesCommunication/InterProcessApi.cs @@ -30,7 +30,7 @@ namespace hass_workstation_service.Communication.InterProcesCommunication /// /// You can use this to check if the application responds. - /// + /// /// /// public string Ping(string str) => str == "ping" ? "pong" : "what?"; @@ -44,7 +44,7 @@ namespace hass_workstation_service.Communication.InterProcesCommunication public void WriteMqttBrokerSettingsAsync(MqttSettings settings) => _configurationService.WriteMqttBrokerSettingsAsync(settings); /// - /// Enables or disables autostart. + /// Enables or disables autostart. /// /// public void EnableAutostart(bool enable) => _configurationService.EnableAutoStart(enable); @@ -108,7 +108,7 @@ namespace hass_workstation_service.Communication.InterProcesCommunication public void RemoveCommandById(Guid id) => _configurationService.DeleteConfiguredCommand(id); /// - /// Adds a command to the configured commands. This properly initializes the class and writes it to the config file. + /// Adds a command to the configured commands. This properly initializes the class and writes it to the config file. /// /// /// @@ -123,7 +123,7 @@ namespace hass_workstation_service.Communication.InterProcesCommunication } /// - /// Adds a command to the configured commands. This properly initializes the class, subscribes to the command topic and writes it to the config file. + /// Adds a command to the configured commands. This properly initializes the class, subscribes to the command topic and writes it to the config file. /// /// /// @@ -179,6 +179,7 @@ namespace hass_workstation_service.Communication.InterProcesCommunication AvailableSensors.WebcamActiveSensor => new WebcamActiveSensor(_publisher, (int)model.UpdateInterval, model.Name), AvailableSensors.WebcamProcessSensor => new WebcamProcessSensor(_publisher, (int)model.UpdateInterval, model.Name), AvailableSensors.MicrophoneActiveSensor => new MicrophoneActiveSensor(_publisher, (int)model.UpdateInterval, model.Name), + AvailableSensors.MicrophoneProcessSensor => new MicrophoneProcessSensor(_publisher, (int)model.UpdateInterval, model.Name), AvailableSensors.NamedWindowSensor => new NamedWindowSensor(_publisher, model.WindowName, model.Name, (int)model.UpdateInterval), AvailableSensors.LastActiveSensor => new LastActiveSensor(_publisher, (int)model.UpdateInterval, model.Name), AvailableSensors.LastBootSensor => new LastBootSensor(_publisher, (int)model.UpdateInterval, model.Name), diff --git a/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractModels.cs b/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractModels.cs index fafa58d..86f7065 100644 --- a/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractModels.cs +++ b/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractModels.cs @@ -45,7 +45,7 @@ namespace hass_workstation_service.Communication.InterProcesCommunication.Models { this.WindowName = namedWindowSensor.WindowName; } - this.UpdateInterval = sensor.UpdateInterval; + this.UpdateInterval = sensor.UpdateInterval; this.UnitOfMeasurement = ((SensorDiscoveryConfigModel)sensor.GetAutoDiscoveryConfig()).Unit_of_measurement; } public ConfiguredSensorModel() @@ -95,6 +95,7 @@ namespace hass_workstation_service.Communication.InterProcesCommunication.Models WebcamActiveSensor, WebcamProcessSensor, MicrophoneActiveSensor, + MicrophoneProcessSensor, ActiveWindowSensor, NamedWindowSensor, LastActiveSensor, diff --git a/hass-workstation-service/Data/ConfigurationService.cs b/hass-workstation-service/Data/ConfigurationService.cs index 3ef2a15..f00bb3c 100644 --- a/hass-workstation-service/Data/ConfigurationService.cs +++ b/hass-workstation-service/Data/ConfigurationService.cs @@ -135,6 +135,9 @@ namespace hass_workstation_service.Data case "MicrophoneActiveSensor": sensor = new MicrophoneActiveSensor(publisher, configuredSensor.UpdateInterval, configuredSensor.Name, configuredSensor.Id); break; + case "MicrophoneProcessSensor": + sensor = new MicrophoneProcessSensor(publisher, configuredSensor.UpdateInterval, configuredSensor.Name, configuredSensor.Id); + break; case "SessionStateSensor": sensor = new SessionStateSensor(publisher, configuredSensor.UpdateInterval, configuredSensor.Name, configuredSensor.Id); break; @@ -462,7 +465,7 @@ namespace hass_workstation_service.Data } /// - /// + /// /// /// The Id of the sensor to replace /// The new sensor diff --git a/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs b/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs new file mode 100644 index 0000000..17ec875 --- /dev/null +++ b/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs @@ -0,0 +1,130 @@ +using hass_workstation_service.Communication; +using Microsoft.Win32; +using System; +using System.Linq; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace hass_workstation_service.Domain.Sensors +{ + public class MicrophoneProcessSensor : AbstractSensor + { + public MicrophoneProcessSensor(MqttPublisher publisher, int? updateInterval = null, string name = "MicrophoneProcess", Guid id = default) : base(publisher, name ?? "MicrophoneProcess", updateInterval ?? 10, id) + { + } + + public override string GetState() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return IsMicrophoneInUseRegistry(); + } + else + { + return "unsupported"; + } + } + public override SensorDiscoveryConfigModel GetAutoDiscoveryConfig() + { + return this._autoDiscoveryConfigModel ?? SetAutoDiscoveryConfigModel(new SensorDiscoveryConfigModel() + { + Name = this.Name, + NamePrefix = Publisher.NamePrefix, + Unique_id = this.Id.ToString(), + Device = this.Publisher.DeviceConfigModel, + State_topic = $"homeassistant/{this.Domain}/{Publisher.DeviceConfigModel.Name}/{DiscoveryConfigModel.GetNameWithPrefix(Publisher.NamePrefix, this.ObjectId)}/state", + Availability_topic = $"homeassistant/sensor/{Publisher.DeviceConfigModel.Name}/availability" + }); + } + + [SupportedOSPlatform("windows")] + private string IsMicrophoneInUseRegistry() + { + using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone")) + { + foreach (var subKeyName in key.GetSubKeyNames()) + { + // NonPackaged has multiple subkeys + if (subKeyName == "NonPackaged") + { + using (var nonpackagedkey = key.OpenSubKey(subKeyName)) + { + foreach (var nonpackagedSubKeyName in nonpackagedkey.GetSubKeyNames()) + { + using (var subKey = nonpackagedkey.OpenSubKey(nonpackagedSubKeyName)) + { + if (subKey.GetValueNames().Contains("LastUsedTimeStop")) + { + var endTime = subKey.GetValue("LastUsedTimeStop") is long ? (long)subKey.GetValue("LastUsedTimeStop") : -1; + if (endTime <= 0) + { + return nonpackagedSubKeyName; + } + } + } + } + } + } + else + { + using (var subKey = key.OpenSubKey(subKeyName)) + { + if (subKey.GetValueNames().Contains("LastUsedTimeStop")) + { + var endTime = subKey.GetValue("LastUsedTimeStop") is long ? (long)subKey.GetValue("LastUsedTimeStop") : -1; + if (endTime <= 0) + { + return subKeyName; + } + } + } + } + } + } + + using (var key = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone")) + { + foreach (var subKeyName in key.GetSubKeyNames()) + { + // NonPackaged has multiple subkeys + if (subKeyName == "NonPackaged") + { + using (var nonpackagedkey = key.OpenSubKey(subKeyName)) + { + foreach (var nonpackagedSubKeyName in nonpackagedkey.GetSubKeyNames()) + { + using (var subKey = nonpackagedkey.OpenSubKey(nonpackagedSubKeyName)) + { + if (subKey.GetValueNames().Contains("LastUsedTimeStop")) + { + var endTime = subKey.GetValue("LastUsedTimeStop") is long ? (long)subKey.GetValue("LastUsedTimeStop") : -1; + if (endTime <= 0) + { + return nonpackagedSubKeyName; + } + } + } + } + } + } + else + { + using (var subKey = key.OpenSubKey(subKeyName)) + { + if (subKey.GetValueNames().Contains("LastUsedTimeStop")) + { + var endTime = subKey.GetValue("LastUsedTimeStop") is long ? (long)subKey.GetValue("LastUsedTimeStop") : -1; + if (endTime <= 0) + { + return subKeyName; + } + } + } + } + } + } + + return "off"; + } + } +} From 93cdde4cde07f710a379d672295d6d67655f7c5b Mon Sep 17 00:00:00 2001 From: Jordan Janzen Date: Wed, 15 Dec 2021 15:00:04 -0800 Subject: [PATCH 02/13] Make microphone and webcam sensors report all active processes --- .../Domain/Sensors/MicrophoneProcessSensor.cs | 99 ++++++----------- .../Domain/Sensors/WebcamProcessSensor.cs | 101 ++++++------------ 2 files changed, 63 insertions(+), 137 deletions(-) diff --git a/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs b/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs index 17ec875..f834740 100644 --- a/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs +++ b/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs @@ -4,11 +4,14 @@ using System; using System.Linq; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using System.Collections.Generic; namespace hass_workstation_service.Domain.Sensors { public class MicrophoneProcessSensor : AbstractSensor { + private List processes = new List(); + public MicrophoneProcessSensor(MqttPublisher publisher, int? updateInterval = null, string name = "MicrophoneProcess", Guid id = default) : base(publisher, name ?? "MicrophoneProcess", updateInterval ?? 10, id) { } @@ -38,92 +41,52 @@ namespace hass_workstation_service.Domain.Sensors } [SupportedOSPlatform("windows")] - private string IsMicrophoneInUseRegistry() + private void CheckLastUsed(RegistryKey key) { - using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone")) + foreach (var subKeyName in key.GetSubKeyNames()) { - foreach (var subKeyName in key.GetSubKeyNames()) + // NonPackaged has multiple subkeys + if (subKeyName == "NonPackaged") { - // NonPackaged has multiple subkeys - if (subKeyName == "NonPackaged") + using (var nonpackagedkey = key.OpenSubKey(subKeyName)) { - using (var nonpackagedkey = key.OpenSubKey(subKeyName)) - { - foreach (var nonpackagedSubKeyName in nonpackagedkey.GetSubKeyNames()) - { - using (var subKey = nonpackagedkey.OpenSubKey(nonpackagedSubKeyName)) - { - if (subKey.GetValueNames().Contains("LastUsedTimeStop")) - { - var endTime = subKey.GetValue("LastUsedTimeStop") is long ? (long)subKey.GetValue("LastUsedTimeStop") : -1; - if (endTime <= 0) - { - return nonpackagedSubKeyName; - } - } - } - } - } + CheckLastUsed(nonpackagedkey); } - else + } + else + { + using (var subKey = key.OpenSubKey(subKeyName)) { - using (var subKey = key.OpenSubKey(subKeyName)) + if (subKey.GetValueNames().Contains("LastUsedTimeStop")) { - if (subKey.GetValueNames().Contains("LastUsedTimeStop")) + var endTime = subKey.GetValue("LastUsedTimeStop") is long ? (long)subKey.GetValue("LastUsedTimeStop") : -1; + if (endTime <= 0) { - var endTime = subKey.GetValue("LastUsedTimeStop") is long ? (long)subKey.GetValue("LastUsedTimeStop") : -1; - if (endTime <= 0) - { - return subKeyName; - } + this.processes.Add(subKeyName); } } } } } + } + + [SupportedOSPlatform("windows")] + private string IsMicrophoneInUseRegistry() + { + using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone")) + { + CheckLastUsed(key); + } using (var key = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone")) { - foreach (var subKeyName in key.GetSubKeyNames()) - { - // NonPackaged has multiple subkeys - if (subKeyName == "NonPackaged") - { - using (var nonpackagedkey = key.OpenSubKey(subKeyName)) - { - foreach (var nonpackagedSubKeyName in nonpackagedkey.GetSubKeyNames()) - { - using (var subKey = nonpackagedkey.OpenSubKey(nonpackagedSubKeyName)) - { - if (subKey.GetValueNames().Contains("LastUsedTimeStop")) - { - var endTime = subKey.GetValue("LastUsedTimeStop") is long ? (long)subKey.GetValue("LastUsedTimeStop") : -1; - if (endTime <= 0) - { - return nonpackagedSubKeyName; - } - } - } - } - } - } - else - { - using (var subKey = key.OpenSubKey(subKeyName)) - { - if (subKey.GetValueNames().Contains("LastUsedTimeStop")) - { - var endTime = subKey.GetValue("LastUsedTimeStop") is long ? (long)subKey.GetValue("LastUsedTimeStop") : -1; - if (endTime <= 0) - { - return subKeyName; - } - } - } - } - } + CheckLastUsed(key); } + if (this.processes.Count() > 0) + { + return String.Join(",", this.processes.ToArray()); + } return "off"; } } diff --git a/hass-workstation-service/Domain/Sensors/WebcamProcessSensor.cs b/hass-workstation-service/Domain/Sensors/WebcamProcessSensor.cs index 9210654..a35b390 100644 --- a/hass-workstation-service/Domain/Sensors/WebcamProcessSensor.cs +++ b/hass-workstation-service/Domain/Sensors/WebcamProcessSensor.cs @@ -4,15 +4,18 @@ using System; using System.Linq; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using System.Collections.Generic; namespace hass_workstation_service.Domain.Sensors { public class WebcamProcessSensor : AbstractSensor { + private List processes = new List(); + public WebcamProcessSensor(MqttPublisher publisher, int? updateInterval = null, string name = "WebcamProcess", Guid id = default) : base(publisher, name ?? "WebcamProcess", updateInterval ?? 10, id) { } - + public override string GetState() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -38,92 +41,52 @@ namespace hass_workstation_service.Domain.Sensors } [SupportedOSPlatform("windows")] - private string IsWebCamInUseRegistry() + private void CheckLastUsed(RegistryKey key) { - using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam")) + foreach (var subKeyName in key.GetSubKeyNames()) { - foreach (var subKeyName in key.GetSubKeyNames()) + // NonPackaged has multiple subkeys + if (subKeyName == "NonPackaged") { - // NonPackaged has multiple subkeys - if (subKeyName == "NonPackaged") + using (var nonpackagedkey = key.OpenSubKey(subKeyName)) { - using (var nonpackagedkey = key.OpenSubKey(subKeyName)) - { - foreach (var nonpackagedSubKeyName in nonpackagedkey.GetSubKeyNames()) - { - using (var subKey = nonpackagedkey.OpenSubKey(nonpackagedSubKeyName)) - { - if (subKey.GetValueNames().Contains("LastUsedTimeStop")) - { - var endTime = subKey.GetValue("LastUsedTimeStop") is long ? (long)subKey.GetValue("LastUsedTimeStop") : -1; - if (endTime <= 0) - { - return nonpackagedSubKeyName; - } - } - } - } - } + CheckLastUsed(nonpackagedkey); } - else + } + else + { + using (var subKey = key.OpenSubKey(subKeyName)) { - using (var subKey = key.OpenSubKey(subKeyName)) + if (subKey.GetValueNames().Contains("LastUsedTimeStop")) { - if (subKey.GetValueNames().Contains("LastUsedTimeStop")) + var endTime = subKey.GetValue("LastUsedTimeStop") is long ? (long)subKey.GetValue("LastUsedTimeStop") : -1; + if (endTime <= 0) { - var endTime = subKey.GetValue("LastUsedTimeStop") is long ? (long)subKey.GetValue("LastUsedTimeStop") : -1; - if (endTime <= 0) - { - return subKeyName; - } + this.processes.Add(subKeyName); } } } } } + } + + [SupportedOSPlatform("windows")] + private string IsWebCamInUseRegistry() + { + using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam")) + { + CheckLastUsed(key); + } using (var key = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam")) { - foreach (var subKeyName in key.GetSubKeyNames()) - { - // NonPackaged has multiple subkeys - if (subKeyName == "NonPackaged") - { - using (var nonpackagedkey = key.OpenSubKey(subKeyName)) - { - foreach (var nonpackagedSubKeyName in nonpackagedkey.GetSubKeyNames()) - { - using (var subKey = nonpackagedkey.OpenSubKey(nonpackagedSubKeyName)) - { - if (subKey.GetValueNames().Contains("LastUsedTimeStop")) - { - var endTime = subKey.GetValue("LastUsedTimeStop") is long ? (long)subKey.GetValue("LastUsedTimeStop") : -1; - if (endTime <= 0) - { - return nonpackagedSubKeyName; - } - } - } - } - } - } - else - { - using (var subKey = key.OpenSubKey(subKeyName)) - { - if (subKey.GetValueNames().Contains("LastUsedTimeStop")) - { - var endTime = subKey.GetValue("LastUsedTimeStop") is long ? (long)subKey.GetValue("LastUsedTimeStop") : -1; - if (endTime <= 0) - { - return subKeyName; - } - } - } - } - } + CheckLastUsed(key); } + if (this.processes.Count() > 0) + { + return String.Join(",", this.processes.ToArray()); + } return "off"; } } From a892409543eab14f3104970ac7a5ae7bd3571cc6 Mon Sep 17 00:00:00 2001 From: Jordan Janzen Date: Wed, 15 Dec 2021 15:06:50 -0800 Subject: [PATCH 03/13] Use HashSet instead of List --- .../Domain/Sensors/MicrophoneProcessSensor.cs | 2 +- hass-workstation-service/Domain/Sensors/WebcamProcessSensor.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs b/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs index f834740..645c67b 100644 --- a/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs +++ b/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs @@ -10,7 +10,7 @@ namespace hass_workstation_service.Domain.Sensors { public class MicrophoneProcessSensor : AbstractSensor { - private List processes = new List(); + private HashSet processes = new HashSet(); public MicrophoneProcessSensor(MqttPublisher publisher, int? updateInterval = null, string name = "MicrophoneProcess", Guid id = default) : base(publisher, name ?? "MicrophoneProcess", updateInterval ?? 10, id) { diff --git a/hass-workstation-service/Domain/Sensors/WebcamProcessSensor.cs b/hass-workstation-service/Domain/Sensors/WebcamProcessSensor.cs index a35b390..bc4c90b 100644 --- a/hass-workstation-service/Domain/Sensors/WebcamProcessSensor.cs +++ b/hass-workstation-service/Domain/Sensors/WebcamProcessSensor.cs @@ -10,7 +10,7 @@ namespace hass_workstation_service.Domain.Sensors { public class WebcamProcessSensor : AbstractSensor { - private List processes = new List(); + private HashSet processes = new HashSet(); public WebcamProcessSensor(MqttPublisher publisher, int? updateInterval = null, string name = "WebcamProcess", Guid id = default) : base(publisher, name ?? "WebcamProcess", updateInterval ?? 10, id) { From d33cb53e3e8ce5fd111cded95def0f9a9f85d6a7 Mon Sep 17 00:00:00 2001 From: Jordan Janzen Date: Wed, 15 Dec 2021 15:17:04 -0800 Subject: [PATCH 04/13] Ensure processes are removed --- .../Domain/Sensors/MicrophoneProcessSensor.cs | 3 +++ hass-workstation-service/Domain/Sensors/WebcamProcessSensor.cs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs b/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs index 645c67b..e266194 100644 --- a/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs +++ b/hass-workstation-service/Domain/Sensors/MicrophoneProcessSensor.cs @@ -73,6 +73,9 @@ namespace hass_workstation_service.Domain.Sensors [SupportedOSPlatform("windows")] private string IsMicrophoneInUseRegistry() { + // Clear old values + this.processes.Clear(); + using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone")) { CheckLastUsed(key); diff --git a/hass-workstation-service/Domain/Sensors/WebcamProcessSensor.cs b/hass-workstation-service/Domain/Sensors/WebcamProcessSensor.cs index bc4c90b..4e5ebdf 100644 --- a/hass-workstation-service/Domain/Sensors/WebcamProcessSensor.cs +++ b/hass-workstation-service/Domain/Sensors/WebcamProcessSensor.cs @@ -73,6 +73,9 @@ namespace hass_workstation_service.Domain.Sensors [SupportedOSPlatform("windows")] private string IsWebCamInUseRegistry() { + // Clear old values + this.processes.Clear(); + using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam")) { CheckLastUsed(key); From e145cc774c7a228e4ea7472aca4b274ffb91b890 Mon Sep 17 00:00:00 2001 From: Preston Tamkin Date: Fri, 24 Dec 2021 10:02:21 -0800 Subject: [PATCH 05/13] Add support for Client Certificates for MQTT and disabling Retained messages For my usecase, I want to connect to a cloud broker(AWS IoT). This service uses Client Certificates. AWS IoT has an incomplete MQTT implementation and does not support QoS (Exactly Once). It also does not support Retain messages in LWT and other situations. This change is to get feedback on how we prefer to deal with these configuration edge cases. It's not clear to me that QoS2 is actually useful because of how HWS republishes all messages on a recurring basis. Should we add additional options for supporting Retains/QoS2? Should we remove the use of QoS2 and make the option I've already added apply to all retains? Additionally, I'm having some problems with the UI. I am not familiar with this UI framework, but the second new textbox and save button are now out of frame/invisible in my build. It looks like there should be a scrollbar, so not sure of the best way to fix this. Testing: I have this working with AWS IoT in my AWS account. I have configured my HomeAssistant mosquitto broker to bridge to AWS IoT and my HWS sensors are working great. --- .../ViewModels/BrokerSettingsViewModel.cs | 13 ++++ UserInterface/Views/BrokerSettings.axaml | 59 +++++++++++++++- UserInterface/Views/BrokerSettings.axaml.cs | 2 +- UserInterface/Views/GeneralSettings.axaml | 22 +++--- .../ServiceContractModels.cs | 4 ++ .../Communication/MQTT/MqttPublisher.cs | 4 +- .../Data/ConfigurationService.cs | 70 +++++++++++++++---- .../Data/ConfiguredMqttBroker.cs | 22 ++++++ .../Domain/Commands/AbstractCommand.cs | 4 +- .../Domain/Sensors/AbstractSensor.cs | 4 +- .../hass-workstation-service.csproj | 2 +- 11 files changed, 171 insertions(+), 35 deletions(-) diff --git a/UserInterface/ViewModels/BrokerSettingsViewModel.cs b/UserInterface/ViewModels/BrokerSettingsViewModel.cs index 2811cb3..5c999d6 100644 --- a/UserInterface/ViewModels/BrokerSettingsViewModel.cs +++ b/UserInterface/ViewModels/BrokerSettingsViewModel.cs @@ -16,6 +16,9 @@ namespace UserInterface.ViewModels private bool isConnected; private int? port; private bool useTLS; + private bool retainLWT = true; + private string rootCaPath; + private string clientCertPath; public bool IsConnected { get => isConnected; set => this.RaiseAndSetIfChanged(ref isConnected, value); } public string Message { get => message; set => this.RaiseAndSetIfChanged(ref message, value); } @@ -29,6 +32,13 @@ namespace UserInterface.ViewModels public bool UseTLS { get => useTLS; set => this.RaiseAndSetIfChanged(ref useTLS, value); } + public bool RetainLWT { get => retainLWT; set => this.RaiseAndSetIfChanged(ref retainLWT, value); } + + public string RootCAPath { get => rootCaPath; set => this.RaiseAndSetIfChanged(ref rootCaPath, value); } + + public string ClientCertPath { get => clientCertPath; set => this.RaiseAndSetIfChanged(ref clientCertPath, value); } + + public void Update(MqttSettings settings) { this.Host = settings.Host; @@ -36,6 +46,9 @@ namespace UserInterface.ViewModels this.Password = settings.Password; this.Port = settings.Port; this.UseTLS = settings.UseTLS; + this.RetainLWT = settings.RetainLWT; + this.RootCAPath = settings.RootCAPath; + this.ClientCertPath = settings.ClientCertPath; } public void UpdateStatus(MqqtClientStatus status) diff --git a/UserInterface/Views/BrokerSettings.axaml b/UserInterface/Views/BrokerSettings.axaml index b23b798..92673df 100644 --- a/UserInterface/Views/BrokerSettings.axaml +++ b/UserInterface/Views/BrokerSettings.axaml @@ -19,12 +19,69 @@ Use TLS + + + + Retain LastWillAndTestament + + (What's this?) + + + +[Experimental] +If set, sets Retain on the Last Will and Testament message. +Only turn this off if you use a broker that does not support this(e.g. AWS IoT Core) +Defaults to True + + + + + + + Username + Password - + + + + Root Cert Path (.pem/.crt) + + (What's this?) + + + + [Experimental] + If set, use this certificate in the TLS configuration for the MQTT connection. + This will be a pem or crt file provided by your broker. + + + + + + + + + Client Cert Path (.pfx) + + (What's this?) + + + + [Experimental] + If set, use this certificate in the TLS configuration for the MQTT connection. + This should be the private key .pfx file for a device created in your broker corresponding to this Windows PC. + + + + + + + + diff --git a/UserInterface/Views/BrokerSettings.axaml.cs b/UserInterface/Views/BrokerSettings.axaml.cs index e870a80..746b44d 100644 --- a/UserInterface/Views/BrokerSettings.axaml.cs +++ b/UserInterface/Views/BrokerSettings.axaml.cs @@ -50,7 +50,7 @@ namespace UserInterface.Views ICollection results; if (model.IsValid(model, out results)) { - var result = this.client.InvokeAsync(x => x.WriteMqttBrokerSettingsAsync(new MqttSettings() { Host = model.Host, Username = model.Username, Password = model.Password ?? "", Port = model.Port, UseTLS = model.UseTLS })); + var result = this.client.InvokeAsync(x => x.WriteMqttBrokerSettingsAsync(new MqttSettings() { Host = model.Host, Username = model.Username, Password = model.Password ?? "", Port = model.Port, UseTLS = model.UseTLS, RootCAPath = model.RootCAPath, ClientCertPath = model.ClientCertPath, RetainLWT = model.RetainLWT })); } } diff --git a/UserInterface/Views/GeneralSettings.axaml b/UserInterface/Views/GeneralSettings.axaml index 4ca197d..a2c723e 100644 --- a/UserInterface/Views/GeneralSettings.axaml +++ b/UserInterface/Views/GeneralSettings.axaml @@ -8,18 +8,18 @@ Settings Name prefix - (What's this?) - - - -[Experimental] -This allows you to set a name which will be used to prefix all sensor- and command names. For example: -If a sensor is called "ActiveWindow" and the name prefix is set to "laptop", the sensor will be named "laptop-ActiveWindow" and its entityId will be "laptop_activewindow". - + (What's this?) + + + + [Experimental] + This allows you to set a name which will be used to prefix all sensor- and command names. For example: + If a sensor is called "ActiveWindow" and the name prefix is set to "laptop", the sensor will be named "laptop-ActiveWindow" and its entityId will be "laptop_activewindow". + - - - + + + diff --git a/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractModels.cs b/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractModels.cs index fafa58d..bd5ace1 100644 --- a/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractModels.cs +++ b/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractModels.cs @@ -11,6 +11,10 @@ namespace hass_workstation_service.Communication.InterProcesCommunication.Models public string Password { get; set; } public int? Port { get; set; } public bool UseTLS { get; set; } + + public bool RetainLWT { get; set; } + public string RootCAPath { get; set; } + public string ClientCertPath { get; set; } } public class MqqtClientStatus diff --git a/hass-workstation-service/Communication/MQTT/MqttPublisher.cs b/hass-workstation-service/Communication/MQTT/MqttPublisher.cs index 7449d67..0cacde5 100644 --- a/hass-workstation-service/Communication/MQTT/MqttPublisher.cs +++ b/hass-workstation-service/Communication/MQTT/MqttPublisher.cs @@ -116,7 +116,7 @@ namespace hass_workstation_service.Communication var message = new MqttApplicationMessageBuilder() .WithTopic($"homeassistant/{discoverable.Domain}/{this.DeviceConfigModel.Name}/{DiscoveryConfigModel.GetNameWithPrefix(discoverable.GetAutoDiscoveryConfig().NamePrefix, discoverable.ObjectId)}/config") .WithPayload(clearConfig ? "" : JsonSerializer.Serialize(discoverable.GetAutoDiscoveryConfig(), discoverable.GetAutoDiscoveryConfig().GetType(), options)) - .WithRetainFlag() + //.WithRetainFlag() .Build(); await this.Publish(message); // if clearconfig is true, also remove previous state messages @@ -125,7 +125,7 @@ namespace hass_workstation_service.Communication var stateMessage = new MqttApplicationMessageBuilder() .WithTopic($"homeassistant/{discoverable.Domain}/{this.DeviceConfigModel.Name}/{DiscoveryConfigModel.GetNameWithPrefix(discoverable.GetAutoDiscoveryConfig().NamePrefix, discoverable.ObjectId)}/state") .WithPayload("") - .WithRetainFlag() + // .WithRetainFlag() .Build(); await this.Publish(stateMessage); } diff --git a/hass-workstation-service/Data/ConfigurationService.cs b/hass-workstation-service/Data/ConfigurationService.cs index 3ef2a15..b41b5f7 100644 --- a/hass-workstation-service/Data/ConfigurationService.cs +++ b/hass-workstation-service/Data/ConfigurationService.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Security; +using System.Security.Cryptography.X509Certificates; using System.Text.Json; using System.Threading.Tasks; using hass_workstation_service.Communication; @@ -20,6 +21,7 @@ using MQTTnet.Client.Options; using MQTTnet.Extensions.ManagedClient; using Serilog; + namespace hass_workstation_service.Data { public class ConfigurationService : IConfigurationService @@ -307,22 +309,54 @@ namespace hass_workstation_service.Data if (configuredBroker != null && configuredBroker.Host != null) { - var mqttClientOptions = new MqttClientOptionsBuilder() + + var mqttClientOptionsBuilder = new MqttClientOptionsBuilder() .WithTcpServer(configuredBroker.Host, configuredBroker.Port) - .WithTls(new MqttClientOptionsBuilderTlsParameters() - { - UseTls = configuredBroker.UseTLS, - AllowUntrustedCertificates = true, - SslProtocol = configuredBroker.UseTLS ? System.Security.Authentication.SslProtocols.Tls12 : System.Security.Authentication.SslProtocols.None - }) .WithCredentials(configuredBroker.Username, configuredBroker.Password.ToString()) - .WithKeepAlivePeriod(TimeSpan.FromSeconds(30)) - .WithWillMessage(new MqttApplicationMessageBuilder() - .WithRetainFlag() + .WithKeepAlivePeriod(TimeSpan.FromSeconds(30)); + + + /* Start LWT */ + var lwtMessage = new MqttApplicationMessageBuilder() .WithTopic($"homeassistant/sensor/{_deviceConfigModel.Name}/availability") - .WithPayload("offline") - .Build()) - .Build(); + .WithPayload("offline"); + if (configuredBroker.RetainLWT) { + lwtMessage.WithRetainFlag(); + } + + mqttClientOptionsBuilder.WithWillMessage(lwtMessage.Build()); + /* End LWT */ + + + /* Start TLS/Certificate configuration */ + + var tlsParameters = new MqttClientOptionsBuilderTlsParameters() + { + UseTls = configuredBroker.UseTLS, + AllowUntrustedCertificates = true, + SslProtocol = configuredBroker.UseTLS ? System.Security.Authentication.SslProtocols.Tls12 : System.Security.Authentication.SslProtocols.None + }; + + var certs = new List(); + + if (configuredBroker.RootCAPath != null) { + certs.Add(new X509Certificate2(configuredBroker.RootCAPath)); + } + + if (configuredBroker.ClientCertPath != null) + { + certs.Add(new X509Certificate2(configuredBroker.ClientCertPath)); + } + if (certs.Count > 0) { + // IF certs are configured, let's add them here + tlsParameters.Certificates = certs; + } + mqttClientOptionsBuilder.WithTls(tlsParameters); + + /* End TLS/Certificate Configuration */ + + + var mqttClientOptions = mqttClientOptionsBuilder.Build(); return new ManagedMqttClientOptionsBuilder().WithClientOptions(mqttClientOptions).Build(); } else @@ -541,7 +575,10 @@ namespace hass_workstation_service.Data Username = settings.Username, Password = settings.Password ?? "", Port = settings.Port ?? 1883, - UseTLS = settings.UseTLS + UseTLS = settings.UseTLS, + RetainLWT = settings.RetainLWT, + RootCAPath = settings.RootCAPath, + ClientCertPath = settings.ClientCertPath }; await JsonSerializer.SerializeAsync(stream, configuredBroker); @@ -560,7 +597,10 @@ namespace hass_workstation_service.Data Username = broker?.Username, Password = broker?.Password, Port = broker?.Port, - UseTLS = broker?.UseTLS ?? false + UseTLS = broker?.UseTLS ?? false, + RetainLWT = broker?.RetainLWT ?? true, + RootCAPath = broker?.RootCAPath, + ClientCertPath = broker?.RootCAPath }; } diff --git a/hass-workstation-service/Data/ConfiguredMqttBroker.cs b/hass-workstation-service/Data/ConfiguredMqttBroker.cs index f188322..d4a73d1 100644 --- a/hass-workstation-service/Data/ConfiguredMqttBroker.cs +++ b/hass-workstation-service/Data/ConfiguredMqttBroker.cs @@ -8,11 +8,33 @@ namespace hass_workstation_service.Data private string username; private string password; private int? port; + + private string rootCAPath; + private string clientCertPath; public string Host { get; set; } public int Port { get => port ?? 1883; set => port = value; } public bool UseTLS { get; set; } + // Before this option, Retains was the default, so let's keep that here to not break backwards compatibility + public bool RetainLWT { get; set; } = true; + + public string RootCAPath { + get + { + if (rootCAPath!= null) return rootCAPath; + return ""; + } + set => rootCAPath = value; + } + public string ClientCertPath { + get + { + if (clientCertPath != null) return clientCertPath; + return ""; + } + set => clientCertPath = value; + } public string Username { diff --git a/hass-workstation-service/Domain/Commands/AbstractCommand.cs b/hass-workstation-service/Domain/Commands/AbstractCommand.cs index 94df49d..4e42b86 100644 --- a/hass-workstation-service/Domain/Commands/AbstractCommand.cs +++ b/hass-workstation-service/Domain/Commands/AbstractCommand.cs @@ -40,8 +40,8 @@ namespace hass_workstation_service.Domain.Commands var message = new MqttApplicationMessageBuilder() .WithTopic(GetAutoDiscoveryConfig().State_topic) .WithPayload(state) - .WithExactlyOnceQoS() - .WithRetainFlag() + //.WithExactlyOnceQoS() + //.WithRetainFlag() .Build(); await Publisher.Publish(message); PreviousPublishedState = state; diff --git a/hass-workstation-service/Domain/Sensors/AbstractSensor.cs b/hass-workstation-service/Domain/Sensors/AbstractSensor.cs index 73904bf..4aa8f7c 100644 --- a/hass-workstation-service/Domain/Sensors/AbstractSensor.cs +++ b/hass-workstation-service/Domain/Sensors/AbstractSensor.cs @@ -40,8 +40,8 @@ namespace hass_workstation_service.Domain.Sensors var message = new MqttApplicationMessageBuilder() .WithTopic(GetAutoDiscoveryConfig().State_topic) .WithPayload(state) - .WithExactlyOnceQoS() - .WithRetainFlag() + //.WithExactlyOnceQoS() + //.WithRetainFlag() .Build(); await Publisher.Publish(message); PreviousPublishedState = state; diff --git a/hass-workstation-service/hass-workstation-service.csproj b/hass-workstation-service/hass-workstation-service.csproj index 30c7c44..77d0cd7 100644 --- a/hass-workstation-service/hass-workstation-service.csproj +++ b/hass-workstation-service/hass-workstation-service.csproj @@ -43,7 +43,7 @@ Always - Always + Never From a61138dd0b2aecf43344cd8cf6cd7bd6b0a166c4 Mon Sep 17 00:00:00 2001 From: Sleevezipper Date: Wed, 5 Jan 2022 21:19:21 +0100 Subject: [PATCH 06/13] Add a scrollviewer. This fixes #148 --- UserInterface/Views/MainWindow.axaml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/UserInterface/Views/MainWindow.axaml b/UserInterface/Views/MainWindow.axaml index 31d7da7..2fe5479 100644 --- a/UserInterface/Views/MainWindow.axaml +++ b/UserInterface/Views/MainWindow.axaml @@ -14,17 +14,19 @@ - + - - - - - - + + + + + + + + \ No newline at end of file From d31856c9e760ce60c175fadc30750a4a9f69b223 Mon Sep 17 00:00:00 2001 From: Rene Scott Simonsen Date: Mon, 10 Jan 2022 14:36:07 +0100 Subject: [PATCH 07/13] Changed LastActive precision to 1 second. --- .../Domain/Sensors/LastActiveSensor.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/hass-workstation-service/Domain/Sensors/LastActiveSensor.cs b/hass-workstation-service/Domain/Sensors/LastActiveSensor.cs index 9eba3b8..0405377 100644 --- a/hass-workstation-service/Domain/Sensors/LastActiveSensor.cs +++ b/hass-workstation-service/Domain/Sensors/LastActiveSensor.cs @@ -6,7 +6,7 @@ namespace hass_workstation_service.Domain.Sensors { public class LastActiveSensor : AbstractSensor { - + private DateTime _lastActive = DateTime.MinValue; public LastActiveSensor(MqttPublisher publisher, int? updateInterval = 10, string name = "LastActive", Guid id = default) : base(publisher, name ?? "LastActive", updateInterval ?? 10, id){} public override SensorDiscoveryConfigModel GetAutoDiscoveryConfig() @@ -25,7 +25,12 @@ namespace hass_workstation_service.Domain.Sensors public override string GetState() { - return GetLastInputTime().ToString("o", System.Globalization.CultureInfo.InvariantCulture); + var lastInput = GetLastInputTime(); + if ((_lastActive - lastInput).Duration().TotalSeconds > 1) + { + _lastActive = lastInput; + } + return _lastActive.ToString("o", System.Globalization.CultureInfo.InvariantCulture); } From 06d132c570149773c8eb916251212a67fd1a51e1 Mon Sep 17 00:00:00 2001 From: Sleevezipper Date: Sun, 16 Jan 2022 13:33:58 +0100 Subject: [PATCH 08/13] some fixes for new settings --- UserInterface/Views/BrokerSettings.axaml | 114 +++++++++--------- UserInterface/Views/GeneralSettings.axaml | 8 +- UserInterface/Views/MainWindow.axaml | 15 ++- .../Data/ConfigurationService.cs | 4 +- 4 files changed, 71 insertions(+), 70 deletions(-) diff --git a/UserInterface/Views/BrokerSettings.axaml b/UserInterface/Views/BrokerSettings.axaml index 92673df..1c6de34 100644 --- a/UserInterface/Views/BrokerSettings.axaml +++ b/UserInterface/Views/BrokerSettings.axaml @@ -4,7 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450" x:Class="UserInterface.Views.BrokerSettings"> - + MQTT Broker @@ -19,69 +19,71 @@ Use TLS - - - - Retain LastWillAndTestament - - (What's this?) - - - -[Experimental] -If set, sets Retain on the Last Will and Testament message. -Only turn this off if you use a broker that does not support this(e.g. AWS IoT Core) -Defaults to True - - - - - - - Username - Password - + + + + + Retain LastWillAndTestament + + (What's this?) + + + +[Experimental] +If set, sets Retain on the Last Will and Testament message. +Only turn this off if you use a broker that does not support this(e.g. AWS IoT Core) +Defaults to True + + + + + + + + + + Root Cert Path (.pem/.crt) + + (What's this?) + + + +[Experimental] +If set, use this certificate in the TLS configuration for the MQTT connection. +This will be a pem or crt file provided by your broker. + + + + + + - Root Cert Path (.pem/.crt) - - (What's this?) - - - - [Experimental] - If set, use this certificate in the TLS configuration for the MQTT connection. - This will be a pem or crt file provided by your broker. - - - - - - + + Client Cert Path (.pfx) + + (What's this?) + + + +[Experimental] +If set, use this certificate in the TLS configuration for the MQTT connection. +This should be the private key .pfx file for a device created in your broker corresponding to this Windows PC. + + + + + + + + - - Client Cert Path (.pfx) - - (What's this?) - - - - [Experimental] - If set, use this certificate in the TLS configuration for the MQTT connection. - This should be the private key .pfx file for a device created in your broker corresponding to this Windows PC. - - - - - - - - + diff --git a/UserInterface/Views/GeneralSettings.axaml b/UserInterface/Views/GeneralSettings.axaml index a2c723e..47259c2 100644 --- a/UserInterface/Views/GeneralSettings.axaml +++ b/UserInterface/Views/GeneralSettings.axaml @@ -4,7 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450" x:Class="UserInterface.Views.GeneralSettingsView"> - + Settings Name prefix @@ -12,9 +12,9 @@ - [Experimental] - This allows you to set a name which will be used to prefix all sensor- and command names. For example: - If a sensor is called "ActiveWindow" and the name prefix is set to "laptop", the sensor will be named "laptop-ActiveWindow" and its entityId will be "laptop_activewindow". +[Experimental] +This allows you to set a name which will be used to prefix all sensor- and command names. For example: +If a sensor is called "ActiveWindow" and the name prefix is set to "laptop", the sensor will be named "laptop-ActiveWindow" and its entityId will be "laptop_activewindow". diff --git a/UserInterface/Views/MainWindow.axaml b/UserInterface/Views/MainWindow.axaml index 2fe5479..c9cfe81 100644 --- a/UserInterface/Views/MainWindow.axaml +++ b/UserInterface/Views/MainWindow.axaml @@ -15,14 +15,13 @@ - - - - - - - - + + + + + + + diff --git a/hass-workstation-service/Data/ConfigurationService.cs b/hass-workstation-service/Data/ConfigurationService.cs index c3090f0..1cd59c4 100644 --- a/hass-workstation-service/Data/ConfigurationService.cs +++ b/hass-workstation-service/Data/ConfigurationService.cs @@ -342,11 +342,11 @@ namespace hass_workstation_service.Data var certs = new List(); - if (configuredBroker.RootCAPath != null) { + if (!string.IsNullOrEmpty(configuredBroker.RootCAPath)) { certs.Add(new X509Certificate2(configuredBroker.RootCAPath)); } - if (configuredBroker.ClientCertPath != null) + if (!string.IsNullOrEmpty(configuredBroker.ClientCertPath)) { certs.Add(new X509Certificate2(configuredBroker.ClientCertPath)); } From f43ce8f9bff54fa4863b83ccd58be6ad49033053 Mon Sep 17 00:00:00 2001 From: Sleevezipper Date: Sun, 16 Jan 2022 16:04:16 +0100 Subject: [PATCH 09/13] add support for custom scopes for wmi sensors --- UserInterface/ViewModels/AddSensorViewModel.cs | 2 ++ UserInterface/Views/AddSensorDialog.axaml | 6 ++++-- UserInterface/Views/AddSensorDialog.axaml.cs | 3 ++- .../InterProcesCommunication/InterProcessApi.cs | 2 +- .../ServiceContractModels.cs | 2 ++ .../Data/ConfigurationService.cs | 16 ++++++++-------- .../Data/ConfiguredSensor.cs | 1 + .../Domain/Sensors/WMIQuerySensor.cs | 17 +++++++++++++++-- 8 files changed, 35 insertions(+), 14 deletions(-) diff --git a/UserInterface/ViewModels/AddSensorViewModel.cs b/UserInterface/ViewModels/AddSensorViewModel.cs index 4e22e0f..f458e82 100644 --- a/UserInterface/ViewModels/AddSensorViewModel.cs +++ b/UserInterface/ViewModels/AddSensorViewModel.cs @@ -13,6 +13,7 @@ namespace UserInterface.ViewModels private bool _showWindowNameInput; private string _moreInfoLink; private string _query; + private string _scope; private string _windowName; public AvailableSensors SelectedType { get => _selectedType; set => this.RaiseAndSetIfChanged(ref _selectedType, value); } @@ -23,6 +24,7 @@ namespace UserInterface.ViewModels public bool ShowWindowNameInput { get => _showWindowNameInput; set => this.RaiseAndSetIfChanged(ref _showWindowNameInput, value); } public string MoreInfoLink { get => _moreInfoLink; set => this.RaiseAndSetIfChanged(ref _moreInfoLink, value); } public string Query { get => _query; set => this.RaiseAndSetIfChanged(ref _query, value); } + public string Scope { get => _scope; set => this.RaiseAndSetIfChanged(ref _scope, value); } public string WindowName { get => _windowName; set => this.RaiseAndSetIfChanged(ref _windowName, value); } } } \ No newline at end of file diff --git a/UserInterface/Views/AddSensorDialog.axaml b/UserInterface/Views/AddSensorDialog.axaml index 8b3b295..865ab19 100644 --- a/UserInterface/Views/AddSensorDialog.axaml +++ b/UserInterface/Views/AddSensorDialog.axaml @@ -21,8 +21,10 @@ - Query - + Scope (optional) + + Query + Window name This is case-insensitive and loosely matched. A window called "Spotify Premium" will match "spotify" or "premium". diff --git a/UserInterface/Views/AddSensorDialog.axaml.cs b/UserInterface/Views/AddSensorDialog.axaml.cs index b75f2aa..ef1a29a 100644 --- a/UserInterface/Views/AddSensorDialog.axaml.cs +++ b/UserInterface/Views/AddSensorDialog.axaml.cs @@ -67,6 +67,7 @@ namespace UserInterface.Views item.Name = sensor.Name; item.UpdateInterval = sensor.UpdateInterval; item.Query = sensor.Query; + item.Scope = sensor.Scope; item.WindowName = sensor.WindowName; Title = $"Edit {sensor.Name}"; @@ -75,7 +76,7 @@ namespace UserInterface.Views public async void Save(object sender, RoutedEventArgs args) { var item = (AddSensorViewModel)DataContext; - dynamic model = new { item.Name, item.Query, item.UpdateInterval, item.WindowName }; + dynamic model = new { item.Name, item.Query, item.UpdateInterval, item.WindowName, item.Scope }; string json = JsonSerializer.Serialize(model); if (SensorId == Guid.Empty) await _client.InvokeAsync(x => x.AddSensor(item.SelectedType, json)); diff --git a/hass-workstation-service/Communication/InterProcesCommunication/InterProcessApi.cs b/hass-workstation-service/Communication/InterProcesCommunication/InterProcessApi.cs index bf4b3a4..bc26f5b 100644 --- a/hass-workstation-service/Communication/InterProcesCommunication/InterProcessApi.cs +++ b/hass-workstation-service/Communication/InterProcesCommunication/InterProcessApi.cs @@ -173,7 +173,7 @@ namespace hass_workstation_service.Communication.InterProcesCommunication AvailableSensors.DummySensor => new DummySensor(_publisher, (int)model.UpdateInterval, model.Name), AvailableSensors.CurrentClockSpeedSensor => new CurrentClockSpeedSensor(_publisher, (int)model.UpdateInterval, model.Name), AvailableSensors.CPULoadSensor => new CPULoadSensor(_publisher, (int)model.UpdateInterval, model.Name), - AvailableSensors.WMIQuerySensor => new WMIQuerySensor(_publisher, model.Query, (int)model.UpdateInterval, model.Name), + AvailableSensors.WMIQuerySensor => new WMIQuerySensor(_publisher, model.Query, (int)model.UpdateInterval, model.Name, scope: model.Scope), AvailableSensors.MemoryUsageSensor => new MemoryUsageSensor(_publisher, (int)model.UpdateInterval, model.Name), AvailableSensors.ActiveWindowSensor => new ActiveWindowSensor(_publisher, (int)model.UpdateInterval, model.Name), AvailableSensors.WebcamActiveSensor => new WebcamActiveSensor(_publisher, (int)model.UpdateInterval, model.Name), diff --git a/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractModels.cs b/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractModels.cs index 5423707..e803f52 100644 --- a/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractModels.cs +++ b/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractModels.cs @@ -30,6 +30,7 @@ namespace hass_workstation_service.Communication.InterProcesCommunication.Models public string Name { get; set; } public string Value { get; set; } public string Query { get; set; } + public string Scope { get; set; } public string WindowName { get; set; } public int UpdateInterval { get; set; } public string UnitOfMeasurement { get; set; } @@ -44,6 +45,7 @@ namespace hass_workstation_service.Communication.InterProcesCommunication.Models if (sensor is WMIQuerySensor wMIQuerySensor) { this.Query = wMIQuerySensor.Query; + this.Scope = wMIQuerySensor.Scope; } if (sensor is NamedWindowSensor namedWindowSensor) { diff --git a/hass-workstation-service/Data/ConfigurationService.cs b/hass-workstation-service/Data/ConfigurationService.cs index 1cd59c4..88ba77c 100644 --- a/hass-workstation-service/Data/ConfigurationService.cs +++ b/hass-workstation-service/Data/ConfigurationService.cs @@ -157,7 +157,7 @@ namespace hass_workstation_service.Data break; // keep this one last! case "WMIQuerySensor": - sensor = new WMIQuerySensor(publisher, configuredSensor.Query, configuredSensor.UpdateInterval, configuredSensor.Name, configuredSensor.Id); + sensor = new WMIQuerySensor(publisher, configuredSensor.Query, configuredSensor.UpdateInterval, configuredSensor.Name, configuredSensor.Id, configuredSensor.Scope); break; default: Log.Logger.Error("unsupported sensor type in config"); @@ -210,22 +210,22 @@ namespace hass_workstation_service.Data case "CustomCommand": command = new CustomCommand(publisher, configuredCommand.Command, configuredCommand.Name, configuredCommand.Id); break; - case "MediaPlayPauseCommand": + case "PlayPauseCommand": command = new PlayPauseCommand(publisher, configuredCommand.Name, configuredCommand.Id); break; - case "MediaNextCommand": + case "NextCommand": command = new NextCommand(publisher, configuredCommand.Name, configuredCommand.Id); break; - case "MediaPreviousCommand": + case "PreviousCommand": command = new PreviousCommand(publisher, configuredCommand.Name, configuredCommand.Id); break; - case "MediaVolumeUpCommand": + case "VolumeUpCommand": command = new VolumeUpCommand(publisher, configuredCommand.Name, configuredCommand.Id); break; - case "MediaVolumeDownCommand": + case "VolumeDownCommand": command = new VolumeDownCommand(publisher, configuredCommand.Name, configuredCommand.Id); break; - case "MediaMuteCommand": + case "MuteCommand": command = new MuteCommand(publisher, configuredCommand.Name, configuredCommand.Id); break; case "KeyCommand": @@ -412,7 +412,7 @@ namespace hass_workstation_service.Data if (sensor is WMIQuerySensor wmiSensor) { #pragma warning disable CA1416 // Validate platform compatibility. We ignore it here because this would never happen. A cleaner solution may be implemented later. - configuredSensorsToSave.Add(new ConfiguredSensor() { Id = wmiSensor.Id, Name = wmiSensor.Name, Type = wmiSensor.GetType().Name, UpdateInterval = wmiSensor.UpdateInterval, Query = wmiSensor.Query }); + configuredSensorsToSave.Add(new ConfiguredSensor() { Id = wmiSensor.Id, Name = wmiSensor.Name, Type = wmiSensor.GetType().Name, UpdateInterval = wmiSensor.UpdateInterval, Query = wmiSensor.Query, Scope = wmiSensor.Scope }); #pragma warning restore CA1416 // Validate platform compatibility } else if (sensor is NamedWindowSensor namedWindowSensor) diff --git a/hass-workstation-service/Data/ConfiguredSensor.cs b/hass-workstation-service/Data/ConfiguredSensor.cs index dcb29eb..7a18d87 100644 --- a/hass-workstation-service/Data/ConfiguredSensor.cs +++ b/hass-workstation-service/Data/ConfiguredSensor.cs @@ -9,6 +9,7 @@ namespace hass_workstation_service.Data public Guid Id { get; set; } public string Name { get; set; } public string Query { get; set; } + public string Scope { get; set; } public int? UpdateInterval { get; set; } public string WindowName { get; set; } } diff --git a/hass-workstation-service/Domain/Sensors/WMIQuerySensor.cs b/hass-workstation-service/Domain/Sensors/WMIQuerySensor.cs index a0b6984..c1601cf 100644 --- a/hass-workstation-service/Domain/Sensors/WMIQuerySensor.cs +++ b/hass-workstation-service/Domain/Sensors/WMIQuerySensor.cs @@ -12,13 +12,26 @@ namespace hass_workstation_service.Domain.Sensors public class WMIQuerySensor : AbstractSensor { public string Query { get; private set; } + public string Scope { get; private set; } protected readonly ObjectQuery _objectQuery; protected readonly ManagementObjectSearcher _searcher; - public WMIQuerySensor(MqttPublisher publisher, string query, int? updateInterval = null, string name = "WMIQuerySensor", Guid id = default) : base(publisher, name ?? "WMIQuerySensor", updateInterval ?? 10, id) + public WMIQuerySensor(MqttPublisher publisher, string query, int? updateInterval = null, string name = "WMIQuerySensor", Guid id = default, string scope = "") : base(publisher, name ?? "WMIQuerySensor", updateInterval ?? 10, id) { this.Query = query; + this.Scope = scope; _objectQuery = new ObjectQuery(this.Query); - _searcher = new ManagementObjectSearcher(query); + ManagementScope managementscope; + // if we have a custom scope, use that + if (!string.IsNullOrWhiteSpace(scope)) + { + managementscope = new ManagementScope(scope); + } + // otherwise, use the default + else + { + managementscope = new ManagementScope(@"\\localhost\"); + } + _searcher = new ManagementObjectSearcher(managementscope, _objectQuery); } public override SensorDiscoveryConfigModel GetAutoDiscoveryConfig() { From 148293dc44f556f2b35c9a95617a1b32620d7342 Mon Sep 17 00:00:00 2001 From: Sleevezipper Date: Sun, 16 Jan 2022 16:08:44 +0100 Subject: [PATCH 10/13] add custom scope info to docs --- documentation/WMIQuery.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/WMIQuery.md b/documentation/WMIQuery.md index 5065f62..f577594 100644 --- a/documentation/WMIQuery.md +++ b/documentation/WMIQuery.md @@ -13,7 +13,7 @@ The command ```sql SELECT * FROM Win32_Processor``` cannot be used because it re You can use [WMI Explorer](https://github.com/vinaypamnani/wmie2/releases) to construct a query, or alternatively look at the user submitted sensors below - +If a class or value cannot be found in the default scope, you can use the "Scope" setting when adding or editing the sensor. --- ## User Submitted Sensor Examples From e2384dcd4b3e71729ed0c347159fd2c65fe61077 Mon Sep 17 00:00:00 2001 From: Sleevezipper Date: Sun, 16 Jan 2022 16:22:45 +0100 Subject: [PATCH 11/13] update dependencies --- UserInterface/UserInterface.csproj | 2 +- .../hass-workstation-service.csproj | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/UserInterface/UserInterface.csproj b/UserInterface/UserInterface.csproj index 73c7ed9..343270c 100644 --- a/UserInterface/UserInterface.csproj +++ b/UserInterface/UserInterface.csproj @@ -17,7 +17,7 @@ - + diff --git a/hass-workstation-service/hass-workstation-service.csproj b/hass-workstation-service/hass-workstation-service.csproj index 77d0cd7..0361625 100644 --- a/hass-workstation-service/hass-workstation-service.csproj +++ b/hass-workstation-service/hass-workstation-service.csproj @@ -55,15 +55,15 @@ - - + + - - + + - + - + From 7d174fdb9740ef78e9e2351f253f9185b3a0b408 Mon Sep 17 00:00:00 2001 From: Sleevezipper Date: Sun, 16 Jan 2022 16:24:42 +0100 Subject: [PATCH 12/13] add some margin to advanced expander --- UserInterface/Views/BrokerSettings.axaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UserInterface/Views/BrokerSettings.axaml b/UserInterface/Views/BrokerSettings.axaml index 1c6de34..becb04e 100644 --- a/UserInterface/Views/BrokerSettings.axaml +++ b/UserInterface/Views/BrokerSettings.axaml @@ -26,7 +26,7 @@ Password - + From becbfff963c2ed1954ecdd894da1bd907156bc69 Mon Sep 17 00:00:00 2001 From: Sleevezipper Date: Sun, 16 Jan 2022 17:15:40 +0100 Subject: [PATCH 13/13] release --- .../PublishProfiles/AzureHosted.pubxml | 14 ++++++++------ .../hass-workstation-service_1_TemporaryKey.pfx | Bin 0 -> 1700 bytes 2 files changed, 8 insertions(+), 6 deletions(-) create mode 100644 hass-workstation-service/hass-workstation-service_1_TemporaryKey.pfx diff --git a/hass-workstation-service/Properties/PublishProfiles/AzureHosted.pubxml b/hass-workstation-service/Properties/PublishProfiles/AzureHosted.pubxml index cb38d89..6af45d4 100644 --- a/hass-workstation-service/Properties/PublishProfiles/AzureHosted.pubxml +++ b/hass-workstation-service/Properties/PublishProfiles/AzureHosted.pubxml @@ -4,11 +4,12 @@ https://go.microsoft.com/fwlink/?LinkID=208121. --> - 57 + 58 1.0.0.* True Release True + True https://github.com/sleevezipper/hass-workstation-service True true @@ -16,8 +17,8 @@ https://go.microsoft.com/fwlink/?LinkID=208121. https://hassworkstationstorage.z6.web.core.windows.net/publish/ True True - 820B7EDF3E26E24BB4C25B177A05B3D0C77BF73A - hass-workstation-service_TemporaryKey.pfx + 66F1CEE175B90B89AFD3AA8F5F649268BF4DFA9E + hass-workstation-service_1_TemporaryKey.pfx true false Any CPU @@ -37,12 +38,13 @@ https://go.microsoft.com/fwlink/?LinkID=208121. True True Foreground - False|2021-11-14T15:44:38.1032015Z; + False + Publish.html - true - .NET Runtime 5.0.1 (x64) + True + .NET Runtime 5.0.12 (x64) \ No newline at end of file diff --git a/hass-workstation-service/hass-workstation-service_1_TemporaryKey.pfx b/hass-workstation-service/hass-workstation-service_1_TemporaryKey.pfx new file mode 100644 index 0000000000000000000000000000000000000000..7c4bb5debc23648ba6d99fd7843edd8de8a4b845 GIT binary patch literal 1700 zcmZWoc~sL^7JdmKfe67su#v3@C`7;{LS%6TL|MeqKx7jnNTLj~2$llM76M6Fl&a_e z7DEyW6pk=#0i{UTD@$d!ieem=02%}>m7q<3_H^1aXa2bFe#^b@yg%MYVj=l(mCY_u+9~bo7`#+CfnC zNoX7n%UmaTDcv-IUXc9{wo(t@Ge1+?Jva>i-VohyY0G<_BSgn)SBz~p(k+zO7Zh-+ zdZ-cAm3Z?hTv~Cya$tW!neGLJa45i!Um1-N565%M-rV0lXfE0rNes{0CRka?Sx!yn z_Ow2{p-CsCy@Kr`92PlhI^N1^r0pva1?t-Enbm8g&=QwKEzT2K6zw^{ys~1qFLs0N zc#xZUXGoqRk5sFB5y&XG-7>#KrFd(4^)O7j&qx`0qVE2RFW3EBQuVHdtT}j*A*ni7ppWHJ-s^nyzwoT{__>y9 zcfSYg^u3Eg;?XR&PoRWqRv;PF|8740+86Vt_ZTxxekK{*i-qRdxAp*^B^J$&WO-_*6?w-{YwjeqKHB^`Jf^*pk<&5Em7DdIMiT-MD~27GVj zQCF{?jBfBe=V=wUu}bbU{8muW-`udTX^Zh{?-rs7DtQ!LJF=B;^0>tRfW>rxfkP`V zU%ItAB|2ovUuS7*!qp};WN9XXbL{exCs7!a@^zon+Jg@@)i}R_psN~F>o2!0}^lnPEdCScEA=`0|!8cxE+LS zAxwc>Yshnk`e6v!fjwY1q}xMf16-jQ8_1!6WKs}P-4Y2yz*u%z82NgTwovEYXu#C6+$)UPV&{BS{NabV%=99={} z_;Mb>CrzfL7Ct=R`;%t**oZ4V|A7w=I5W9!@0Q_m*R4Qq7+*Z|$eI*Y@3b%|&(`2s z*-?|5#>!^ISj>(h{KdZ1RD>;ei;t6xcUuyN5xy=RxK3^i*R#R;B$_4uqV`QwU1M^v z_MxbH)Vb70IT0Bh@sA2KWdeWkqzAc!6MD6l!>p^>pOik~SGL%^o}k!p*BU0hGygGH zdxZHjp69orKcrm!KweRfc%a_5PrV*GWFIEa8)|1b54tR0`$3Ml>5cQGRlLOh)_I<= zus=rTd$ISWk3~m!heN#}7c=>u9wcRL^dnz0MA+d<`s$U;E4e0Q+!eWqik-WEg5{C$ z%5(!gez0^{%oKD6N`oy+?%!iR3Ezu1xI>6aSA~CJ?l6ERts7PKrv7{U$2LOR&FSmY z0xw?t(9%T}moU3G4GK=EwaGei9pnD%Dy2_ieHwRXs=6Et^3KB|e=;RnA3ai2Y#MX- zB!RvrSTv)#Wpvgzckrs*>AucMbt0T>&l3&b7IwCOM)S<>{kyE&9NY4yubuG5o*g5r zp%uh=19y9^9WDJ;gBA-tXMXYAhnl9fxi`tX;zmbC ze@+nN#p0p2pEP9>YI4%%Q@C>4sA@33h&cO$Cp99Q&)f2C=&sEv_=u$_k%GS(f)bZ#OOttYDX;PuDPJh@`!wFG(n*swGARu7|*_95y80 tD!$;0PJP?16e!%gUQh!OxUWj@CQSRIoT%5^wRUZ5Rq~B)#C`sNzXPy<-Z}sP literal 0 HcmV?d00001