diff --git a/src/HASS.Agent.Staging/HASS.Agent.Shared/HomeAssistant/Commands/PowershellCommand.cs b/src/HASS.Agent.Staging/HASS.Agent.Shared/HomeAssistant/Commands/PowershellCommand.cs index e9076055..187cade1 100644 --- a/src/HASS.Agent.Staging/HASS.Agent.Shared/HomeAssistant/Commands/PowershellCommand.cs +++ b/src/HASS.Agent.Staging/HASS.Agent.Shared/HomeAssistant/Commands/PowershellCommand.cs @@ -45,7 +45,7 @@ public override void TurnOn() } var executed = _isScript - ? PowershellManager.ExecuteScriptHeadless(Command) + ? PowershellManager.ExecuteScriptHeadless(Command, string.Empty) : PowershellManager.ExecuteCommandHeadless(Command); if (!executed) Log.Error("[POWERSHELL] [{name}] Executing {descriptor} failed", Name, _descriptor, Name); @@ -57,12 +57,9 @@ public override void TurnOnWithAction(string action) { State = "ON"; - // prepare command - var command = string.IsNullOrWhiteSpace(Command) ? action : $"{Command} {action}"; - var executed = _isScript - ? PowershellManager.ExecuteScriptHeadless(command) - : PowershellManager.ExecuteCommandHeadless(command); + ? PowershellManager.ExecuteScriptHeadless(Command, action) + : PowershellManager.ExecuteCommandHeadless(Command); if (!executed) Log.Error("[POWERSHELL] [{name}] Launching PS {descriptor} with action '{action}' failed", Name, _descriptor, action); diff --git a/src/HASS.Agent.Staging/HASS.Agent.Shared/Managers/PowershellManager.cs b/src/HASS.Agent.Staging/HASS.Agent.Shared/Managers/PowershellManager.cs index 869873a8..576823dc 100644 --- a/src/HASS.Agent.Staging/HASS.Agent.Shared/Managers/PowershellManager.cs +++ b/src/HASS.Agent.Staging/HASS.Agent.Shared/Managers/PowershellManager.cs @@ -3,273 +3,321 @@ using System.Globalization; using System.IO; using System.Text; +using CliWrap; +using Newtonsoft.Json; using Serilog; namespace HASS.Agent.Shared.Managers { - /// - /// Performs powershell-related actions - /// - public static class PowershellManager - { - /// - /// Execute a Powershell command without waiting for or checking results - /// - /// - /// - public static bool ExecuteCommandHeadless(string command) => ExecuteHeadless(command, false); - - /// - /// Executes a Powershell script without waiting for or checking results - /// - /// - /// - public static bool ExecuteScriptHeadless(string script) => ExecuteHeadless(script, true); - - private static bool ExecuteHeadless(string command, bool isScript) - { - var descriptor = isScript ? "script" : "command"; - - try - { - var workingDir = string.Empty; - if (isScript) - { - // try to get the script's startup path - var scriptDir = Path.GetDirectoryName(command); - workingDir = !string.IsNullOrEmpty(scriptDir) ? scriptDir : string.Empty; - } - - // find the powershell executable - var psExec = GetPsExecutable(); - if (string.IsNullOrEmpty(psExec)) return false; - - // prepare the executing process - var processInfo = new ProcessStartInfo - { - WindowStyle = ProcessWindowStyle.Hidden, - CreateNoWindow = true, - FileName = psExec, - WorkingDirectory = workingDir - }; - - // set the right type of arguments - processInfo.Arguments = isScript ? - $@"& '{command}'" - : $@"& {{{command}}}"; - - // launch - using var process = new Process(); - process.StartInfo = processInfo; - var start = process.Start(); - - if (!start) - { - Log.Error("[POWERSHELL] Unable to start processing {descriptor}: {command}", descriptor, command); - return false; - } - - // done - return true; - } - catch (Exception ex) - { - Log.Fatal(ex, "[POWERSHELL] Fatal error when executing {descriptor}: {command}", descriptor, command); - return false; - } - } - - /// - /// Execute a Powershell command, logs the output if it fails - /// - /// - /// - /// - public static bool ExecuteCommand(string command, TimeSpan timeout) => Execute(command, false, timeout); - - /// - /// Executes a Powershell script, logs the output if it fails - /// - /// - /// - /// - public static bool ExecuteScript(string script, TimeSpan timeout) => Execute(script, true, timeout); - - private static bool Execute(string command, bool isScript, TimeSpan timeout) - { - var descriptor = isScript ? "script" : "command"; - - try - { - var workingDir = string.Empty; - if (isScript) - { - // try to get the script's startup path - var scriptDir = Path.GetDirectoryName(command); - workingDir = !string.IsNullOrEmpty(scriptDir) ? scriptDir : string.Empty; - } - - // find the powershell executable - var psExec = GetPsExecutable(); - if (string.IsNullOrEmpty(psExec)) return false; - - // prepare the executing process - var processInfo = new ProcessStartInfo - { - FileName = psExec, - RedirectStandardError = true, - RedirectStandardOutput = true, - UseShellExecute = false, - WorkingDirectory = workingDir, - // set the right type of arguments - Arguments = isScript - ? $@"& '{command}'" - : $@"& {{{command}}}" - }; - - // launch - using var process = new Process(); - process.StartInfo = processInfo; - var start = process.Start(); - - if (!start) - { - Log.Error("[POWERSHELL] Unable to start processing {descriptor}: {script}", descriptor, command); - return false; - } - - // execute and wait - process.WaitForExit(Convert.ToInt32(timeout.TotalMilliseconds)); - - if (process.ExitCode == 0) - { - // done, all good - return true; - } - - // non-zero exitcode, process as failed - Log.Error("[POWERSHELL] The {descriptor} returned non-zero exitcode: {code}", descriptor, process.ExitCode); - - var errors = process.StandardError.ReadToEnd().Trim(); - if (!string.IsNullOrEmpty(errors)) Log.Error("[POWERSHELL] Error output:\r\n{output}", errors); - else - { - var console = process.StandardOutput.ReadToEnd().Trim(); - if (!string.IsNullOrEmpty(console)) Log.Error("[POWERSHELL] No error output, console output:\r\n{output}", errors); - else Log.Error("[POWERSHELL] No error and no console output"); - } - - // done - return false; - } - catch (Exception ex) - { - Log.Fatal(ex, "[POWERSHELL] Fatal error when executing {descriptor}: {command}", descriptor, command); - return false; - } - } - - /// - /// Executes the command or script, and returns the standard and error output - /// - /// - /// - /// - /// - /// - internal static bool ExecuteWithOutput(string command, TimeSpan timeout, out string output, out string errors) - { - output = string.Empty; - errors = string.Empty; - - try - { - // check whether we're executing a script - var isScript = command.ToLower().EndsWith(".ps1"); - - var workingDir = string.Empty; - if (isScript) - { - // try to get the script's startup path - var scriptDir = Path.GetDirectoryName(command); - workingDir = !string.IsNullOrEmpty(scriptDir) ? scriptDir : string.Empty; - } - - // find the powershell executable - var psExec = GetPsExecutable(); - if (string.IsNullOrEmpty(psExec)) return false; - - // prepare the executing process - var processInfo = new ProcessStartInfo - { - FileName = psExec, - RedirectStandardError = true, - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = workingDir, - // attempt to set the right encoding - StandardOutputEncoding = Encoding.GetEncoding(CultureInfo.CurrentCulture.TextInfo.OEMCodePage), - StandardErrorEncoding = Encoding.GetEncoding(CultureInfo.CurrentCulture.TextInfo.OEMCodePage), - // set the right type of arguments - Arguments = isScript - ? $@"& '{command}'" - : $@"& {{{command}}}" - }; - - // execute and wait - using var process = new Process(); - process.StartInfo = processInfo; - - var start = process.Start(); - if (!start) - { - Log.Error("[POWERSHELL] Unable to begin executing the {type}: {cmd}", isScript ? "script" : "command", command); - return false; - } - - // wait for completion - var completed = process.WaitForExit(Convert.ToInt32(timeout.TotalMilliseconds)); - if (!completed) Log.Error("[POWERSHELL] Timeout executing the {type}: {cmd}", isScript ? "script" : "command", command); - - // read the streams - output = process.StandardOutput.ReadToEnd().Trim(); - errors = process.StandardError.ReadToEnd().Trim(); - - // dispose of them - process.StandardOutput.Dispose(); - process.StandardError.Dispose(); - - // make sure the process ends - process.Kill(); - - // done - return completed; - } - catch (Exception ex) - { - Log.Fatal(ex, ex.Message); - return false; - } - } - - /// - /// Attempt to locate powershell.exe - /// - /// - public static string GetPsExecutable() - { - // try regular location - var psExec = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell\\v1.0\\powershell.exe"); - if (File.Exists(psExec)) return psExec; - - // try specific - psExec = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.SystemX86), "WindowsPowerShell\\v1.0\\powershell.exe"); - if (File.Exists(psExec)) return psExec; - - // not found - Log.Error("[POWERSHELL] PS executable not found, make sure you have powershell installed on your system"); - return string.Empty; - } - } + /// + /// Performs Powershell-related actions + /// + public static class PowershellManager + { + /// + /// Execute a Powershell command without waiting for or checking results + /// + /// + /// + public static bool ExecuteCommandHeadless(string command) => ExecuteHeadless(command, string.Empty, false); + + /// + /// Executes a Powershell script without waiting for or checking results + /// + /// + /// + /// + public static bool ExecuteScriptHeadless(string script, string parameters) => ExecuteHeadless(script, parameters, true); + + private static string GetProcessArguments(string command, string parameters, bool isScript) + { + if (isScript) + { + return string.IsNullOrWhiteSpace(parameters) + ? $"-File \"{command}\"" + : $"-File \"{command}\" \"{parameters}\""; + } + else + { + return $@"& {{{command}}}"; //NOTE: place to fix any potential future issues with "command part of the command" + } + } + + private static bool ExecuteHeadless(string command, string parameters, bool isScript) + { + var descriptor = isScript ? "script" : "command"; + + try + { + var workingDir = string.Empty; + if (isScript) + { + // try to get the script's startup path + var scriptDir = Path.GetDirectoryName(command); + workingDir = !string.IsNullOrEmpty(scriptDir) ? scriptDir : string.Empty; + } + + var psExec = GetPsExecutable(); + if (string.IsNullOrEmpty(psExec)) + return false; + + var processInfo = new ProcessStartInfo + { + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + FileName = psExec, + WorkingDirectory = workingDir, + Arguments = GetProcessArguments(command, parameters, isScript) + }; + + using var process = new Process(); + process.StartInfo = processInfo; + var start = process.Start(); + + if (!start) + { + Log.Error("[POWERSHELL] Unable to start processing {descriptor}: {command}", descriptor, command); + + return false; + } + + return true; + } + catch (Exception ex) + { + Log.Fatal(ex, "[POWERSHELL] Fatal error when executing {descriptor}: {command}", descriptor, command); + + return false; + } + } + + /// + /// Execute a Powershell command, logs the output if it fails + /// + /// + /// + /// + public static bool ExecuteCommand(string command, TimeSpan timeout) => Execute(command, string.Empty, false, timeout); + + /// + /// Executes a Powershell script, logs the output if it fails + /// + /// + /// + /// + public static bool ExecuteScript(string script, string parameters, TimeSpan timeout) => Execute(script, parameters, true, timeout); + + private static bool Execute(string command, string parameters, bool isScript, TimeSpan timeout) + { + var descriptor = isScript ? "script" : "command"; + + try + { + var workingDir = string.Empty; + if (isScript) + { + // try to get the script's startup path + var scriptDir = Path.GetDirectoryName(command); + workingDir = !string.IsNullOrEmpty(scriptDir) ? scriptDir : string.Empty; + } + + var psExec = GetPsExecutable(); + if (string.IsNullOrEmpty(psExec)) return false; + + var processInfo = new ProcessStartInfo + { + FileName = psExec, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + WorkingDirectory = workingDir, + Arguments = GetProcessArguments(command, parameters, isScript) + }; + + using var process = new Process(); + process.StartInfo = processInfo; + var start = process.Start(); + + if (!start) + { + Log.Error("[POWERSHELL] Unable to start processing {descriptor}: {script}", descriptor, command); + + return false; + } + + process.WaitForExit(Convert.ToInt32(timeout.TotalMilliseconds)); + + if (process.ExitCode == 0) + return true; + + // non-zero exitcode, process as failed + Log.Error("[POWERSHELL] The {descriptor} returned non-zero exitcode: {code}", descriptor, process.ExitCode); + + var errors = process.StandardError.ReadToEnd().Trim(); + if (!string.IsNullOrEmpty(errors)) + { + Log.Error("[POWERSHELL] Error output:\r\n{output}", errors); + } + else + { + var console = process.StandardOutput.ReadToEnd().Trim(); + if (!string.IsNullOrEmpty(console)) + Log.Error("[POWERSHELL] No error output, console output:\r\n{output}", errors); + else + Log.Error("[POWERSHELL] No error and no console output"); + } + + return false; + } + catch (Exception ex) + { + Log.Fatal(ex, "[POWERSHELL] Fatal error when executing {descriptor}: {command}", descriptor, command); + + return false; + } + } + + private static Encoding TryParseCodePage(int codePage) + { + Encoding encoding = null; + try + { + encoding = Encoding.GetEncoding(codePage); + } + catch + { + // best effort + } + + return encoding; + } + + private static Encoding GetEncoding() + { + var encoding = TryParseCodePage(CultureInfo.InstalledUICulture.TextInfo.OEMCodePage); + if (encoding != null) + return encoding; + + encoding = TryParseCodePage(CultureInfo.CurrentCulture.TextInfo.OEMCodePage); + if (encoding != null) + return encoding; + + encoding = TryParseCodePage(CultureInfo.CurrentUICulture.TextInfo.OEMCodePage); + if (encoding != null) + return encoding; + + encoding = TryParseCodePage(CultureInfo.InvariantCulture.TextInfo.OEMCodePage); + if (encoding != null) + return encoding; + + Log.Warning("[POWERSHELL] Cannot parse system text culture to encoding, returning UTF-8 as a fallback, please report this as a GitHub issue"); + + Log.Debug("[POWERSHELL] currentInstalledUICulture {c}", JsonConvert.SerializeObject(CultureInfo.InstalledUICulture.TextInfo)); + Log.Debug("[POWERSHELL] currentCulture {c}", JsonConvert.SerializeObject(CultureInfo.CurrentCulture.TextInfo)); + Log.Debug("[POWERSHELL] currentUICulture {c}", JsonConvert.SerializeObject(CultureInfo.CurrentUICulture.TextInfo)); + Log.Debug("[POWERSHELL] invariantCulture {c}", JsonConvert.SerializeObject(CultureInfo.InvariantCulture.TextInfo)); + + return Encoding.UTF8; + } + + /// + /// Executes the command or script, and returns the standard and error output + /// + /// + /// + /// + /// + /// + internal static bool ExecuteWithOutput(string command, TimeSpan timeout, out string output, out string errors) + { + output = string.Empty; + errors = string.Empty; + + try + { + var isScript = command.ToLower().EndsWith(".ps1"); + + var workingDir = string.Empty; + if (isScript) + { + // try to get the script's startup path + var scriptDir = Path.GetDirectoryName(command); + workingDir = !string.IsNullOrEmpty(scriptDir) ? scriptDir : string.Empty; + } + + var psExec = GetPsExecutable(); + if (string.IsNullOrEmpty(psExec)) + return false; + + var encoding = GetEncoding(); + + var processInfo = new ProcessStartInfo + { + FileName = psExec, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDir, + StandardOutputEncoding = encoding, + StandardErrorEncoding = encoding, + // set the right type of arguments + Arguments = isScript + ? $@"& '{command}'" + : $@"& {{{command}}}" + }; + + using var process = new Process(); + process.StartInfo = processInfo; + + var start = process.Start(); + if (!start) + { + Log.Error("[POWERSHELL] Unable to begin executing the {type}: {cmd}", isScript ? "script" : "command", command); + + return false; + } + + var completed = process.WaitForExit(Convert.ToInt32(timeout.TotalMilliseconds)); + if (!completed) + Log.Error("[POWERSHELL] Timeout executing the {type}: {cmd}", isScript ? "script" : "command", command); + + output = process.StandardOutput.ReadToEnd().Trim(); + errors = process.StandardError.ReadToEnd().Trim(); + + process.StandardOutput.Dispose(); + process.StandardError.Dispose(); + + process.Kill(); + + return completed; + } + catch (Exception ex) + { + Log.Fatal(ex, ex.Message); + + return false; + } + } + + /// + /// Attempt to locate powershell.exe + /// + /// + public static string GetPsExecutable() + { + // try regular location + var psExec = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell\\v1.0\\powershell.exe"); + if (File.Exists(psExec)) + return psExec; + + // try specific + psExec = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.SystemX86), "WindowsPowerShell\\v1.0\\powershell.exe"); + if (File.Exists(psExec)) + return psExec; + + Log.Error("[POWERSHELL] PS executable not found, make sure you have powershell installed on your system"); + return string.Empty; + } + } } diff --git a/src/HASS.Agent.Staging/HASS.Agent/MQTT/MqttManager.cs b/src/HASS.Agent.Staging/HASS.Agent/MQTT/MqttManager.cs index d4f08916..875a46de 100644 --- a/src/HASS.Agent.Staging/HASS.Agent/MQTT/MqttManager.cs +++ b/src/HASS.Agent.Staging/HASS.Agent/MQTT/MqttManager.cs @@ -37,7 +37,7 @@ public class MqttManager : IMqttManager private bool _disconnectionNotified = false; private bool _connectingFailureNotified = false; - + private MqttStatus _status = MqttStatus.Connecting; /// @@ -82,7 +82,7 @@ public void Initialize() // create our device's config model if (Variables.DeviceConfig == null) CreateDeviceConfigModel(); - + // create a new mqtt client _mqttClient = Variables.MqttFactory.CreateManagedMqttClient(); @@ -348,7 +348,7 @@ public async Task PublishAsync(MqttApplicationMessage message) if (Variables.ExtendedLogging) Log.Warning("[MQTT] Not connected, message dropped (won't report again for 5 minutes)"); return false; } - + // publish away var published = await _mqttClient.PublishAsync(message); if (published.ReasonCode == MqttClientPublishReasonCode.Success) return true; @@ -390,12 +390,12 @@ public async Task AnnounceAutoDiscoveryConfigAsync(AbstractDiscoverable discover // prepare topic var topic = $"{Variables.AppSettings.MqttDiscoveryPrefix}/{domain}/{Variables.DeviceConfig.Name}/{discoverable.ObjectId}/config"; - + // build config message var messageBuilder = new MqttApplicationMessageBuilder() .WithTopic(topic) .WithRetainFlag(Variables.AppSettings.MqttUseRetainFlag); - + // add payload if (clearConfig) messageBuilder.WithPayload(Array.Empty()); else messageBuilder.WithPayload(JsonSerializer.Serialize(discoverable.GetAutoDiscoveryConfig(), discoverable.GetAutoDiscoveryConfig().GetType(), JsonSerializerOptions)); @@ -420,7 +420,7 @@ public async Task AnnounceAutoDiscoveryConfigAsync(AbstractDiscoverable discover /// private DateTime _lastAvailableAnnouncement = DateTime.MinValue; private DateTime _lastAvailableAnnouncementFailedLogged = DateTime.MinValue; - + /// /// JSON serializer options (camelcase, casing, ignore condition, converters) /// @@ -516,7 +516,7 @@ public async Task ClearDeviceConfigAsync() .WithTopic($"{Variables.AppSettings.MqttDiscoveryPrefix}/sensor/{Variables.DeviceConfig.Name}/availability") .WithPayload(Array.Empty()) .WithRetainFlag(Variables.AppSettings.MqttUseRetainFlag); - + // publish await _mqttClient.PublishAsync(messageBuilder.Build()); } @@ -600,20 +600,20 @@ public async Task UnubscribeAsync(AbstractCommand command) private static ManagedMqttClientOptions GetOptions() { if (string.IsNullOrEmpty(Variables.AppSettings.MqttAddress)) return null; - + // id can be random, but we'll store it for consistency (unless user-defined) if (string.IsNullOrEmpty(Variables.AppSettings.MqttClientId)) { Variables.AppSettings.MqttClientId = Guid.NewGuid().ToString()[..8]; SettingsManager.StoreAppSettings(); } - + // configure last will message var lastWillMessageBuilder = new MqttApplicationMessageBuilder() .WithTopic($"{Variables.AppSettings.MqttDiscoveryPrefix}/sensor/{Variables.DeviceConfig.Name}/availability") .WithPayload("offline") .WithRetainFlag(Variables.AppSettings.MqttUseRetainFlag); - + // prepare message var lastWillMessage = lastWillMessageBuilder.Build(); @@ -687,7 +687,7 @@ private static void HandleMessageReceived(MqttApplicationMessage applicationMess var notification = JsonSerializer.Deserialize(applicationMessage.Payload, JsonSerializerOptions)!; _ = Task.Run(() => NotificationManager.ShowNotification(notification)); return; - } + } if (applicationMessage.Topic == $"hass.agent/media_player/{HelperFunctions.GetConfiguredDeviceName()}/cmd") { @@ -745,12 +745,12 @@ private static void HandleCommandReceived(MqttApplicationMessage applicationMess if (payload.Contains("on")) command.TurnOn(); else if (payload.Contains("off")) command.TurnOff(); else switch (payload) - { - case "press": - case "lock": - command.TurnOn(); - break; - } + { + case "press": + case "lock": + command.TurnOn(); + break; + } } /// @@ -760,8 +760,12 @@ private static void HandleCommandReceived(MqttApplicationMessage applicationMess /// private static void HandleActionReceived(MqttApplicationMessage applicationMessage, AbstractCommand command) { + if (applicationMessage.Payload == null) + return; + var payload = Encoding.UTF8.GetString(applicationMessage.Payload); - if (string.IsNullOrWhiteSpace(payload)) return; + if (string.IsNullOrWhiteSpace(payload)) + return; command.TurnOnWithAction(payload); }