From 074ad7b5ecbef6a2002619eb646e9bbc92d9a16e Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 16 Jun 2026 13:15:23 +0100 Subject: [PATCH 01/15] Added Support for multiple gpus --- .../Settings/StoredSensors.cs | 2 +- .../SingleValue/GpuLoadSensor.cs | 126 ++++++++++++++++-- .../HASS.Agent/Forms/Sensors/SensorsMod.cs | 40 ++++++ .../Localization/Languages.Designer.cs | 11 +- .../Resources/Localization/Languages.en.resx | 5 +- .../Resources/Localization/Languages.resx | 5 +- .../HASS.Agent/Settings/StoredSensors.cs | 2 +- 7 files changed, 175 insertions(+), 16 deletions(-) diff --git a/src/HASS.Agent/HASS.Agent.Satellite.Service/Settings/StoredSensors.cs b/src/HASS.Agent/HASS.Agent.Satellite.Service/Settings/StoredSensors.cs index 556c8086..4dce2b0b 100644 --- a/src/HASS.Agent/HASS.Agent.Satellite.Service/Settings/StoredSensors.cs +++ b/src/HASS.Agent/HASS.Agent.Satellite.Service/Settings/StoredSensors.cs @@ -142,7 +142,7 @@ await Task.Run(delegate abstractSensor = new CurrentVolumeSensor(sensor.UpdateInterval, sensor.EntityName, sensor.Name, sensor.Id.ToString(), sensor.AdvancedSettings); break; case SensorType.GpuLoadSensor: - abstractSensor = new GpuLoadSensor(sensor.UpdateInterval, sensor.EntityName, sensor.Name, sensor.Id.ToString(), sensor.AdvancedSettings); + abstractSensor = new GpuLoadSensor(sensor.Query, sensor.UpdateInterval, sensor.EntityName, sensor.Name, sensor.Id.ToString(), sensor.AdvancedSettings); break; case SensorType.GpuTemperatureSensor: abstractSensor = new GpuTemperatureSensor(sensor.UpdateInterval, sensor.EntityName, sensor.Name, sensor.Id.ToString(), sensor.AdvancedSettings); diff --git a/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs b/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs index 00f54708..17543c37 100644 --- a/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs +++ b/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs @@ -1,6 +1,10 @@ -using System.Diagnostics; +using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.Linq; +using System.Management; +using System.Text.RegularExpressions; using System.Threading; using HASS.Agent.Shared.Managers; using HASS.Agent.Shared.Models.HomeAssistant; @@ -13,10 +17,17 @@ namespace HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.SingleValue; public class GpuLoadSensor : AbstractSingleValueSensor { private const string DefaultName = "gpuload"; + private const string AllGpus = "*"; - public GpuLoadSensor(int? updateInterval = null, string entityName = DefaultName, string name = DefaultName, string id = default, string advancedSettings = default) : base(entityName ?? DefaultName, name ?? null, updateInterval ?? 30, id, advancedSettings: advancedSettings) - { + private static readonly Regex PhysicalGpuIndexRegex = new(@"_phys_(\d+)_", RegexOptions.Compiled); + + public string GpuId { get; protected set; } + private readonly bool _useSpecificGpu; + public GpuLoadSensor(string gpuId = AllGpus, int? updateInterval = null, string entityName = DefaultName, string name = DefaultName, string id = default, string advancedSettings = default) : base(entityName ?? DefaultName, name ?? null, updateInterval ?? 30, id, advancedSettings: advancedSettings) + { + GpuId = string.IsNullOrEmpty(gpuId) ? AllGpus : gpuId; + _useSpecificGpu = GpuId != AllGpus; } public override DiscoveryConfigModel GetAutoDiscoveryConfig() @@ -50,23 +61,116 @@ public override string GetState() public float GetGPUUsage() { + try + { + var perGpuUsage = GetPerGpuUsage(); + if (perGpuUsage.Count == 0) + return 0; + + if (_useSpecificGpu) + return perGpuUsage.TryGetValue(GpuId, out var usage) ? usage : 0; + + // 'all' selected: average across every detected gpu, instead of summing them together + return perGpuUsage.Values.Average(); + } + catch + { + return 0; + } + } + + /// + /// Reads the 'GPU Engine' 3D counters and sums them per physical GPU (identified by its 'phys_n' index) + /// + private static Dictionary GetPerGpuUsage() + { + var category = new PerformanceCounterCategory("GPU Engine"); + var gpuCounters = category.GetInstanceNames() + .Where(name => name.EndsWith("engtype_3D")) + .SelectMany(name => category.GetCounters(name)) + .Where(counter => counter.CounterName.Equals("Utilization Percentage")) + .ToList(); + + gpuCounters.ForEach(x => { _ = x.NextValue(); }); + Thread.Sleep(10); //TODO(Amadeo): fix this + + return gpuCounters + .GroupBy(x => GetPhysicalGpuIndex(x.InstanceName)) + .ToDictionary(g => g.Key, g => g.Sum(x => x.NextValue())); + } + + /// + /// Extracts the physical adapter index (the 'n' in 'phys_n') from a GPU Engine counter instance name + /// + private static string GetPhysicalGpuIndex(string instanceName) + { + var match = PhysicalGpuIndexRegex.Match(instanceName); + return match.Success ? match.Groups[1].Value : "0"; + } + + /// + /// Enumerates the physical GPUs currently exposing a 'GPU Engine' performance counter, keyed by their physical adapter index + /// + public static Dictionary GetAvailableGpus() + { + var gpus = new Dictionary(); + try { var category = new PerformanceCounterCategory("GPU Engine"); - var gpuCounters = category.GetInstanceNames() + var physicalIndexes = category.GetInstanceNames() .Where(name => name.EndsWith("engtype_3D")) - .SelectMany(name => category.GetCounters(name)) - .Where(counter => counter.CounterName.Equals("Utilization Percentage")) - .ToList(); + .Select(GetPhysicalGpuIndex) + .Distinct() + .OrderBy(x => int.TryParse(x, out var parsed) ? parsed : int.MaxValue); - gpuCounters.ForEach(x => { _ = x.NextValue(); }); - Thread.Sleep(10); //TODO(Amadeo): fix this + var gpuNames = GetGpuNamesByIndex(); - return gpuCounters.Sum(x => x.NextValue()); + foreach (var physicalIndex in physicalIndexes) + { + gpus[physicalIndex] = gpuNames.TryGetValue(physicalIndex, out var gpuName) && !string.IsNullOrWhiteSpace(gpuName) + ? gpuName + : $"GPU {physicalIndex}"; + } } catch { - return 0; + // best effort, no gpu's found + } + + return gpus; + } + + /// + /// Best-effort mapping of physical adapter index to a friendly GPU name, using WMI's video controller enumeration order + /// + private static Dictionary GetGpuNamesByIndex() + { + var names = new Dictionary(); + + try + { + using var searcher = new ManagementObjectSearcher("SELECT Name, PNPDeviceID FROM Win32_VideoController"); + using var results = searcher.Get(); + + var index = 0; + foreach (var result in results) + { + using var videoController = (ManagementObject)result; + + var pnpDeviceId = videoController["PNPDeviceID"]?.ToString() ?? string.Empty; + if (!pnpDeviceId.StartsWith("PCI", StringComparison.OrdinalIgnoreCase)) + continue; // skip basic render/remote display virtual adapters + + names[index.ToString(CultureInfo.InvariantCulture)] = videoController["Name"]?.ToString(); + index++; + } } + catch + { + // best effort, no names found + } + + return names; } } diff --git a/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs b/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs index df3f172a..59518055 100644 --- a/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs +++ b/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs @@ -30,6 +30,7 @@ public partial class SensorsMod : MetroForm private readonly Dictionary _networkCards = new(); private readonly Dictionary _internalSensors = new(); + private readonly Dictionary _gpuDevices = new(); private SensorType _selectedSensorType = SensorType.ActiveWindowSensor; @@ -112,6 +113,10 @@ private void SensorMod_Load(object sender, EventArgs e) _internalSensors.Add(internalSensorType, internalSensorType); } + _gpuDevices.Add("*", Languages.SensorsMod_All); + foreach (var gpu in GpuLoadSensor.GetAvailableGpus()) + _gpuDevices.Add(gpu.Key, gpu.Value); + CbIgnoreAvailability.CheckedChanged += CbIgnoreAvailability_CheckedChanged; // load in gui @@ -218,6 +223,11 @@ private void LoadSensor() CbNetworkCard.SelectedItem = new KeyValuePair(Sensor.Query, _networkCards[Sensor.Query]); break; + case SensorType.GpuLoadSensor: + if (_gpuDevices.ContainsKey(Sensor.Query)) + CbNetworkCard.SelectedItem = new KeyValuePair(Sensor.Query, _gpuDevices[Sensor.Query]); + break; + case SensorType.InternalDeviceSensor: if (_internalSensors.ContainsKey(Sensor.Query)) CbNetworkCard.SelectedItem = new KeyValuePair(Sensor.Query, _internalSensors[Sensor.Query]); @@ -318,6 +328,11 @@ private bool SetType(bool setDefaultValues = true) SetNetworkGui(); break; + case SensorType.GpuLoadSensor: + CbNetworkCard.DataSource = new BindingSource(_gpuDevices, null); + SetGpuGui(); + break; + case SensorType.InternalDeviceSensor: CbNetworkCard.DataSource = new BindingSource(_internalSensors, null); SetInternalSensorGui(); @@ -503,6 +518,22 @@ private void SetNetworkGui() })); } + /// + /// Change the UI to a 'gpu load' type + /// + private void SetGpuGui() + { + Invoke(new MethodInvoker(delegate + { + SetEmptyGui(); + + LblSetting1.Text = Languages.SensorsMod_LblSetting1_Gpu; + LblSetting1.Visible = true; + + CbNetworkCard.Visible = true; + })); + } + /// /// Change the UI to a 'internal sensor' type /// @@ -789,6 +820,15 @@ private void BtnStore_Click(object sender, EventArgs e) } break; + case SensorType.GpuLoadSensor: + Sensor.Query = "*"; + if (CbNetworkCard.SelectedItem != null) + { + var item = (KeyValuePair)CbNetworkCard.SelectedItem; + Sensor.Query = item.Key; + } + break; + case SensorType.InternalDeviceSensor: if (CbNetworkCard.SelectedItem != null) { diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.Designer.cs b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.Designer.cs index 0e4dcc85..e91cf16b 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.Designer.cs +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.Designer.cs @@ -6757,7 +6757,16 @@ internal static string SensorsMod_LblSetting1_Category { return ResourceManager.GetString("SensorsMod_LblSetting1_Category", resourceCulture); } } - + + /// + /// Looks up a localized string similar to GPU Device. + /// + internal static string SensorsMod_LblSetting1_Gpu { + get { + return ResourceManager.GetString("SensorsMod_LblSetting1_Gpu", resourceCulture); + } + } + /// /// Looks up a localized string similar to Internal Sensor. /// diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.en.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.en.resx index 1b3ef33c..9edee253 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.en.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.en.resx @@ -1791,6 +1791,9 @@ Please configure an executor or your command will not run. Category + + GPU Device + Counter @@ -2056,7 +2059,7 @@ Currently takes the volume of your default device. Dummy sensor for testing purposes, sends a random integer value between 0 and 100. - Provides the current load of the first GPU as a percentage. + Provides the current load of the selected GPU as a percentage, or the average load of all GPUs. NOTE: This is a non-functioning sensor. diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.resx index 11153161..f6705fbd 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.resx @@ -901,6 +901,9 @@ Please configure an executor or your command will not run. Category + + GPU Device + Counter @@ -1166,7 +1169,7 @@ Currently takes the volume of your default device. Dummy sensor for testing purposes, sends a random integer value between 0 and 100. - Provides the current load of the first GPU as a percentage. + Provides the current load of the selected GPU as a percentage, or the average load of all GPUs. NOTE: This is a non-functioning sensor. diff --git a/src/HASS.Agent/HASS.Agent/Settings/StoredSensors.cs b/src/HASS.Agent/HASS.Agent/Settings/StoredSensors.cs index a8db9bef..baae8529 100644 --- a/src/HASS.Agent/HASS.Agent/Settings/StoredSensors.cs +++ b/src/HASS.Agent/HASS.Agent/Settings/StoredSensors.cs @@ -154,7 +154,7 @@ internal static AbstractSingleValueSensor ConvertConfiguredToAbstractSingleValue abstractSensor = new CurrentVolumeSensor(sensor.UpdateInterval, sensor.EntityName, sensor.Name, sensor.Id.ToString(), sensor.AdvancedSettings); break; case SensorType.GpuLoadSensor: - abstractSensor = new GpuLoadSensor(sensor.UpdateInterval, sensor.EntityName, sensor.Name, sensor.Id.ToString(), sensor.AdvancedSettings); + abstractSensor = new GpuLoadSensor(sensor.Query, sensor.UpdateInterval, sensor.EntityName, sensor.Name, sensor.Id.ToString(), sensor.AdvancedSettings); break; case SensorType.GpuTemperatureSensor: abstractSensor = new GpuTemperatureSensor(sensor.UpdateInterval, sensor.EntityName, sensor.Name, sensor.Id.ToString(), sensor.AdvancedSettings); From 1bf63d816da45aa829f8191a641fd7d2f16a5c8a Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 16 Jun 2026 13:17:22 +0100 Subject: [PATCH 02/15] Rename CbNetworkCard to CbSetting1 to match form's existing convention --- .../Forms/Sensors/SensorsMod.Designer.cs | 42 +++++++++---------- .../HASS.Agent/Forms/Sensors/SensorsMod.cs | 38 ++++++++--------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.Designer.cs b/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.Designer.cs index e2070687..97e9b08c 100644 --- a/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.Designer.cs +++ b/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.Designer.cs @@ -67,7 +67,7 @@ private void InitializeComponent() LblSpecificClient = new Label(); BtnTest = new Syncfusion.WinForms.Controls.SfButton(); BtnAdvSettings = new Syncfusion.WinForms.Controls.SfButton(); - CbNetworkCard = new ComboBox(); + CbSetting1 = new ComboBox(); NumRound = new Syncfusion.Windows.Forms.Tools.NumericUpDownExt(); LblDigits = new Label(); CbApplyRounding = new CheckBox(); @@ -547,24 +547,24 @@ private void InitializeComponent() BtnAdvSettings.Visible = false; BtnAdvSettings.Click += BtnAdvSettings_Click; // - // CbNetworkCard - // - CbNetworkCard.AccessibleDescription = "List of available network cards."; - CbNetworkCard.AccessibleName = "Network cards"; - CbNetworkCard.AccessibleRole = AccessibleRole.DropList; - CbNetworkCard.BackColor = Color.FromArgb(63, 63, 70); - CbNetworkCard.DrawMode = DrawMode.OwnerDrawFixed; - CbNetworkCard.DropDownHeight = 300; - CbNetworkCard.DropDownStyle = ComboBoxStyle.DropDownList; - CbNetworkCard.Font = new Font("Segoe UI", 9.75F, FontStyle.Regular, GraphicsUnit.Point); - CbNetworkCard.ForeColor = Color.FromArgb(241, 241, 241); - CbNetworkCard.FormattingEnabled = true; - CbNetworkCard.IntegralHeight = false; - CbNetworkCard.Location = new Point(566, 338); - CbNetworkCard.Name = "CbNetworkCard"; - CbNetworkCard.Size = new Size(328, 26); - CbNetworkCard.TabIndex = 4; - CbNetworkCard.Visible = false; + // CbSetting1 + // + CbSetting1.AccessibleDescription = "List of available options for the selected setting."; + CbSetting1.AccessibleName = "Setting options"; + CbSetting1.AccessibleRole = AccessibleRole.DropList; + CbSetting1.BackColor = Color.FromArgb(63, 63, 70); + CbSetting1.DrawMode = DrawMode.OwnerDrawFixed; + CbSetting1.DropDownHeight = 300; + CbSetting1.DropDownStyle = ComboBoxStyle.DropDownList; + CbSetting1.Font = new Font("Segoe UI", 9.75F, FontStyle.Regular, GraphicsUnit.Point); + CbSetting1.ForeColor = Color.FromArgb(241, 241, 241); + CbSetting1.FormattingEnabled = true; + CbSetting1.IntegralHeight = false; + CbSetting1.Location = new Point(566, 338); + CbSetting1.Name = "CbSetting1"; + CbSetting1.Size = new Size(328, 26); + CbSetting1.TabIndex = 4; + CbSetting1.Visible = false; // // NumRound // @@ -693,7 +693,7 @@ private void InitializeComponent() Controls.Add(TbFriendlyName); Controls.Add(LblDigits); Controls.Add(NumRound); - Controls.Add(CbNetworkCard); + Controls.Add(CbSetting1); Controls.Add(BtnTest); Controls.Add(LblSpecificClient); Controls.Add(LblService); @@ -782,7 +782,7 @@ private void InitializeComponent() private ColumnHeader ClmId; private Syncfusion.WinForms.Controls.SfButton BtnTest; private Syncfusion.WinForms.Controls.SfButton BtnAdvSettings; - private ComboBox CbNetworkCard; + private ComboBox CbSetting1; private Syncfusion.Windows.Forms.Tools.NumericUpDownExt NumRound; private Label LblDigits; internal CheckBox CbApplyRounding; diff --git a/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs b/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs index 59518055..4d2989b1 100644 --- a/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs +++ b/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs @@ -82,7 +82,7 @@ private void BindListViewTheme() LvSensors.DrawColumnHeader += ListViewTheme.DrawColumnHeader; } - private void BindComboBoxTheme() => CbNetworkCard.DrawItem += ComboBoxTheme.DrawDictionaryStringStringItem; + private void BindComboBoxTheme() => CbSetting1.DrawItem += ComboBoxTheme.DrawDictionaryStringStringItem; private void SensorMod_Load(object sender, EventArgs e) { @@ -120,7 +120,7 @@ private void SensorMod_Load(object sender, EventArgs e) CbIgnoreAvailability.CheckedChanged += CbIgnoreAvailability_CheckedChanged; // load in gui - CbNetworkCard.DataSource = new BindingSource(_networkCards, null); + CbSetting1.DataSource = new BindingSource(_networkCards, null); // load or set sensor if (Sensor.Id == Guid.Empty) @@ -220,17 +220,17 @@ private void LoadSensor() case SensorType.NetworkSensors: if (_networkCards.ContainsKey(Sensor.Query)) - CbNetworkCard.SelectedItem = new KeyValuePair(Sensor.Query, _networkCards[Sensor.Query]); + CbSetting1.SelectedItem = new KeyValuePair(Sensor.Query, _networkCards[Sensor.Query]); break; case SensorType.GpuLoadSensor: if (_gpuDevices.ContainsKey(Sensor.Query)) - CbNetworkCard.SelectedItem = new KeyValuePair(Sensor.Query, _gpuDevices[Sensor.Query]); + CbSetting1.SelectedItem = new KeyValuePair(Sensor.Query, _gpuDevices[Sensor.Query]); break; case SensorType.InternalDeviceSensor: if (_internalSensors.ContainsKey(Sensor.Query)) - CbNetworkCard.SelectedItem = new KeyValuePair(Sensor.Query, _internalSensors[Sensor.Query]); + CbSetting1.SelectedItem = new KeyValuePair(Sensor.Query, _internalSensors[Sensor.Query]); break; case SensorType.WindowStateSensor: @@ -324,17 +324,17 @@ private bool SetType(bool setDefaultValues = true) break; case SensorType.NetworkSensors: - CbNetworkCard.DataSource = new BindingSource(_networkCards, null); + CbSetting1.DataSource = new BindingSource(_networkCards, null); SetNetworkGui(); break; case SensorType.GpuLoadSensor: - CbNetworkCard.DataSource = new BindingSource(_gpuDevices, null); + CbSetting1.DataSource = new BindingSource(_gpuDevices, null); SetGpuGui(); break; case SensorType.InternalDeviceSensor: - CbNetworkCard.DataSource = new BindingSource(_internalSensors, null); + CbSetting1.DataSource = new BindingSource(_internalSensors, null); SetInternalSensorGui(); break; @@ -514,7 +514,7 @@ private void SetNetworkGui() LblSetting1.Text = Languages.SensorsMod_LblSetting1_Network; LblSetting1.Visible = true; - CbNetworkCard.Visible = true; + CbSetting1.Visible = true; })); } @@ -530,7 +530,7 @@ private void SetGpuGui() LblSetting1.Text = Languages.SensorsMod_LblSetting1_Gpu; LblSetting1.Visible = true; - CbNetworkCard.Visible = true; + CbSetting1.Visible = true; })); } @@ -546,7 +546,7 @@ private void SetInternalSensorGui() LblSetting1.Text = Languages.SensorsMod_LblSetting1_InternalSensor; LblSetting1.Visible = true; - CbNetworkCard.Visible = true; + CbSetting1.Visible = true; })); } @@ -594,7 +594,7 @@ private void SetEmptyGui() { LblSetting1.Visible = false; - CbNetworkCard.Visible = false; + CbSetting1.Visible = false; TbSetting1.Text = string.Empty; TbSetting1.Visible = false; @@ -813,30 +813,30 @@ private void BtnStore_Click(object sender, EventArgs e) case SensorType.NetworkSensors: Sensor.Query = "*"; - if (CbNetworkCard.SelectedItem != null) + if (CbSetting1.SelectedItem != null) { - var item = (KeyValuePair)CbNetworkCard.SelectedItem; + var item = (KeyValuePair)CbSetting1.SelectedItem; Sensor.Query = item.Key; } break; case SensorType.GpuLoadSensor: Sensor.Query = "*"; - if (CbNetworkCard.SelectedItem != null) + if (CbSetting1.SelectedItem != null) { - var item = (KeyValuePair)CbNetworkCard.SelectedItem; + var item = (KeyValuePair)CbSetting1.SelectedItem; Sensor.Query = item.Key; } break; case SensorType.InternalDeviceSensor: - if (CbNetworkCard.SelectedItem != null) + if (CbSetting1.SelectedItem != null) { - var item = (KeyValuePair)CbNetworkCard.SelectedItem; + var item = (KeyValuePair)CbSetting1.SelectedItem; if (item.Value == Languages.SensorsMod_None) { MessageBoxAdv.Show(this, Languages.SensorsMod_BtnStore_MessageBox1, Variables.MessageBoxTitle, MessageBoxButtons.OK, MessageBoxIcon.Error); - ActiveControl = CbNetworkCard; + ActiveControl = CbSetting1; return; } From 0baf8319b59efabe66f531e78ed64880cb05f40c Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 16 Jun 2026 13:56:22 +0100 Subject: [PATCH 03/15] Added unit test projects --- src/HASS.Agent.sln | 53 ++++++++++++++++++- .../AssemblyInfo.cs | 3 ++ .../HASS.Agent.Shared/AssemblyInfo.cs | 3 ++ src/HASS.Agent/HASS.Agent/AssemblyInfo.cs | 3 ++ ...S.Agent.Satellite.Service.UnitTests.csproj | 27 ++++++++++ .../HASS.Agent.Shared.UnitTests.csproj | 27 ++++++++++ .../HASS.Agent.UnitTests.csproj | 31 +++++++++++ 7 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 src/HASS.Agent/HASS.Agent.Satellite.Service/AssemblyInfo.cs create mode 100644 src/HASS.Agent/HASS.Agent.Shared/AssemblyInfo.cs create mode 100644 src/HASS.Agent/HASS.Agent/AssemblyInfo.cs create mode 100644 src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/HASS.Agent.Satellite.Service.UnitTests.csproj create mode 100644 src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HASS.Agent.Shared.UnitTests.csproj create mode 100644 src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/HASS.Agent.UnitTests.csproj diff --git a/src/HASS.Agent.sln b/src/HASS.Agent.sln index 4317c9ab..8b1d8067 100644 --- a/src/HASS.Agent.sln +++ b/src/HASS.Agent.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32319.34 +# Visual Studio Version 18 +VisualStudioVersion = 18.4.11605.240 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HASS.Agent", "HASS.Agent\HASS.Agent\HASS.Agent.csproj", "{6CB1FA4F-4798-4939-B8DF-7E908FD23242}" ProjectSection(ProjectDependencies) = postProject @@ -17,6 +17,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HASS.Agent.UnitTests", "HASS.Agent\UnitTests\HASS.Agent.UnitTests\HASS.Agent.UnitTests.csproj", "{D5796F7C-061A-473D-B7BA-B4F7D15E50D3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HASS.Agent.Shared.UnitTests", "HASS.Agent\UnitTests\HASS.Agent.Shared.UnitTests\HASS.Agent.Shared.UnitTests.csproj", "{4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HASS.Agent.Satellite.Service.UnitTests", "HASS.Agent\UnitTests\HASS.Agent.Satellite.Service.UnitTests\HASS.Agent.Satellite.Service.UnitTests.csproj", "{098DFCC8-7033-4006-97C8-4DED9920B0F7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UnitTests", "UnitTests", "{320255ED-7D01-4E7E-B9CE-6147FFD61A82}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -63,10 +71,51 @@ Global {C4138592-B05C-4B3D-B55C-A1337E97CDDD}.Release|x64.Build.0 = Release|Any CPU {C4138592-B05C-4B3D-B55C-A1337E97CDDD}.Release|x86.ActiveCfg = Release|x86 {C4138592-B05C-4B3D-B55C-A1337E97CDDD}.Release|x86.Build.0 = Release|x86 + {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Debug|x64.ActiveCfg = Debug|Any CPU + {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Debug|x64.Build.0 = Debug|Any CPU + {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Debug|x86.ActiveCfg = Debug|Any CPU + {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Debug|x86.Build.0 = Debug|Any CPU + {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Release|Any CPU.Build.0 = Release|Any CPU + {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Release|x64.ActiveCfg = Release|Any CPU + {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Release|x64.Build.0 = Release|Any CPU + {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Release|x86.ActiveCfg = Release|Any CPU + {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Release|x86.Build.0 = Release|Any CPU + {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Debug|x64.ActiveCfg = Debug|Any CPU + {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Debug|x64.Build.0 = Debug|Any CPU + {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Debug|x86.ActiveCfg = Debug|Any CPU + {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Debug|x86.Build.0 = Debug|Any CPU + {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Release|Any CPU.Build.0 = Release|Any CPU + {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Release|x64.ActiveCfg = Release|Any CPU + {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Release|x64.Build.0 = Release|Any CPU + {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Release|x86.ActiveCfg = Release|Any CPU + {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Release|x86.Build.0 = Release|Any CPU + {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Debug|x64.ActiveCfg = Debug|Any CPU + {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Debug|x64.Build.0 = Debug|Any CPU + {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Debug|x86.ActiveCfg = Debug|Any CPU + {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Debug|x86.Build.0 = Debug|Any CPU + {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Release|Any CPU.Build.0 = Release|Any CPU + {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Release|x64.ActiveCfg = Release|Any CPU + {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Release|x64.Build.0 = Release|Any CPU + {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Release|x86.ActiveCfg = Release|Any CPU + {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D5796F7C-061A-473D-B7BA-B4F7D15E50D3} = {320255ED-7D01-4E7E-B9CE-6147FFD61A82} + {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D} = {320255ED-7D01-4E7E-B9CE-6147FFD61A82} + {098DFCC8-7033-4006-97C8-4DED9920B0F7} = {320255ED-7D01-4E7E-B9CE-6147FFD61A82} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C17243F4-9756-47EF-9AA8-86C64B7582A6} VisualSVNWorkingCopyRoot = . diff --git a/src/HASS.Agent/HASS.Agent.Satellite.Service/AssemblyInfo.cs b/src/HASS.Agent/HASS.Agent.Satellite.Service/AssemblyInfo.cs new file mode 100644 index 00000000..90b85149 --- /dev/null +++ b/src/HASS.Agent/HASS.Agent.Satellite.Service/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("HASS.Agent.Satellite.Service.UnitTests")] diff --git a/src/HASS.Agent/HASS.Agent.Shared/AssemblyInfo.cs b/src/HASS.Agent/HASS.Agent.Shared/AssemblyInfo.cs new file mode 100644 index 00000000..6941e95f --- /dev/null +++ b/src/HASS.Agent/HASS.Agent.Shared/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("HASS.Agent.Shared.UnitTests")] diff --git a/src/HASS.Agent/HASS.Agent/AssemblyInfo.cs b/src/HASS.Agent/HASS.Agent/AssemblyInfo.cs new file mode 100644 index 00000000..3b6baad3 --- /dev/null +++ b/src/HASS.Agent/HASS.Agent/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("HASS.Agent.UnitTests")] diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/HASS.Agent.Satellite.Service.UnitTests.csproj b/src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/HASS.Agent.Satellite.Service.UnitTests.csproj new file mode 100644 index 00000000..08b6c6f1 --- /dev/null +++ b/src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/HASS.Agent.Satellite.Service.UnitTests.csproj @@ -0,0 +1,27 @@ + + + + net8.0-windows10.0.22621.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HASS.Agent.Shared.UnitTests.csproj b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HASS.Agent.Shared.UnitTests.csproj new file mode 100644 index 00000000..dcc7cf3c --- /dev/null +++ b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HASS.Agent.Shared.UnitTests.csproj @@ -0,0 +1,27 @@ + + + + net8.0-windows10.0.22621.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/HASS.Agent.UnitTests.csproj b/src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/HASS.Agent.UnitTests.csproj new file mode 100644 index 00000000..43a52583 --- /dev/null +++ b/src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/HASS.Agent.UnitTests.csproj @@ -0,0 +1,31 @@ + + + + net8.0-windows10.0.22621.0 + latest + enable + enable + false + + false + + + + + + + + + + + + + + + + + + + From e6a2f96213b563f8f45c7e04d956a7f790e710af Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 16 Jun 2026 14:00:45 +0100 Subject: [PATCH 04/15] Added Tests for GPU Load Sensor --- .../SingleValue/GpuLoadSensor.cs | 54 +++--- .../Settings/StoredSensorsTests.cs | 49 ++++++ .../SingleValue/GpuLoadSensorTests.cs | 156 ++++++++++++++++++ .../Settings/StoredSensorsTests.cs | 71 ++++++++ 4 files changed, 310 insertions(+), 20 deletions(-) create mode 100644 src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/Settings/StoredSensorsTests.cs create mode 100644 src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs create mode 100644 src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/Settings/StoredSensorsTests.cs diff --git a/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs b/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs index 17543c37..0c0d7d40 100644 --- a/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs +++ b/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs @@ -63,15 +63,7 @@ public float GetGPUUsage() { try { - var perGpuUsage = GetPerGpuUsage(); - if (perGpuUsage.Count == 0) - return 0; - - if (_useSpecificGpu) - return perGpuUsage.TryGetValue(GpuId, out var usage) ? usage : 0; - - // 'all' selected: average across every detected gpu, instead of summing them together - return perGpuUsage.Values.Average(); + return SelectGpuUsage(GetPerGpuUsage(), GpuId, _useSpecificGpu); } catch { @@ -79,6 +71,21 @@ public float GetGPUUsage() } } + /// + /// Pure selection logic: picks a single GPU's usage, or averages across all of them when none is specified + /// + internal static float SelectGpuUsage(IReadOnlyDictionary perGpuUsage, string gpuId, bool useSpecificGpu) + { + if (perGpuUsage.Count == 0) + return 0; + + if (useSpecificGpu) + return perGpuUsage.TryGetValue(gpuId, out var usage) ? usage : 0; + + // 'all' selected: average across every detected gpu, instead of summing them together + return perGpuUsage.Values.Average(); + } + /// /// Reads the 'GPU Engine' 3D counters and sums them per physical GPU (identified by its 'phys_n' index) /// @@ -102,7 +109,7 @@ private static Dictionary GetPerGpuUsage() /// /// Extracts the physical adapter index (the 'n' in 'phys_n') from a GPU Engine counter instance name /// - private static string GetPhysicalGpuIndex(string instanceName) + internal static string GetPhysicalGpuIndex(string instanceName) { var match = PhysicalGpuIndexRegex.Match(instanceName); return match.Success ? match.Groups[1].Value : "0"; @@ -113,8 +120,6 @@ private static string GetPhysicalGpuIndex(string instanceName) /// public static Dictionary GetAvailableGpus() { - var gpus = new Dictionary(); - try { var category = new PerformanceCounterCategory("GPU Engine"); @@ -124,18 +129,27 @@ public static Dictionary GetAvailableGpus() .Distinct() .OrderBy(x => int.TryParse(x, out var parsed) ? parsed : int.MaxValue); - var gpuNames = GetGpuNamesByIndex(); - - foreach (var physicalIndex in physicalIndexes) - { - gpus[physicalIndex] = gpuNames.TryGetValue(physicalIndex, out var gpuName) && !string.IsNullOrWhiteSpace(gpuName) - ? gpuName - : $"GPU {physicalIndex}"; - } + return BuildGpuLabels(physicalIndexes, GetGpuNamesByIndex()); } catch { // best effort, no gpu's found + return new Dictionary(); + } + } + + /// + /// Pure labeling logic: pairs each physical GPU index with a friendly name, falling back to a generic label when none is known + /// + internal static Dictionary BuildGpuLabels(IEnumerable physicalIndexes, IReadOnlyDictionary gpuNames) + { + var gpus = new Dictionary(); + + foreach (var physicalIndex in physicalIndexes) + { + gpus[physicalIndex] = gpuNames.TryGetValue(physicalIndex, out var gpuName) && !string.IsNullOrWhiteSpace(gpuName) + ? gpuName + : $"GPU {physicalIndex}"; } return gpus; diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/Settings/StoredSensorsTests.cs b/src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/Settings/StoredSensorsTests.cs new file mode 100644 index 00000000..205a6031 --- /dev/null +++ b/src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/Settings/StoredSensorsTests.cs @@ -0,0 +1,49 @@ +using HASS.Agent.Satellite.Service.Settings; +using HASS.Agent.Shared.Enums; +using HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.SingleValue; +using HASS.Agent.Shared.Models.Config; + +namespace HASS.Agent.Satellite.Service.UnitTests.Settings; + +public class StoredSensorsTests +{ + [TestCase("*")] + [TestCase("0")] + [TestCase("1")] + public void ConvertConfiguredToAbstractSingleValue_GpuLoadSensor_PassesQueryThroughAsGpuId(string query) + { + var configured = new ConfiguredSensor + { + Type = SensorType.GpuLoadSensor, + Id = Guid.NewGuid(), + EntityName = "gpuload", + Name = "GPU Load", + UpdateInterval = 30, + Query = query + }; + + var sensor = StoredSensors.ConvertConfiguredToAbstractSingleValue(configured); + + Assert.That(sensor, Is.InstanceOf()); + Assert.That(((GpuLoadSensor)sensor!).GpuId, Is.EqualTo(query)); + } + + [Test] + public void ConvertConfiguredToAbstractSingleValue_GpuLoadSensor_EmptyQuery_DefaultsToAllGpus() + { + // sensors configured before per-gpu selection existed have an empty Query - they should keep working as 'all' + var configured = new ConfiguredSensor + { + Type = SensorType.GpuLoadSensor, + Id = Guid.NewGuid(), + EntityName = "gpuload", + Name = "GPU Load", + UpdateInterval = 30, + Query = string.Empty + }; + + var sensor = StoredSensors.ConvertConfiguredToAbstractSingleValue(configured); + + Assert.That(((GpuLoadSensor)sensor!).GpuId, Is.EqualTo("*")); + } +} diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs new file mode 100644 index 00000000..30136350 --- /dev/null +++ b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs @@ -0,0 +1,156 @@ +using HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.SingleValue; + +namespace HASS.Agent.Shared.UnitTests.HomeAssistant.Sensors.GeneralSensors.SingleValue; + +public class GpuLoadSensorTests +{ + [Test] + public void DefaultConstructor_SelectsAllGpus() + { + var sensor = new GpuLoadSensor(); + + Assert.That(sensor.GpuId, Is.EqualTo("*")); + } + + [TestCase(null)] + [TestCase("")] + public void Constructor_NullOrEmptyGpuId_NormalizesToAllGpus(string? gpuId) + { + var sensor = new GpuLoadSensor(gpuId!); + + Assert.That(sensor.GpuId, Is.EqualTo("*")); + } + + [TestCase("0")] + [TestCase("1")] + [TestCase("12")] + public void Constructor_SpecificGpuId_IsPreserved(string gpuId) + { + var sensor = new GpuLoadSensor(gpuId); + + Assert.That(sensor.GpuId, Is.EqualTo(gpuId)); + } + + [Test] + public void SelectGpuUsage_NoGpusDetected_ReturnsZero() + { + var result = GpuLoadSensor.SelectGpuUsage(new Dictionary(), "*", useSpecificGpu: false); + + Assert.That(result, Is.EqualTo(0)); + } + + [Test] + public void SelectGpuUsage_AllGpusSelected_AveragesInsteadOfSumming() + { + // this is the actual bug being fixed: two gpus at 20% and 80% load should report 50%, not 100% + var perGpuUsage = new Dictionary { ["0"] = 20f, ["1"] = 80f }; + + var result = GpuLoadSensor.SelectGpuUsage(perGpuUsage, "*", useSpecificGpu: false); + + Assert.That(result, Is.EqualTo(50f)); + } + + [Test] + public void SelectGpuUsage_AllGpusSelected_SingleGpuUnaffected() + { + var perGpuUsage = new Dictionary { ["0"] = 42f }; + + var result = GpuLoadSensor.SelectGpuUsage(perGpuUsage, "*", useSpecificGpu: false); + + Assert.That(result, Is.EqualTo(42f)); + } + + [Test] + public void SelectGpuUsage_SpecificGpuSelected_ReturnsOnlyThatGpusValue() + { + var perGpuUsage = new Dictionary { ["0"] = 20f, ["1"] = 80f }; + + var result = GpuLoadSensor.SelectGpuUsage(perGpuUsage, "1", useSpecificGpu: true); + + Assert.That(result, Is.EqualTo(80f)); + } + + [Test] + public void SelectGpuUsage_SpecificGpuNoLongerPresent_ReturnsZero() + { + var perGpuUsage = new Dictionary { ["0"] = 20f }; + + var result = GpuLoadSensor.SelectGpuUsage(perGpuUsage, "5", useSpecificGpu: true); + + Assert.That(result, Is.EqualTo(0)); + } + + [TestCase("pid_1234_luid_0x00000000_0x0000C5F2_phys_0_eng_0_engtype_3D", "0")] + [TestCase("pid_4080_luid_0x00000000_0x0000C8F1_phys_1_eng_2_engtype_3D", "1")] + [TestCase("pid_999_luid_0x00000000_0x00001234_phys_12_eng_3_engtype_3D", "12")] + public void GetPhysicalGpuIndex_ParsesPhysIndexFromInstanceName(string instanceName, string expectedIndex) + { + var result = GpuLoadSensor.GetPhysicalGpuIndex(instanceName); + + Assert.That(result, Is.EqualTo(expectedIndex)); + } + + [Test] + public void GetPhysicalGpuIndex_UnrecognizedFormat_FallsBackToZero() + { + var result = GpuLoadSensor.GetPhysicalGpuIndex("some_unexpected_counter_instance_name"); + + Assert.That(result, Is.EqualTo("0")); + } + + [Test] + public void BuildGpuLabels_KnownGpu_UsesFriendlyName() + { + var names = new Dictionary { ["0"] = "NVIDIA GeForce RTX 3070" }; + + var result = GpuLoadSensor.BuildGpuLabels(new[] { "0" }, names); + + Assert.That(result["0"], Is.EqualTo("NVIDIA GeForce RTX 3070")); + } + + [Test] + public void BuildGpuLabels_UnknownGpu_FallsBackToGenericLabel() + { + var names = new Dictionary { ["0"] = "NVIDIA GeForce RTX 3070" }; + + var result = GpuLoadSensor.BuildGpuLabels(new[] { "0", "1" }, names); + + Assert.That(result["1"], Is.EqualTo("GPU 1")); + } + + [Test] + public void BuildGpuLabels_BlankName_FallsBackToGenericLabel() + { + var names = new Dictionary { ["0"] = " " }; + + var result = GpuLoadSensor.BuildGpuLabels(new[] { "0" }, names); + + Assert.That(result["0"], Is.EqualTo("GPU 0")); + } + + [Test] + public void BuildGpuLabels_NoIndexes_ReturnsEmpty() + { + var result = GpuLoadSensor.BuildGpuLabels(System.Array.Empty(), new Dictionary()); + + Assert.That(result, Is.Empty); + } + + [Test] + public void GetGPUUsage_NeverThrowsAndReturnsNonNegativeValue() + { + var sensor = new GpuLoadSensor(); + + float result = 0; + Assert.DoesNotThrow(() => result = sensor.GetGPUUsage()); + Assert.That(result, Is.GreaterThanOrEqualTo(0)); + } + + [Test] + public void GetAvailableGpus_NeverThrowsAndReturnsADictionary() + { + Dictionary? result = null; + Assert.DoesNotThrow(() => result = GpuLoadSensor.GetAvailableGpus()); + Assert.That(result, Is.Not.Null); + } +} diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/Settings/StoredSensorsTests.cs b/src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/Settings/StoredSensorsTests.cs new file mode 100644 index 00000000..aa64b77b --- /dev/null +++ b/src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/Settings/StoredSensorsTests.cs @@ -0,0 +1,71 @@ +using HASS.Agent.Settings; +using HASS.Agent.Shared.Enums; +using HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.SingleValue; +using HASS.Agent.Shared.Models.Config; + +namespace HASS.Agent.UnitTests.Settings; + +public class StoredSensorsTests +{ + [TestCase("*")] + [TestCase("0")] + [TestCase("1")] + public void ConvertConfiguredToAbstractSingleValue_GpuLoadSensor_PassesQueryThroughAsGpuId(string query) + { + var configured = new ConfiguredSensor + { + Type = SensorType.GpuLoadSensor, + Id = Guid.NewGuid(), + EntityName = "gpuload", + Name = "GPU Load", + UpdateInterval = 30, + Query = query + }; + + var sensor = StoredSensors.ConvertConfiguredToAbstractSingleValue(configured); + + Assert.That(sensor, Is.InstanceOf()); + Assert.That(((GpuLoadSensor)sensor).GpuId, Is.EqualTo(query)); + } + + [Test] + public void ConvertConfiguredToAbstractSingleValue_GpuLoadSensor_EmptyQuery_DefaultsToAllGpus() + { + // sensors configured before per-gpu selection existed have an empty Query - they should keep working as 'all' + var configured = new ConfiguredSensor + { + Type = SensorType.GpuLoadSensor, + Id = Guid.NewGuid(), + EntityName = "gpuload", + Name = "GPU Load", + UpdateInterval = 30, + Query = string.Empty + }; + + var sensor = StoredSensors.ConvertConfiguredToAbstractSingleValue(configured); + + Assert.That(((GpuLoadSensor)sensor).GpuId, Is.EqualTo("*")); + } + + [Test] + public void ConvertConfiguredToAbstractSingleValue_GpuLoadSensor_PassesThroughOtherFields() + { + var id = Guid.NewGuid(); + var configured = new ConfiguredSensor + { + Type = SensorType.GpuLoadSensor, + Id = id, + EntityName = "my_gpu", + Name = "My GPU", + UpdateInterval = 45, + Query = "0" + }; + + var sensor = StoredSensors.ConvertConfiguredToAbstractSingleValue(configured); + + Assert.That(sensor.Id, Is.EqualTo(id.ToString())); + Assert.That(sensor.EntityName, Is.EqualTo("my_gpu")); + Assert.That(sensor.Name, Is.EqualTo("My GPU")); + Assert.That(sensor.UpdateIntervalSeconds, Is.EqualTo(45)); + } +} From 81da1bc05d23dbee292b11c7669571b2a65589a5 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 16 Jun 2026 15:13:56 +0100 Subject: [PATCH 05/15] Added translation updates --- .../HASS.Agent/Resources/Localization/Languages.de.resx | 5 ++++- .../HASS.Agent/Resources/Localization/Languages.es.resx | 5 ++++- .../HASS.Agent/Resources/Localization/Languages.fr.resx | 5 ++++- .../HASS.Agent/Resources/Localization/Languages.nl.resx | 5 ++++- .../HASS.Agent/Resources/Localization/Languages.pl.resx | 5 ++++- .../HASS.Agent/Resources/Localization/Languages.pt-br.resx | 5 ++++- .../HASS.Agent/Resources/Localization/Languages.ru.resx | 5 ++++- .../HASS.Agent/Resources/Localization/Languages.sl.resx | 5 ++++- .../HASS.Agent/Resources/Localization/Languages.tr.resx | 5 ++++- 9 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.de.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.de.resx index c7aeedd9..855f46e9 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.de.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.de.resx @@ -1902,6 +1902,9 @@ konfiguriere bitte einen Exekutor oder dein Befehl wird nicht ausgeführt Kategorie + + Grafikkarte + Zähler @@ -2174,7 +2177,7 @@ Nimmt derzeit die Lautstärke deines Standardgeräts. Dummy-Sensor für Testzwecke, sendet einen zufälligen ganzzahligen Wert zwischen 0 und 100. - Liefert die aktuelle Auslastung der ersten GPU in Prozent. + Liefert die aktuelle Auslastung der ausgewählten GPU in Prozent oder die durchschnittliche Auslastung aller GPUs. HINWEIS: Dieser Sensor funktioniert nicht. diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.es.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.es.resx index d535ac12..fe3b971c 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.es.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.es.resx @@ -1795,6 +1795,9 @@ configure un ejecutor o su comando no se ejecutará categoría + + Tarjeta gráfica + mostrador @@ -2060,7 +2063,7 @@ Actualmente toma el volumen de su dispositivo predeterminado. Sensor ficticio para fines de prueba, envía un valor entero aleatorio entre 0 y 100. - Proporciona la carga actual de la primera GPU como porcentaje. + Proporciona la carga actual de la GPU seleccionada como porcentaje, o la carga promedio de todas las GPU. NOTA: Este sensor no funciona. diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.fr.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.fr.resx index cc194c4c..83b9feab 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.fr.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.fr.resx @@ -1811,6 +1811,9 @@ veuillez configurer un interpréteur de commandes ou votre commande ne fonctionn Catégorie + + Carte graphique + Compteur @@ -2081,7 +2084,7 @@ Indique le volume de votre appareil par défaut. Capteur factice à des fins de test, envoie une valeur entière aléatoire entre 0 et 100. - Fournit la charge actuelle du premier GPU sous forme de pourcentage. + Fournit la charge actuelle du GPU sélectionné sous forme de pourcentage, ou la charge moyenne de tous les GPU. REMARQUE : Ce capteur est non fonctionnel. diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.nl.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.nl.resx index 71bc2a11..73dc7fbc 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.nl.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.nl.resx @@ -1800,6 +1800,9 @@ configureer een executor, anders kan het commando niet uitvoeren categorie + + Videokaart + teller @@ -2071,7 +2074,7 @@ Pakt momenteel het volume van je standaardapparaat. Dummy sensor om te testen, stuurt een willekeurig getal tussen 0 en 100. - Geeft de huidige belasting van de eerste GPU als een percentage. + Geeft de huidige belasting van de geselecteerde GPU als percentage, of de gemiddelde belasting van alle GPU's. OPMERKING: Dit is een niet-functionerende sensor. diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pl.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pl.resx index 4c89d396..09eb39d7 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pl.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pl.resx @@ -1889,6 +1889,9 @@ skonfiguruj środowisko wykonania, inaczej twoje polecenie się nie uruchomi kategoria + + Karta graficzna + licznik @@ -2164,7 +2167,7 @@ Obecnie zwraca głośność domyślnego urządzenia. Czujnik atrapa dla testów, wysyła losowe wartości całkowite (int) w przedziale od 0 do 100. - Zwraca obciążenie pierwszego GPU w procentach. + Zwraca obciążenie wybranego GPU w procentach lub średnie obciążenie wszystkich GPU. UWAGA: Ten czujnik nie działa. diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pt-br.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pt-br.resx index 7f6814b9..e81a3b15 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pt-br.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pt-br.resx @@ -2216,7 +2216,7 @@ Certifique-se de que os serviços de localização do Windows estejam ativados! Dependendo da sua versão do Windows, isso pode ser encontrado no novo painel de controle -> 'privacidade e segurança' -> 'localização'. - Fornece a carga atual da GPU como uma porcentagem. + Fornece a carga atual da GPU selecionada como uma porcentagem, ou a carga média de todas as GPUs. NOTA: Este sensor não está a funcionar. @@ -2413,6 +2413,9 @@ Essa é uma solicitação cara, portanto, o intervalo recomendado é de 15 minut categoria + + Placa de vídeo + placa de rede diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.ru.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.ru.resx index 8d089b20..dd354ff0 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.ru.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.ru.resx @@ -1844,6 +1844,9 @@ Home Assistant version: {0} категория + + Видеокарта + счетчик @@ -2115,7 +2118,7 @@ Home Assistant version: {0} Фиктивный датчик для целей тестирования отправляет случайное целое значение в диапазоне от 0 до 100. - Показывает текущую загрузку первого графического процессора в процентах. + Показывает текущую загрузку выбранного графического процессора в процентах или среднюю загрузку всех графических процессоров. ПРИМЕЧАНИЕ: Это неработающий датчик. diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.sl.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.sl.resx index deda2f87..f9f43406 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.sl.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.sl.resx @@ -2294,7 +2294,7 @@ Prepričajte se, da so lokacijske storitve w Windows omogočene! Glede na verzijo Windows-ov se to lahko nahaja v Nadzorna plošča --> Sistem in varnost --> lokacija - Zagotavlja trenutno obremenitev prvega GPU-ja v odstotkih. + Zagotavlja trenutno obremenitev izbranega GPU-ja v odstotkih ali povprečno obremenitev vseh GPU-jev. OPOMBA: To je nedelujoč senzor. @@ -2494,6 +2494,9 @@ To je draga zahteva, zato je priporočeni interval 15 minut (900 sekund). Vendar kategorija + + Grafična kartica + omrežno kartico diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.tr.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.tr.resx index 7edeaab9..954c3f3f 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.tr.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.tr.resx @@ -1628,6 +1628,9 @@ CTRL gibi daha fazla tuşa ve/veya değiştiriciye ihtiyacınız varsa, Multiple Kategori + + Grafik kartı + Tezgah @@ -1852,7 +1855,7 @@ CTRL gibi daha fazla tuşa ve/veya değiştiriciye ihtiyacınız varsa, Multiple Test amaçlı kukla sensör, 0 ile 100 arasında rastgele bir tamsayı değeri gönderir. - Yüzde olarak ilk GPU'nun mevcut yükünü sağlar. + Seçilen GPU'nun mevcut yükünü yüzde olarak veya tüm GPU'ların ortalama yükünü sağlar. NOT: Bu, çalışmayan bir sensördür. From 9e8b8dd0f8b375e80d0f4fb3bf50e4dc963aa4c7 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 16 Jun 2026 15:55:19 +0100 Subject: [PATCH 06/15] Added test to check real world data from my machine --- .../SingleValue/GpuLoadSensorTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs index 30136350..df042ee8 100644 --- a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs +++ b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs @@ -153,4 +153,38 @@ public void GetAvailableGpus_NeverThrowsAndReturnsADictionary() Assert.DoesNotThrow(() => result = GpuLoadSensor.GetAvailableGpus()); Assert.That(result, Is.Not.Null); } + + [Test] + [Category("Hardware")] + public void GetAvailableGpus_OnThisMachine_EachGpuReportsAPlausibleLoad() + { + // exercises the real 'GPU Engine' performance counters on whatever machine runs this test, + // rather than synthetic data - skips instead of failing on a machine with no gpu counters + var gpus = GpuLoadSensor.GetAvailableGpus(); + if (gpus.Count == 0) + { + Assert.Ignore("No 'GPU Engine' performance counters were detected on this machine."); + return; + } + + TestContext.Out.WriteLine($"Detected {gpus.Count} GPU(s):"); + + foreach (var (gpuId, gpuName) in gpus) + { + var sensor = new GpuLoadSensor(gpuId); + var usage = sensor.GetGPUUsage(); + var state = sensor.GetState(); + + TestContext.Out.WriteLine($" [{gpuId}] {gpuName}: {state}% (raw value: {usage})"); + + Assert.That(usage, Is.GreaterThanOrEqualTo(0).And.LessThanOrEqualTo(100), + $"GPU '{gpuName}' (id {gpuId}) reported an out-of-range load: {usage}"); + } + + var allGpus = new GpuLoadSensor(); + var averageUsage = allGpus.GetGPUUsage(); + TestContext.Out.WriteLine($" [*] All GPUs (average): {allGpus.GetState()}% (raw value: {averageUsage})"); + + Assert.That(averageUsage, Is.GreaterThanOrEqualTo(0).And.LessThanOrEqualTo(100)); + } } From 9b90f7149f64e14d54e4a5b52c8d83efbe6f413d Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 16 Jun 2026 17:43:10 +0100 Subject: [PATCH 07/15] Changed to using LUID rather than phys_n as windows piles all GPUs in 0 --- .../HASS.Agent.Shared.csproj | 1 + .../SingleValue/GpuLoadSensor.cs | 181 +++++++++++------- .../SingleValue/GpuLoadSensorTests.cs | 134 ++++++++++--- 3 files changed, 216 insertions(+), 100 deletions(-) diff --git a/src/HASS.Agent/HASS.Agent.Shared/HASS.Agent.Shared.csproj b/src/HASS.Agent/HASS.Agent.Shared/HASS.Agent.Shared.csproj index 31336c84..1f1a6f52 100644 --- a/src/HASS.Agent/HASS.Agent.Shared/HASS.Agent.Shared.csproj +++ b/src/HASS.Agent/HASS.Agent.Shared/HASS.Agent.Shared.csproj @@ -46,6 +46,7 @@ + diff --git a/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs b/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs index 0c0d7d40..fedcd724 100644 --- a/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs +++ b/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs @@ -1,13 +1,12 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; -using System.Management; +using System.Runtime.InteropServices; using System.Text.RegularExpressions; -using System.Threading; -using HASS.Agent.Shared.Managers; using HASS.Agent.Shared.Models.HomeAssistant; +using Vanara.PInvoke; +using static Vanara.PInvoke.DXGI; namespace HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.SingleValue; @@ -19,10 +18,17 @@ public class GpuLoadSensor : AbstractSingleValueSensor private const string DefaultName = "gpuload"; private const string AllGpus = "*"; - private static readonly Regex PhysicalGpuIndexRegex = new(@"_phys_(\d+)_", RegexOptions.Compiled); + /// + /// The regex used to extract the adapter luid from a GPU Engine counter instance name (eg. 'luid_0x00000000_0x00016e08_engtype_3D'). + /// + private static readonly Regex AdapterLuidRegex = new(@"luid_(0x[0-9A-Fa-f]+_0x[0-9A-Fa-f]+)", RegexOptions.Compiled); public string GpuId { get; protected set; } private readonly bool _useSpecificGpu; + /// + /// The cached 'GPU Engine' counters. + /// + private readonly Dictionary _engineCounterCache = new(); public GpuLoadSensor(string gpuId = AllGpus, int? updateInterval = null, string entityName = DefaultName, string name = DefaultName, string id = default, string advancedSettings = default) : base(entityName ?? DefaultName, name ?? null, updateInterval ?? 30, id, advancedSettings: advancedSettings) { @@ -54,7 +60,7 @@ public override DiscoveryConfigModel GetAutoDiscoveryConfig() public override string GetState() { - return GetGPUUsage().ToString("#.##", CultureInfo.InvariantCulture); + return GetGPUUsage().ToString("0.##", CultureInfo.InvariantCulture); } public override string GetAttributes() => string.Empty; @@ -82,109 +88,138 @@ internal static float SelectGpuUsage(IReadOnlyDictionary perGpuUs if (useSpecificGpu) return perGpuUsage.TryGetValue(gpuId, out var usage) ? usage : 0; - // 'all' selected: average across every detected gpu, instead of summing them together return perGpuUsage.Values.Average(); } /// - /// Reads the 'GPU Engine' 3D counters and sums them per physical GPU (identified by its 'phys_n' index) + /// Reads the 'GPU Engine' 3D counters and sums them per physical adapter (identified by its luid). + /// Counter objects are kept in across calls (instances come and go as processes start/stop using the gpu) so a still-running process's counter diffs against its previous real reading - only a process seen for the first time needs a one-off throwaway priming read /// - private static Dictionary GetPerGpuUsage() + private Dictionary GetPerGpuUsage() { + // Get the list of current GPU Engine counters var category = new PerformanceCounterCategory("GPU Engine"); - var gpuCounters = category.GetInstanceNames() - .Where(name => name.EndsWith("engtype_3D")) - .SelectMany(name => category.GetCounters(name)) - .Where(counter => counter.CounterName.Equals("Utilization Percentage")) - .ToList(); - - gpuCounters.ForEach(x => { _ = x.NextValue(); }); - Thread.Sleep(10); //TODO(Amadeo): fix this - - return gpuCounters - .GroupBy(x => GetPhysicalGpuIndex(x.InstanceName)) - .ToDictionary(g => g.Key, g => g.Sum(x => x.NextValue())); + var instanceNames = FilterToKnownAdapters( + category.GetInstanceNames().Where(name => name.EndsWith("engtype_3D")), + GetAvailableGpus().Keys + ).ToList(); + + //Remove any stale counters from the cache (eg. a process that was using the GPU but has since exited) + foreach (var staleInstanceName in _engineCounterCache.Keys.Except(instanceNames).ToList()) + { + _engineCounterCache[staleInstanceName].Dispose(); + _engineCounterCache.Remove(staleInstanceName); + } + + //Add any new counters to the cache and do a throwaway read to prime them (otherwise their first real reading will be 0) + var newlySeenCounters = new List(); + foreach (var instanceName in instanceNames) + { + if (_engineCounterCache.ContainsKey(instanceName)) + continue; + + var counter = category.GetCounters(instanceName).FirstOrDefault(c => c.CounterName.Equals("Utilization Percentage")); + if (counter == null) + continue; + + _engineCounterCache[instanceName] = counter; + newlySeenCounters.Add(counter); + } + + newlySeenCounters.ForEach(x => { _ = x.NextValue(); }); + + // Read the current values of all cached counters and aggregate them by adapter + var samples = _engineCounterCache.Select(x => (InstanceName: x.Key, Value: x.Value.NextValue())); + return AggregateUsageByAdapter(samples); } /// - /// Extracts the physical adapter index (the 'n' in 'phys_n') from a GPU Engine counter instance name + /// Extracts the adapter luid (eg. '0x00000000_0x00016e08') from a GPU Engine counter instance name, falling back to the full instance name if it doesn't match the expected format. /// - internal static string GetPhysicalGpuIndex(string instanceName) + /// + /// Lowercased because Windows doesn't consistently capitalize the hex digits across different instance names for the same adapter, and this must match 's output exactly + /// + internal static string GetAdapterLuid(string instanceName) { - var match = PhysicalGpuIndexRegex.Match(instanceName); - return match.Success ? match.Groups[1].Value : "0"; + var match = AdapterLuidRegex.Match(instanceName); + return match.Success ? match.Groups[1].Value.ToLowerInvariant() : instanceName; } /// - /// Enumerates the physical GPUs currently exposing a 'GPU Engine' performance counter, keyed by their physical adapter index + /// Pure filter logic: keeps only the counter instances belonging to a known real adapter, dropping ones from a phantom/virtual adapter (eg. WARP, or an indirect display driver) that doesn't know about. + /// Otherwise an unselectable phantom adapter could still silently skew the 'all gpus' average. /// - public static Dictionary GetAvailableGpus() + internal static IEnumerable FilterToKnownAdapters(IEnumerable instanceNames, IEnumerable knownGpuLuids) { - try - { - var category = new PerformanceCounterCategory("GPU Engine"); - var physicalIndexes = category.GetInstanceNames() - .Where(name => name.EndsWith("engtype_3D")) - .Select(GetPhysicalGpuIndex) - .Distinct() - .OrderBy(x => int.TryParse(x, out var parsed) ? parsed : int.MaxValue); - - return BuildGpuLabels(physicalIndexes, GetGpuNamesByIndex()); - } - catch - { - // best effort, no gpu's found - return new Dictionary(); - } + var knownSet = knownGpuLuids is ISet set ? set : new HashSet(knownGpuLuids); + return instanceNames.Where(name => knownSet.Contains(GetAdapterLuid(name))); } /// - /// Pure labeling logic: pairs each physical GPU index with a friendly name, falling back to a generic label when none is known + /// Pure aggregation logic: sums each sample's value per adapter, grouped by its luid (the same luid uses as the GpuId, so no separate index-matching scheme is needed) /// - internal static Dictionary BuildGpuLabels(IEnumerable physicalIndexes, IReadOnlyDictionary gpuNames) + internal static Dictionary AggregateUsageByAdapter(IEnumerable<(string InstanceName, float Value)> samples) { - var gpus = new Dictionary(); - - foreach (var physicalIndex in physicalIndexes) - { - gpus[physicalIndex] = gpuNames.TryGetValue(physicalIndex, out var gpuName) && !string.IsNullOrWhiteSpace(gpuName) - ? gpuName - : $"GPU {physicalIndex}"; - } - - return gpus; + return samples + .GroupBy(s => GetAdapterLuid(s.InstanceName)) + .ToDictionary(g => g.Key, g => g.Sum(s => s.Value)); } /// - /// Best-effort mapping of physical adapter index to a friendly GPU name, using WMI's video controller enumeration order + /// Enumerates the known physical GPUs (excluding the WARP/Microsoft Basic Render software rasterizer) via DXGI, keyed by their adapter luid. + /// DXGI lists every installed adapter unconditionally, active or idle, so no separate 'GPU Engine' counter pass is needed to make an unused GPU (eg. an idle iGPU) selectable /// - private static Dictionary GetGpuNamesByIndex() + public static Dictionary GetAvailableGpus() { - var names = new Dictionary(); + var gpus = new Dictionary(); + IDXGIFactory1 factory = null; try { - using var searcher = new ManagementObjectSearcher("SELECT Name, PNPDeviceID FROM Win32_VideoController"); - using var results = searcher.Get(); + var factoryResult = CreateDXGIFactory1(typeof(IDXGIFactory1).GUID, out var factoryObj); + if (factoryResult.Failed || factoryObj is not IDXGIFactory1 dxgiFactory) + return gpus; - var index = 0; - foreach (var result in results) - { - using var videoController = (ManagementObject)result; - - var pnpDeviceId = videoController["PNPDeviceID"]?.ToString() ?? string.Empty; - if (!pnpDeviceId.StartsWith("PCI", StringComparison.OrdinalIgnoreCase)) - continue; // skip basic render/remote display virtual adapters + factory = dxgiFactory; - names[index.ToString(CultureInfo.InvariantCulture)] = videoController["Name"]?.ToString(); - index++; + for (uint adapterIndex = 0; ; adapterIndex++) + { + IDXGIAdapter1 adapter = null; + try + { + if (factory.EnumAdapters1(adapterIndex, out adapter).Failed || adapter == null) + break; + + var desc = adapter.GetDesc1(); + if (desc.Flags.HasFlag(DXGI_ADAPTER_FLAG.DXGI_ADAPTER_FLAG_SOFTWARE)) + continue; + + var luid = FormatLuid(desc.AdapterLuid); + gpus[luid] = string.IsNullOrWhiteSpace(desc.Description) ? $"GPU {luid}" : desc.Description; + } + finally + { + if (adapter != null) + Marshal.ReleaseComObject(adapter); + } } } catch { - // best effort, no names found + // best effort, no gpus found + } + finally + { + if (factory != null) + Marshal.ReleaseComObject(factory); } - return names; + return gpus; } + + /// + /// Formats a DXGI LUID exactly as it appears inside a 'GPU Engine' counter instance name (eg. '0x00000000_0x00016e08'). + /// Lowercase 'x8' to match 's normalization - the two must always agree + /// + private static string FormatLuid(LUID luid) => $"0x{(uint)luid.HighPart:x8}_0x{luid.LowPart:x8}"; } diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs index df042ee8..f29f73b7 100644 --- a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs +++ b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs @@ -1,3 +1,4 @@ +using System.Threading; using HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.SingleValue; namespace HASS.Agent.Shared.UnitTests.HomeAssistant.Sensors.GeneralSensors.SingleValue; @@ -80,62 +81,132 @@ public void SelectGpuUsage_SpecificGpuNoLongerPresent_ReturnsZero() Assert.That(result, Is.EqualTo(0)); } - [TestCase("pid_1234_luid_0x00000000_0x0000C5F2_phys_0_eng_0_engtype_3D", "0")] - [TestCase("pid_4080_luid_0x00000000_0x0000C8F1_phys_1_eng_2_engtype_3D", "1")] - [TestCase("pid_999_luid_0x00000000_0x00001234_phys_12_eng_3_engtype_3D", "12")] - public void GetPhysicalGpuIndex_ParsesPhysIndexFromInstanceName(string instanceName, string expectedIndex) + [TestCase("pid_1234_luid_0x00000000_0x0000C5F2_phys_0_eng_0_engtype_3D", "0x00000000_0x0000c5f2")] + [TestCase("pid_4080_luid_0x00000000_0x0000C8F1_phys_1_eng_2_engtype_3D", "0x00000000_0x0000c8f1")] + // the real-world bug: 'phys_0' for every instance regardless of which adapter it actually belongs to - + // the luid is what's actually unique here, so it must be what gets extracted, not the phys segment + [TestCase("pid_2568_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D", "0x00000000_0x00016e08")] + [TestCase("pid_2568_luid_0x00000000_0x000183dd_phys_0_eng_0_engtype_3D", "0x00000000_0x000183dd")] + public void GetAdapterLuid_ParsesLuidFromInstanceName(string instanceName, string expectedLuid) { - var result = GpuLoadSensor.GetPhysicalGpuIndex(instanceName); + var result = GpuLoadSensor.GetAdapterLuid(instanceName); - Assert.That(result, Is.EqualTo(expectedIndex)); + Assert.That(result, Is.EqualTo(expectedLuid)); } [Test] - public void GetPhysicalGpuIndex_UnrecognizedFormat_FallsBackToZero() + public void GetAdapterLuid_UnrecognizedFormat_FallsBackToFullInstanceName() { - var result = GpuLoadSensor.GetPhysicalGpuIndex("some_unexpected_counter_instance_name"); + var result = GpuLoadSensor.GetAdapterLuid("some_unexpected_counter_instance_name"); - Assert.That(result, Is.EqualTo("0")); + Assert.That(result, Is.EqualTo("some_unexpected_counter_instance_name")); } [Test] - public void BuildGpuLabels_KnownGpu_UsesFriendlyName() + public void GetAdapterLuid_DifferentCasingForSameAdapter_NormalizesToSameValue() { - var names = new Dictionary { ["0"] = "NVIDIA GeForce RTX 3070" }; + // the actual bug this fixes: windows doesn't consistently capitalize the luid's hex digits across + // different processes' counter instances, so two instances for the SAME real adapter could otherwise + // be grouped as two different "adapters" - and fail to match the lowercase luid GetAvailableGpus() builds + var lower = GpuLoadSensor.GetAdapterLuid("pid_1_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D"); + var upper = GpuLoadSensor.GetAdapterLuid("pid_2_luid_0x00000000_0x00016E08_phys_0_eng_0_engtype_3D"); - var result = GpuLoadSensor.BuildGpuLabels(new[] { "0" }, names); + Assert.That(upper, Is.EqualTo(lower)); + } + + [Test] + public void FilterToKnownAdapters_DropsInstancesFromUnknownAdapter() + { + // the actual bug this fixes: GetAvailableGpus() (DXGI) no longer lists a phantom/virtual adapter (eg. + // WARP), but without this filter its counter instances would still flow into the 'all gpus' average + var instanceNames = new[] + { + "pid_1_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D", // real, known + "pid_2_luid_0x00000000_0x0001839e_phys_0_eng_0_engtype_3D", // phantom, unknown + }; + var knownGpuLuids = new[] { "0x00000000_0x00016e08" }; + + var result = GpuLoadSensor.FilterToKnownAdapters(instanceNames, knownGpuLuids); + + Assert.That(result, Is.EqualTo(new[] { "pid_1_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D" })); + } + + [Test] + public void FilterToKnownAdapters_KnownLuidCasingDiffersFromInstanceName_StillMatches() + { + var instanceNames = new[] { "pid_1_luid_0x00000000_0x00016E08_phys_0_eng_0_engtype_3D" }; + var knownGpuLuids = new[] { "0x00000000_0x00016e08" }; + + var result = GpuLoadSensor.FilterToKnownAdapters(instanceNames, knownGpuLuids); + + Assert.That(result, Is.EqualTo(instanceNames)); + } - Assert.That(result["0"], Is.EqualTo("NVIDIA GeForce RTX 3070")); + [Test] + public void FilterToKnownAdapters_NoKnownAdapters_ReturnsEmpty() + { + var instanceNames = new[] { "pid_1_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D" }; + + var result = GpuLoadSensor.FilterToKnownAdapters(instanceNames, Enumerable.Empty()); + + Assert.That(result, Is.Empty); } [Test] - public void BuildGpuLabels_UnknownGpu_FallsBackToGenericLabel() + public void AggregateUsageByAdapter_GroupsByLuidNotPhysIndex() { - var names = new Dictionary { ["0"] = "NVIDIA GeForce RTX 3070" }; + // two different processes on the SAME adapter (same luid, both 'phys_0') must be summed together, + // while a process on a DIFFERENT adapter (different luid, also 'phys_0') must stay separate + var samples = new[] + { + ("pid_1_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D", 10f), + ("pid_2_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D", 5f), + ("pid_3_luid_0x00000000_0x000183dd_phys_0_eng_0_engtype_3D", 20f), + }; - var result = GpuLoadSensor.BuildGpuLabels(new[] { "0", "1" }, names); + var result = GpuLoadSensor.AggregateUsageByAdapter(samples); - Assert.That(result["1"], Is.EqualTo("GPU 1")); + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result.Values, Has.Member(15f)); // 10 + 5, same adapter + Assert.That(result.Values, Has.Member(20f)); } [Test] - public void BuildGpuLabels_BlankName_FallsBackToGenericLabel() + public void AggregateUsageByAdapter_KeysResultByRawLuid() { - var names = new Dictionary { ["0"] = " " }; + var samples = new[] + { + ("pid_1_luid_0x00000000_0x000183dd_phys_0_eng_0_engtype_3D", 99f), + ("pid_2_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D", 11f), + }; - var result = GpuLoadSensor.BuildGpuLabels(new[] { "0" }, names); + var result = GpuLoadSensor.AggregateUsageByAdapter(samples); - Assert.That(result["0"], Is.EqualTo("GPU 0")); + // keyed directly by the adapter's luid - the same id GetAvailableGpus() uses, so no separate + // index-matching scheme is needed to look up which gpu a given sample's value belongs to + Assert.That(result["0x00000000_0x00016e08"], Is.EqualTo(11f)); + Assert.That(result["0x00000000_0x000183dd"], Is.EqualTo(99f)); } [Test] - public void BuildGpuLabels_NoIndexes_ReturnsEmpty() + public void AggregateUsageByAdapter_NoSamples_ReturnsEmpty() { - var result = GpuLoadSensor.BuildGpuLabels(System.Array.Empty(), new Dictionary()); + var result = GpuLoadSensor.AggregateUsageByAdapter(Enumerable.Empty<(string, float)>()); Assert.That(result, Is.Empty); } + [Test] + public void GetState_SpecificGpuNotPresent_ReturnsZeroNotEmptyString() + { + // regression test: ToString("#.##") on exactly 0 produces an empty string in .NET, not "0" - this + // exercises the real GetState()/GetGPUUsage() path with a gpu id that can never match real hardware, + // so SelectGpuUsage deterministically falls through to 0 regardless of what's actually installed + var sensor = new GpuLoadSensor("999"); + + Assert.That(sensor.GetState(), Is.EqualTo("0")); + } + [Test] public void GetGPUUsage_NeverThrowsAndReturnsNonNegativeValue() { @@ -154,12 +225,12 @@ public void GetAvailableGpus_NeverThrowsAndReturnsADictionary() Assert.That(result, Is.Not.Null); } + // exercises the real 'GPU Engine' performance counters on whatever machine runs this test, rather than synthetic data - skips instead of failing on a machine with no gpu counters [Test] [Category("Hardware")] public void GetAvailableGpus_OnThisMachine_EachGpuReportsAPlausibleLoad() { - // exercises the real 'GPU Engine' performance counters on whatever machine runs this test, - // rather than synthetic data - skips instead of failing on a machine with no gpu counters + var sleep = 1000; var gpus = GpuLoadSensor.GetAvailableGpus(); if (gpus.Count == 0) { @@ -169,9 +240,16 @@ public void GetAvailableGpus_OnThisMachine_EachGpuReportsAPlausibleLoad() TestContext.Out.WriteLine($"Detected {gpus.Count} GPU(s):"); - foreach (var (gpuId, gpuName) in gpus) + // GpuLoadSensor caches its counters across calls, so the first read on a freshly-created sensor only primes them and carries little meaning on its own. + // Read once to prime, wait for a real time window to pass, then read again, since percentage-style performance counters need a meaningful delta between samples to be accurate. + var sensors = gpus.Select(gpu => (gpu.Key, gpu.Value, Sensor: new GpuLoadSensor(gpu.Key))).ToList(); + foreach (var (_, _, sensor) in sensors) + sensor.GetGPUUsage(); + + Thread.Sleep(sleep); + + foreach (var (gpuId, gpuName, sensor) in sensors) { - var sensor = new GpuLoadSensor(gpuId); var usage = sensor.GetGPUUsage(); var state = sensor.GetState(); @@ -182,6 +260,8 @@ public void GetAvailableGpus_OnThisMachine_EachGpuReportsAPlausibleLoad() } var allGpus = new GpuLoadSensor(); + allGpus.GetGPUUsage(); + Thread.Sleep(sleep); var averageUsage = allGpus.GetGPUUsage(); TestContext.Out.WriteLine($" [*] All GPUs (average): {allGpus.GetState()}% (raw value: {averageUsage})"); From 590c7194442c534990fa7238c4e1443c7bf16335 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 16 Jun 2026 18:58:18 +0100 Subject: [PATCH 08/15] Updated tests to use LUID's --- .../GeneralSensors/SingleValue/GpuLoadSensorTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs index f29f73b7..5a53d431 100644 --- a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs +++ b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs @@ -22,9 +22,9 @@ public void Constructor_NullOrEmptyGpuId_NormalizesToAllGpus(string? gpuId) Assert.That(sensor.GpuId, Is.EqualTo("*")); } - [TestCase("0")] - [TestCase("1")] - [TestCase("12")] + [TestCase("0x00000000_0x0000c8f10")] + [TestCase("0x00000000_0x000183dd")] + [TestCase("0x00000000_0x0000c5f2")] public void Constructor_SpecificGpuId_IsPreserved(string gpuId) { var sensor = new GpuLoadSensor(gpuId); @@ -44,7 +44,7 @@ public void SelectGpuUsage_NoGpusDetected_ReturnsZero() public void SelectGpuUsage_AllGpusSelected_AveragesInsteadOfSumming() { // this is the actual bug being fixed: two gpus at 20% and 80% load should report 50%, not 100% - var perGpuUsage = new Dictionary { ["0"] = 20f, ["1"] = 80f }; + var perGpuUsage = new Dictionary { ["0x00000000_0x0000c8f10"] = 20f, ["0x00000000_0x000183dd"] = 80f }; var result = GpuLoadSensor.SelectGpuUsage(perGpuUsage, "*", useSpecificGpu: false); From 1bcbe28d32b73042ea71735c27a9eb48642e73ff Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 16 Jun 2026 18:59:03 +0100 Subject: [PATCH 09/15] Added comments --- .../SingleValue/GpuLoadSensor.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs b/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs index fedcd724..21f06536 100644 --- a/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs +++ b/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs @@ -15,7 +15,14 @@ namespace HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.SingleValue; /// public class GpuLoadSensor : AbstractSingleValueSensor { + /// + /// The default entity name and friendly name for this sensor + /// private const string DefaultName = "gpuload"; + + /// + /// The special value for that indicates the sensor should average across all detected GPUs, rather than reporting on a specific adapter + /// private const string AllGpus = "*"; /// @@ -23,19 +30,37 @@ public class GpuLoadSensor : AbstractSingleValueSensor /// private static readonly Regex AdapterLuidRegex = new(@"luid_(0x[0-9A-Fa-f]+_0x[0-9A-Fa-f]+)", RegexOptions.Compiled); + /// + /// The adapter luid (eg. '0x00000000_0x00016e08') to report on, or '*' to average across every detected gpu + /// public string GpuId { get; protected set; } + + /// + /// Indicates whether the sensor is reporting on a specific adapter (true) or averaging across all detected adapters (false) + /// private readonly bool _useSpecificGpu; + /// /// The cached 'GPU Engine' counters. /// private readonly Dictionary _engineCounterCache = new(); + /// + /// Creates a new gpu load sensor + /// + /// The adapter luid (eg. '0x00000000_0x00016e08') to report on, or '*' to average across every detected gpu + /// How often, in seconds, the sensor's value is refreshed + /// The entity's unique name, used in its mqtt topic + /// The entity's friendly (display) name + /// The entity's unique id + /// Serialized advanced settings (device class, unit of measurement, state class) overriding the auto discovery config public GpuLoadSensor(string gpuId = AllGpus, int? updateInterval = null, string entityName = DefaultName, string name = DefaultName, string id = default, string advancedSettings = default) : base(entityName ?? DefaultName, name ?? null, updateInterval ?? 30, id, advancedSettings: advancedSettings) { GpuId = string.IsNullOrEmpty(gpuId) ? AllGpus : gpuId; _useSpecificGpu = GpuId != AllGpus; } + /// public override DiscoveryConfigModel GetAutoDiscoveryConfig() { if (Variables.MqttManager == null) @@ -58,13 +83,19 @@ public override DiscoveryConfigModel GetAutoDiscoveryConfig() }); } + /// public override string GetState() { return GetGPUUsage().ToString("0.##", CultureInfo.InvariantCulture); } + /// public override string GetAttributes() => string.Empty; + /// + /// Returns the current GPU usage, either for a specific adapter (if is set to a luid) or averaged across all detected adapters (if is set to '*') + /// + /// The current GPU usage as a float value public float GetGPUUsage() { try From 68942af7c1f68c68eb303f7a30d926e1e6df657d Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 17 Jun 2026 11:23:59 +0100 Subject: [PATCH 10/15] Added single GPU handling for front end --- .../HASS.Agent/Forms/Sensors/SensorsMod.cs | 20 ++++++++++++++++--- .../Localization/Languages.Designer.cs | 11 +++++++++- .../Resources/Localization/Languages.de.resx | 3 +++ .../Resources/Localization/Languages.en.resx | 3 +++ .../Resources/Localization/Languages.es.resx | 3 +++ .../Resources/Localization/Languages.fr.resx | 3 +++ .../Resources/Localization/Languages.nl.resx | 3 +++ .../Resources/Localization/Languages.pl.resx | 3 +++ .../Localization/Languages.pt-br.resx | 3 +++ .../Resources/Localization/Languages.resx | 3 +++ .../Resources/Localization/Languages.ru.resx | 3 +++ .../Resources/Localization/Languages.sl.resx | 3 +++ .../Resources/Localization/Languages.tr.resx | 3 +++ 13 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs b/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs index 4d2989b1..636e68b0 100644 --- a/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs +++ b/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs @@ -1,8 +1,7 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Net.NetworkInformation; using Syncfusion.Windows.Forms; using HASS.Agent.Functions; -using HASS.Agent.Models.Internal; using HASS.Agent.Resources.Localization; using HASS.Agent.Sensors; using HASS.Agent.Shared.Enums; @@ -530,7 +529,19 @@ private void SetGpuGui() LblSetting1.Text = Languages.SensorsMod_LblSetting1_Gpu; LblSetting1.Visible = true; - CbSetting1.Visible = true; + if (_gpuDevices.Count == 2) // exactly 1 real GPU (plus the "*" all-GPUs entry) + { + var singleGpu = _gpuDevices.First(x => x.Key != "*"); + TbSetting1.Text = singleGpu.Value; + TbSetting1.ReadOnly = true; + TbSetting1.Visible = true; + LblSetting2.Text = Languages.SensorsMod_LblSetting2_SingleGpu; + LblSetting2.Visible = true; + } + else + { + CbSetting1.Visible = true; + } })); } @@ -597,14 +608,17 @@ private void SetEmptyGui() CbSetting1.Visible = false; TbSetting1.Text = string.Empty; + TbSetting1.ReadOnly = false; TbSetting1.Visible = false; LblSetting2.Visible = false; TbSetting2.Text = string.Empty; + TbSetting2.ReadOnly = false; TbSetting2.Visible = false; LblSetting3.Visible = false; TbSetting3.Text = string.Empty; + TbSetting3.ReadOnly = false; TbSetting3.Visible = false; CbApplyRounding.Text = Languages.SensorsMod_CbApplyRounding; diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.Designer.cs b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.Designer.cs index e91cf16b..8c773e8e 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.Designer.cs +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.Designer.cs @@ -6865,7 +6865,16 @@ internal static string SensorsMod_LblSetting2_Wmi { return ResourceManager.GetString("SensorsMod_LblSetting2_Wmi", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Only 1 GPU detected. + /// + internal static string SensorsMod_LblSetting2_SingleGpu { + get { + return ResourceManager.GetString("SensorsMod_LblSetting2_SingleGpu", resourceCulture); + } + } + /// /// Looks up a localized string similar to Setting 3. /// diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.de.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.de.resx index 855f46e9..aa12bde2 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.de.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.de.resx @@ -1905,6 +1905,9 @@ konfiguriere bitte einen Exekutor oder dein Befehl wird nicht ausgeführt Grafikkarte + + Only 1 GPU detected + Zähler diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.en.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.en.resx index 9edee253..d756f77a 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.en.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.en.resx @@ -1794,6 +1794,9 @@ Please configure an executor or your command will not run. GPU Device + + Only 1 GPU detected + Counter diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.es.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.es.resx index fe3b971c..7aecb554 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.es.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.es.resx @@ -1798,6 +1798,9 @@ configure un ejecutor o su comando no se ejecutará Tarjeta gráfica + + Only 1 GPU detected + mostrador diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.fr.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.fr.resx index 83b9feab..b86bc627 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.fr.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.fr.resx @@ -1814,6 +1814,9 @@ veuillez configurer un interpréteur de commandes ou votre commande ne fonctionn Carte graphique + + Only 1 GPU detected + Compteur diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.nl.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.nl.resx index 73dc7fbc..9a22bf78 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.nl.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.nl.resx @@ -1803,6 +1803,9 @@ configureer een executor, anders kan het commando niet uitvoeren Videokaart + + Only 1 GPU detected + teller diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pl.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pl.resx index 09eb39d7..c6068c26 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pl.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pl.resx @@ -1892,6 +1892,9 @@ skonfiguruj środowisko wykonania, inaczej twoje polecenie się nie uruchomi Karta graficzna + + Only 1 GPU detected + licznik diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pt-br.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pt-br.resx index e81a3b15..67d67ba0 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pt-br.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pt-br.resx @@ -2416,6 +2416,9 @@ Essa é uma solicitação cara, portanto, o intervalo recomendado é de 15 minut Placa de vídeo + + Only 1 GPU detected + placa de rede diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.resx index f6705fbd..3b6913da 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.resx @@ -904,6 +904,9 @@ Please configure an executor or your command will not run. GPU Device + + Only 1 GPU detected + Counter diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.ru.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.ru.resx index dd354ff0..3a41f1f8 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.ru.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.ru.resx @@ -1847,6 +1847,9 @@ Home Assistant version: {0} Видеокарта + + Only 1 GPU detected + счетчик diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.sl.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.sl.resx index f9f43406..21139324 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.sl.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.sl.resx @@ -2497,6 +2497,9 @@ To je draga zahteva, zato je priporočeni interval 15 minut (900 sekund). Vendar Grafična kartica + + Only 1 GPU detected + omrežno kartico diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.tr.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.tr.resx index 954c3f3f..9a72292b 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.tr.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.tr.resx @@ -1631,6 +1631,9 @@ CTRL gibi daha fazla tuşa ve/veya değiştiriciye ihtiyacınız varsa, Multiple Grafik kartı + + Only 1 GPU detected + Tezgah From bd32e34b526442eb89c0441d3401b73c9427d97c Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 17 Jun 2026 11:25:50 +0100 Subject: [PATCH 11/15] Added sensor constants file --- .../Constants/SensorConstants.cs | 13 +++++++++++++ .../GeneralSensors/MultiValue/NetworkSensors.cs | 14 ++++++++------ .../GeneralSensors/SingleValue/GpuLoadSensor.cs | 9 +++++---- .../HASS.Agent/Forms/Sensors/SensorsMod.cs | 15 ++++++++------- 4 files changed, 34 insertions(+), 17 deletions(-) create mode 100644 src/HASS.Agent/HASS.Agent.Shared/Constants/SensorConstants.cs diff --git a/src/HASS.Agent/HASS.Agent.Shared/Constants/SensorConstants.cs b/src/HASS.Agent/HASS.Agent.Shared/Constants/SensorConstants.cs new file mode 100644 index 00000000..b30561ce --- /dev/null +++ b/src/HASS.Agent/HASS.Agent.Shared/Constants/SensorConstants.cs @@ -0,0 +1,13 @@ +using System.Text.RegularExpressions; + + +namespace HASS.Agent.Shared.Constants; + +public static class SensorConstants +{ + public const string DropdownAll = "*"; + + public const string DropdownNone = "none"; + + public static readonly Regex LuidRegex = new(@"luid_(0x[0-9A-Fa-f]+_0x[0-9A-Fa-f]+)", RegexOptions.Compiled); +} diff --git a/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/MultiValue/NetworkSensors.cs b/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/MultiValue/NetworkSensors.cs index e43755b6..7cf90793 100644 --- a/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/MultiValue/NetworkSensors.cs +++ b/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/MultiValue/NetworkSensors.cs @@ -1,13 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Net.NetworkInformation; -using ByteSizeLib; +using ByteSizeLib; +using HASS.Agent.Shared.Constants; using HASS.Agent.Shared.Functions; using HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.MultiValue.DataTypes; using HASS.Agent.Shared.Models.HomeAssistant; using HASS.Agent.Shared.Models.Internal; using Newtonsoft.Json; using Serilog; +using System; +using System.Collections.Generic; +using System.Net.NetworkInformation; namespace HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.MultiValue; @@ -17,6 +18,7 @@ namespace HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.MultiValue; public class NetworkSensors : AbstractMultiValueSensor { private const string DefaultName = "network"; + private const string AllNetworkCards = SensorConstants.DropdownAll; private readonly int _updateInterval; public string NetworkCard { get; protected set; } @@ -24,12 +26,12 @@ public class NetworkSensors : AbstractMultiValueSensor public override sealed Dictionary Sensors { get; protected set; } = new Dictionary(); - public NetworkSensors(int? updateInterval = null, string entityName = DefaultName, string name = DefaultName, string networkCard = "*", string id = default) : base(entityName ?? DefaultName, name ?? null, updateInterval ?? 30, id) + public NetworkSensors(int? updateInterval = null, string entityName = DefaultName, string name = DefaultName, string networkCard = AllNetworkCards, string id = default) : base(entityName ?? DefaultName, name ?? null, updateInterval ?? 30, id) { _updateInterval = updateInterval ?? 30; NetworkCard = networkCard; - _useSpecificCard = networkCard != "*" && !string.IsNullOrEmpty(networkCard); + _useSpecificCard = networkCard != AllNetworkCards && !string.IsNullOrEmpty(networkCard); UpdateSensorValues(); } diff --git a/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs b/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs index 21f06536..274a265b 100644 --- a/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs +++ b/src/HASS.Agent/HASS.Agent.Shared/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensor.cs @@ -1,10 +1,11 @@ -using System.Collections.Generic; +using HASS.Agent.Shared.Constants; +using HASS.Agent.Shared.Models.HomeAssistant; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; using System.Runtime.InteropServices; using System.Text.RegularExpressions; -using HASS.Agent.Shared.Models.HomeAssistant; using Vanara.PInvoke; using static Vanara.PInvoke.DXGI; @@ -23,12 +24,12 @@ public class GpuLoadSensor : AbstractSingleValueSensor /// /// The special value for that indicates the sensor should average across all detected GPUs, rather than reporting on a specific adapter /// - private const string AllGpus = "*"; + private const string AllGpus = SensorConstants.DropdownAll; /// /// The regex used to extract the adapter luid from a GPU Engine counter instance name (eg. 'luid_0x00000000_0x00016e08_engtype_3D'). /// - private static readonly Regex AdapterLuidRegex = new(@"luid_(0x[0-9A-Fa-f]+_0x[0-9A-Fa-f]+)", RegexOptions.Compiled); + private static readonly Regex AdapterLuidRegex = SensorConstants.LuidRegex; /// /// The adapter luid (eg. '0x00000000_0x00016e08') to report on, or '*' to average across every detected gpu diff --git a/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs b/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs index 636e68b0..83c8d8d9 100644 --- a/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs +++ b/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Net.NetworkInformation; using Syncfusion.Windows.Forms; using HASS.Agent.Functions; @@ -14,6 +14,7 @@ using HASS.Agent.Managers; using HASS.Agent.Forms.Commands.CommandConfig; using Newtonsoft.Json; +using HASS.Agent.Shared.Constants; namespace HASS.Agent.Forms.Sensors { @@ -101,18 +102,18 @@ private void SensorMod_Load(object sender, EventArgs e) } LvSensors.EndUpdate(); - _networkCards.Add("*", Languages.SensorsMod_All); + _networkCards.Add(SensorConstants.DropdownAll, Languages.SensorsMod_All); foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) _networkCards.Add(nic.Id, nic.Name); - _internalSensors.Add("none", Languages.SensorsMod_None); + _internalSensors.Add(SensorConstants.DropdownNone, Languages.SensorsMod_None); foreach (var internalSensor in InternalDeviceSensorsManager.AvailableSensors) { var internalSensorType = internalSensor.Type.ToString(); _internalSensors.Add(internalSensorType, internalSensorType); } - _gpuDevices.Add("*", Languages.SensorsMod_All); + _gpuDevices.Add(SensorConstants.DropdownAll, Languages.SensorsMod_All); foreach (var gpu in GpuLoadSensor.GetAvailableGpus()) _gpuDevices.Add(gpu.Key, gpu.Value); @@ -531,7 +532,7 @@ private void SetGpuGui() if (_gpuDevices.Count == 2) // exactly 1 real GPU (plus the "*" all-GPUs entry) { - var singleGpu = _gpuDevices.First(x => x.Key != "*"); + var singleGpu = _gpuDevices.First(x => x.Key != SensorConstants.DropdownAll); TbSetting1.Text = singleGpu.Value; TbSetting1.ReadOnly = true; TbSetting1.Visible = true; @@ -826,7 +827,7 @@ private void BtnStore_Click(object sender, EventArgs e) break; case SensorType.NetworkSensors: - Sensor.Query = "*"; + Sensor.Query = SensorConstants.DropdownAll; if (CbSetting1.SelectedItem != null) { var item = (KeyValuePair)CbSetting1.SelectedItem; @@ -835,7 +836,7 @@ private void BtnStore_Click(object sender, EventArgs e) break; case SensorType.GpuLoadSensor: - Sensor.Query = "*"; + Sensor.Query = SensorConstants.DropdownAll; if (CbSetting1.SelectedItem != null) { var item = (KeyValuePair)CbSetting1.SelectedItem; From 584b83463e25e0c07a8f71d52f3b105d1fc8fac0 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 17 Jun 2026 11:28:54 +0100 Subject: [PATCH 12/15] Made the SetGpuGui check a little more strict --- src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs b/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs index 83c8d8d9..42ab4213 100644 --- a/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs +++ b/src/HASS.Agent/HASS.Agent/Forms/Sensors/SensorsMod.cs @@ -530,7 +530,7 @@ private void SetGpuGui() LblSetting1.Text = Languages.SensorsMod_LblSetting1_Gpu; LblSetting1.Visible = true; - if (_gpuDevices.Count == 2) // exactly 1 real GPU (plus the "*" all-GPUs entry) + if (_gpuDevices.Count(x => x.Key != SensorConstants.DropdownAll) == 1) // exactly 1 real GPU (plus the "*" all-GPUs entry) { var singleGpu = _gpuDevices.First(x => x.Key != SensorConstants.DropdownAll); TbSetting1.Text = singleGpu.Value; From 710478169aa27230f722ac65fdb8796fcd2f767e Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 17 Jun 2026 11:38:53 +0100 Subject: [PATCH 13/15] Added more tests for new additions --- .../Constants/SensorConstantsTests.cs | 27 +++++++++++++++++++ .../SingleValue/GpuLoadSensorTests.cs | 26 ++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/Constants/SensorConstantsTests.cs diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/Constants/SensorConstantsTests.cs b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/Constants/SensorConstantsTests.cs new file mode 100644 index 00000000..9465d925 --- /dev/null +++ b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/Constants/SensorConstantsTests.cs @@ -0,0 +1,27 @@ +using HASS.Agent.Shared.Constants; + +namespace HASS.Agent.Shared.UnitTests.Constants; + +public class SensorConstantsTests +{ + [TestCase("pid_1234_luid_0x00000000_0x0000C5F2_phys_0_eng_0_engtype_3D", "0x00000000_0x0000C5F2")] + [TestCase("pid_4080_luid_0x00000000_0x0000C8F1_phys_1_eng_2_engtype_3D", "0x00000000_0x0000C8F1")] + [TestCase("pid_2568_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D", "0x00000000_0x00016e08")] + public void LuidRegex_ValidInstanceName_CapturesLuidSegment(string instanceName, string expectedCapture) + { + var match = SensorConstants.LuidRegex.Match(instanceName); + + Assert.That(match.Success, Is.True); + Assert.That(match.Groups[1].Value, Is.EqualTo(expectedCapture)); + } + + [TestCase("some_unexpected_counter_instance_name")] + [TestCase("pid_1234_phys_0_eng_0_engtype_3D")] // missing luid_ segment entirely + [TestCase("")] + public void LuidRegex_NoLuidSegment_DoesNotMatch(string instanceName) + { + var match = SensorConstants.LuidRegex.Match(instanceName); + + Assert.That(match.Success, Is.False); + } +} diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs index 5a53d431..ba32f06a 100644 --- a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs +++ b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs @@ -152,6 +152,14 @@ public void FilterToKnownAdapters_NoKnownAdapters_ReturnsEmpty() Assert.That(result, Is.Empty); } + [Test] + public void FilterToKnownAdapters_EmptyInstanceNames_ReturnsEmpty() + { + var result = GpuLoadSensor.FilterToKnownAdapters(Enumerable.Empty(), new[] { "0x00000000_0x00016e08" }); + + Assert.That(result, Is.Empty); + } + [Test] public void AggregateUsageByAdapter_GroupsByLuidNotPhysIndex() { @@ -196,6 +204,24 @@ public void AggregateUsageByAdapter_NoSamples_ReturnsEmpty() Assert.That(result, Is.Empty); } + [Test] + public void AggregateUsageByAdapter_MixedLuidCasing_TreatsAsOneAdapter() + { + // same real-world scenario as GetAdapterLuid_DifferentCasingForSameAdapter: windows can emit counter + // instances for the same physical adapter with different hex capitalisation across processes - they must + // be summed into one adapter entry, not split into two separate adapter keys + var samples = new[] + { + ("pid_1_luid_0x00000000_0x00016E08_phys_0_eng_0_engtype_3D", 30f), // uppercase hex + ("pid_2_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D", 20f), // lowercase hex + }; + + var result = GpuLoadSensor.AggregateUsageByAdapter(samples); + + Assert.That(result.Count, Is.EqualTo(1)); + Assert.That(result.Values.Single(), Is.EqualTo(50f)); + } + [Test] public void GetState_SpecificGpuNotPresent_ReturnsZeroNotEmptyString() { From 927e42a99790c9a1bf6c75d3519c15b7cc1e97fb Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 17 Jun 2026 12:39:53 +0100 Subject: [PATCH 14/15] Fixed 1 GPU Detected translations --- .../HASS.Agent/Resources/Localization/Languages.de.resx | 2 +- .../HASS.Agent/Resources/Localization/Languages.es.resx | 2 +- .../HASS.Agent/Resources/Localization/Languages.fr.resx | 2 +- .../HASS.Agent/Resources/Localization/Languages.nl.resx | 2 +- .../HASS.Agent/Resources/Localization/Languages.pl.resx | 2 +- .../HASS.Agent/Resources/Localization/Languages.pt-br.resx | 2 +- .../HASS.Agent/Resources/Localization/Languages.ru.resx | 2 +- .../HASS.Agent/Resources/Localization/Languages.sl.resx | 2 +- .../HASS.Agent/Resources/Localization/Languages.tr.resx | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.de.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.de.resx index aa12bde2..8cb63ce9 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.de.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.de.resx @@ -1906,7 +1906,7 @@ konfiguriere bitte einen Exekutor oder dein Befehl wird nicht ausgeführtGrafikkarte - Only 1 GPU detected + Nur 1 GPU erkannt Zähler diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.es.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.es.resx index 7aecb554..4f86a001 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.es.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.es.resx @@ -1799,7 +1799,7 @@ configure un ejecutor o su comando no se ejecutará Tarjeta gráfica - Only 1 GPU detected + Solo 1 GPU detectada mostrador diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.fr.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.fr.resx index b86bc627..1a855119 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.fr.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.fr.resx @@ -1815,7 +1815,7 @@ veuillez configurer un interpréteur de commandes ou votre commande ne fonctionn Carte graphique - Only 1 GPU detected + Seulement 1 GPU détecté Compteur diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.nl.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.nl.resx index 9a22bf78..0861e381 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.nl.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.nl.resx @@ -1804,7 +1804,7 @@ configureer een executor, anders kan het commando niet uitvoeren Videokaart - Only 1 GPU detected + Slechts 1 GPU gedetecteerd teller diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pl.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pl.resx index c6068c26..e510fc2f 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pl.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pl.resx @@ -1893,7 +1893,7 @@ skonfiguruj środowisko wykonania, inaczej twoje polecenie się nie uruchomiKarta graficzna - Only 1 GPU detected + Wykryto tylko 1 GPU licznik diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pt-br.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pt-br.resx index 67d67ba0..9c27b75c 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pt-br.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.pt-br.resx @@ -2417,7 +2417,7 @@ Essa é uma solicitação cara, portanto, o intervalo recomendado é de 15 minut Placa de vídeo - Only 1 GPU detected + Apenas 1 GPU detectada placa de rede diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.ru.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.ru.resx index 3a41f1f8..db3681bf 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.ru.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.ru.resx @@ -1848,7 +1848,7 @@ Home Assistant version: {0} Видеокарта - Only 1 GPU detected + Обнаружен только 1 GPU счетчик diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.sl.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.sl.resx index 21139324..3e53d4a1 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.sl.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.sl.resx @@ -2498,7 +2498,7 @@ To je draga zahteva, zato je priporočeni interval 15 minut (900 sekund). Vendar Grafična kartica - Only 1 GPU detected + Zaznan samo 1 GPU omrežno kartico diff --git a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.tr.resx b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.tr.resx index 9a72292b..37ecf6ce 100644 --- a/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.tr.resx +++ b/src/HASS.Agent/HASS.Agent/Resources/Localization/Languages.tr.resx @@ -1632,7 +1632,7 @@ CTRL gibi daha fazla tuşa ve/veya değiştiriciye ihtiyacınız varsa, Multiple Grafik kartı - Only 1 GPU detected + Yalnızca 1 GPU algılandı Tezgah From 88a74317ac7f17647ac0127f11a53da0d50ef0be Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 6 Jul 2026 10:15:09 +0100 Subject: [PATCH 15/15] Feat: Remove unit tests --- src/HASS.Agent.sln | 51 +-- ...S.Agent.Satellite.Service.UnitTests.csproj | 27 -- .../Settings/StoredSensorsTests.cs | 49 --- .../Constants/SensorConstantsTests.cs | 27 -- .../HASS.Agent.Shared.UnitTests.csproj | 27 -- .../SingleValue/GpuLoadSensorTests.cs | 296 ------------------ .../HASS.Agent.UnitTests.csproj | 31 -- .../Settings/StoredSensorsTests.cs | 71 ----- 8 files changed, 1 insertion(+), 578 deletions(-) delete mode 100644 src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/HASS.Agent.Satellite.Service.UnitTests.csproj delete mode 100644 src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/Settings/StoredSensorsTests.cs delete mode 100644 src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/Constants/SensorConstantsTests.cs delete mode 100644 src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HASS.Agent.Shared.UnitTests.csproj delete mode 100644 src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs delete mode 100644 src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/HASS.Agent.UnitTests.csproj delete mode 100644 src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/Settings/StoredSensorsTests.cs diff --git a/src/HASS.Agent.sln b/src/HASS.Agent.sln index 8b1d8067..304e0fdb 100644 --- a/src/HASS.Agent.sln +++ b/src/HASS.Agent.sln @@ -17,14 +17,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HASS.Agent.UnitTests", "HASS.Agent\UnitTests\HASS.Agent.UnitTests\HASS.Agent.UnitTests.csproj", "{D5796F7C-061A-473D-B7BA-B4F7D15E50D3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HASS.Agent.Shared.UnitTests", "HASS.Agent\UnitTests\HASS.Agent.Shared.UnitTests\HASS.Agent.Shared.UnitTests.csproj", "{4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HASS.Agent.Satellite.Service.UnitTests", "HASS.Agent\UnitTests\HASS.Agent.Satellite.Service.UnitTests\HASS.Agent.Satellite.Service.UnitTests.csproj", "{098DFCC8-7033-4006-97C8-4DED9920B0F7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UnitTests", "UnitTests", "{320255ED-7D01-4E7E-B9CE-6147FFD61A82}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -71,53 +63,12 @@ Global {C4138592-B05C-4B3D-B55C-A1337E97CDDD}.Release|x64.Build.0 = Release|Any CPU {C4138592-B05C-4B3D-B55C-A1337E97CDDD}.Release|x86.ActiveCfg = Release|x86 {C4138592-B05C-4B3D-B55C-A1337E97CDDD}.Release|x86.Build.0 = Release|x86 - {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Debug|x64.ActiveCfg = Debug|Any CPU - {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Debug|x64.Build.0 = Debug|Any CPU - {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Debug|x86.ActiveCfg = Debug|Any CPU - {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Debug|x86.Build.0 = Debug|Any CPU - {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Release|Any CPU.Build.0 = Release|Any CPU - {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Release|x64.ActiveCfg = Release|Any CPU - {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Release|x64.Build.0 = Release|Any CPU - {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Release|x86.ActiveCfg = Release|Any CPU - {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D}.Release|x86.Build.0 = Release|Any CPU - {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Debug|x64.ActiveCfg = Debug|Any CPU - {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Debug|x64.Build.0 = Debug|Any CPU - {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Debug|x86.ActiveCfg = Debug|Any CPU - {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Debug|x86.Build.0 = Debug|Any CPU - {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Release|Any CPU.Build.0 = Release|Any CPU - {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Release|x64.ActiveCfg = Release|Any CPU - {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Release|x64.Build.0 = Release|Any CPU - {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Release|x86.ActiveCfg = Release|Any CPU - {D5796F7C-061A-473D-B7BA-B4F7D15E50D3}.Release|x86.Build.0 = Release|Any CPU - {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Debug|x64.ActiveCfg = Debug|Any CPU - {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Debug|x64.Build.0 = Debug|Any CPU - {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Debug|x86.ActiveCfg = Debug|Any CPU - {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Debug|x86.Build.0 = Debug|Any CPU - {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Release|Any CPU.Build.0 = Release|Any CPU - {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Release|x64.ActiveCfg = Release|Any CPU - {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Release|x64.Build.0 = Release|Any CPU - {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Release|x86.ActiveCfg = Release|Any CPU - {098DFCC8-7033-4006-97C8-4DED9920B0F7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {D5796F7C-061A-473D-B7BA-B4F7D15E50D3} = {320255ED-7D01-4E7E-B9CE-6147FFD61A82} - {4ACA1C69-BDE0-4386-80B1-EE4EEAAEB02D} = {320255ED-7D01-4E7E-B9CE-6147FFD61A82} - {098DFCC8-7033-4006-97C8-4DED9920B0F7} = {320255ED-7D01-4E7E-B9CE-6147FFD61A82} - EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {C17243F4-9756-47EF-9AA8-86C64B7582A6} VisualSVNWorkingCopyRoot = . + SolutionGuid = {C17243F4-9756-47EF-9AA8-86C64B7582A6} EndGlobalSection EndGlobal diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/HASS.Agent.Satellite.Service.UnitTests.csproj b/src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/HASS.Agent.Satellite.Service.UnitTests.csproj deleted file mode 100644 index 08b6c6f1..00000000 --- a/src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/HASS.Agent.Satellite.Service.UnitTests.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net8.0-windows10.0.22621.0 - latest - enable - enable - false - - - - - - - - - - - - - - - - - - - diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/Settings/StoredSensorsTests.cs b/src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/Settings/StoredSensorsTests.cs deleted file mode 100644 index 205a6031..00000000 --- a/src/HASS.Agent/UnitTests/HASS.Agent.Satellite.Service.UnitTests/Settings/StoredSensorsTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -using HASS.Agent.Satellite.Service.Settings; -using HASS.Agent.Shared.Enums; -using HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.SingleValue; -using HASS.Agent.Shared.Models.Config; - -namespace HASS.Agent.Satellite.Service.UnitTests.Settings; - -public class StoredSensorsTests -{ - [TestCase("*")] - [TestCase("0")] - [TestCase("1")] - public void ConvertConfiguredToAbstractSingleValue_GpuLoadSensor_PassesQueryThroughAsGpuId(string query) - { - var configured = new ConfiguredSensor - { - Type = SensorType.GpuLoadSensor, - Id = Guid.NewGuid(), - EntityName = "gpuload", - Name = "GPU Load", - UpdateInterval = 30, - Query = query - }; - - var sensor = StoredSensors.ConvertConfiguredToAbstractSingleValue(configured); - - Assert.That(sensor, Is.InstanceOf()); - Assert.That(((GpuLoadSensor)sensor!).GpuId, Is.EqualTo(query)); - } - - [Test] - public void ConvertConfiguredToAbstractSingleValue_GpuLoadSensor_EmptyQuery_DefaultsToAllGpus() - { - // sensors configured before per-gpu selection existed have an empty Query - they should keep working as 'all' - var configured = new ConfiguredSensor - { - Type = SensorType.GpuLoadSensor, - Id = Guid.NewGuid(), - EntityName = "gpuload", - Name = "GPU Load", - UpdateInterval = 30, - Query = string.Empty - }; - - var sensor = StoredSensors.ConvertConfiguredToAbstractSingleValue(configured); - - Assert.That(((GpuLoadSensor)sensor!).GpuId, Is.EqualTo("*")); - } -} diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/Constants/SensorConstantsTests.cs b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/Constants/SensorConstantsTests.cs deleted file mode 100644 index 9465d925..00000000 --- a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/Constants/SensorConstantsTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using HASS.Agent.Shared.Constants; - -namespace HASS.Agent.Shared.UnitTests.Constants; - -public class SensorConstantsTests -{ - [TestCase("pid_1234_luid_0x00000000_0x0000C5F2_phys_0_eng_0_engtype_3D", "0x00000000_0x0000C5F2")] - [TestCase("pid_4080_luid_0x00000000_0x0000C8F1_phys_1_eng_2_engtype_3D", "0x00000000_0x0000C8F1")] - [TestCase("pid_2568_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D", "0x00000000_0x00016e08")] - public void LuidRegex_ValidInstanceName_CapturesLuidSegment(string instanceName, string expectedCapture) - { - var match = SensorConstants.LuidRegex.Match(instanceName); - - Assert.That(match.Success, Is.True); - Assert.That(match.Groups[1].Value, Is.EqualTo(expectedCapture)); - } - - [TestCase("some_unexpected_counter_instance_name")] - [TestCase("pid_1234_phys_0_eng_0_engtype_3D")] // missing luid_ segment entirely - [TestCase("")] - public void LuidRegex_NoLuidSegment_DoesNotMatch(string instanceName) - { - var match = SensorConstants.LuidRegex.Match(instanceName); - - Assert.That(match.Success, Is.False); - } -} diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HASS.Agent.Shared.UnitTests.csproj b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HASS.Agent.Shared.UnitTests.csproj deleted file mode 100644 index dcc7cf3c..00000000 --- a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HASS.Agent.Shared.UnitTests.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net8.0-windows10.0.22621.0 - latest - enable - enable - false - - - - - - - - - - - - - - - - - - - diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs b/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs deleted file mode 100644 index ba32f06a..00000000 --- a/src/HASS.Agent/UnitTests/HASS.Agent.Shared.UnitTests/HomeAssistant/Sensors/GeneralSensors/SingleValue/GpuLoadSensorTests.cs +++ /dev/null @@ -1,296 +0,0 @@ -using System.Threading; -using HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.SingleValue; - -namespace HASS.Agent.Shared.UnitTests.HomeAssistant.Sensors.GeneralSensors.SingleValue; - -public class GpuLoadSensorTests -{ - [Test] - public void DefaultConstructor_SelectsAllGpus() - { - var sensor = new GpuLoadSensor(); - - Assert.That(sensor.GpuId, Is.EqualTo("*")); - } - - [TestCase(null)] - [TestCase("")] - public void Constructor_NullOrEmptyGpuId_NormalizesToAllGpus(string? gpuId) - { - var sensor = new GpuLoadSensor(gpuId!); - - Assert.That(sensor.GpuId, Is.EqualTo("*")); - } - - [TestCase("0x00000000_0x0000c8f10")] - [TestCase("0x00000000_0x000183dd")] - [TestCase("0x00000000_0x0000c5f2")] - public void Constructor_SpecificGpuId_IsPreserved(string gpuId) - { - var sensor = new GpuLoadSensor(gpuId); - - Assert.That(sensor.GpuId, Is.EqualTo(gpuId)); - } - - [Test] - public void SelectGpuUsage_NoGpusDetected_ReturnsZero() - { - var result = GpuLoadSensor.SelectGpuUsage(new Dictionary(), "*", useSpecificGpu: false); - - Assert.That(result, Is.EqualTo(0)); - } - - [Test] - public void SelectGpuUsage_AllGpusSelected_AveragesInsteadOfSumming() - { - // this is the actual bug being fixed: two gpus at 20% and 80% load should report 50%, not 100% - var perGpuUsage = new Dictionary { ["0x00000000_0x0000c8f10"] = 20f, ["0x00000000_0x000183dd"] = 80f }; - - var result = GpuLoadSensor.SelectGpuUsage(perGpuUsage, "*", useSpecificGpu: false); - - Assert.That(result, Is.EqualTo(50f)); - } - - [Test] - public void SelectGpuUsage_AllGpusSelected_SingleGpuUnaffected() - { - var perGpuUsage = new Dictionary { ["0"] = 42f }; - - var result = GpuLoadSensor.SelectGpuUsage(perGpuUsage, "*", useSpecificGpu: false); - - Assert.That(result, Is.EqualTo(42f)); - } - - [Test] - public void SelectGpuUsage_SpecificGpuSelected_ReturnsOnlyThatGpusValue() - { - var perGpuUsage = new Dictionary { ["0"] = 20f, ["1"] = 80f }; - - var result = GpuLoadSensor.SelectGpuUsage(perGpuUsage, "1", useSpecificGpu: true); - - Assert.That(result, Is.EqualTo(80f)); - } - - [Test] - public void SelectGpuUsage_SpecificGpuNoLongerPresent_ReturnsZero() - { - var perGpuUsage = new Dictionary { ["0"] = 20f }; - - var result = GpuLoadSensor.SelectGpuUsage(perGpuUsage, "5", useSpecificGpu: true); - - Assert.That(result, Is.EqualTo(0)); - } - - [TestCase("pid_1234_luid_0x00000000_0x0000C5F2_phys_0_eng_0_engtype_3D", "0x00000000_0x0000c5f2")] - [TestCase("pid_4080_luid_0x00000000_0x0000C8F1_phys_1_eng_2_engtype_3D", "0x00000000_0x0000c8f1")] - // the real-world bug: 'phys_0' for every instance regardless of which adapter it actually belongs to - - // the luid is what's actually unique here, so it must be what gets extracted, not the phys segment - [TestCase("pid_2568_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D", "0x00000000_0x00016e08")] - [TestCase("pid_2568_luid_0x00000000_0x000183dd_phys_0_eng_0_engtype_3D", "0x00000000_0x000183dd")] - public void GetAdapterLuid_ParsesLuidFromInstanceName(string instanceName, string expectedLuid) - { - var result = GpuLoadSensor.GetAdapterLuid(instanceName); - - Assert.That(result, Is.EqualTo(expectedLuid)); - } - - [Test] - public void GetAdapterLuid_UnrecognizedFormat_FallsBackToFullInstanceName() - { - var result = GpuLoadSensor.GetAdapterLuid("some_unexpected_counter_instance_name"); - - Assert.That(result, Is.EqualTo("some_unexpected_counter_instance_name")); - } - - [Test] - public void GetAdapterLuid_DifferentCasingForSameAdapter_NormalizesToSameValue() - { - // the actual bug this fixes: windows doesn't consistently capitalize the luid's hex digits across - // different processes' counter instances, so two instances for the SAME real adapter could otherwise - // be grouped as two different "adapters" - and fail to match the lowercase luid GetAvailableGpus() builds - var lower = GpuLoadSensor.GetAdapterLuid("pid_1_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D"); - var upper = GpuLoadSensor.GetAdapterLuid("pid_2_luid_0x00000000_0x00016E08_phys_0_eng_0_engtype_3D"); - - Assert.That(upper, Is.EqualTo(lower)); - } - - [Test] - public void FilterToKnownAdapters_DropsInstancesFromUnknownAdapter() - { - // the actual bug this fixes: GetAvailableGpus() (DXGI) no longer lists a phantom/virtual adapter (eg. - // WARP), but without this filter its counter instances would still flow into the 'all gpus' average - var instanceNames = new[] - { - "pid_1_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D", // real, known - "pid_2_luid_0x00000000_0x0001839e_phys_0_eng_0_engtype_3D", // phantom, unknown - }; - var knownGpuLuids = new[] { "0x00000000_0x00016e08" }; - - var result = GpuLoadSensor.FilterToKnownAdapters(instanceNames, knownGpuLuids); - - Assert.That(result, Is.EqualTo(new[] { "pid_1_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D" })); - } - - [Test] - public void FilterToKnownAdapters_KnownLuidCasingDiffersFromInstanceName_StillMatches() - { - var instanceNames = new[] { "pid_1_luid_0x00000000_0x00016E08_phys_0_eng_0_engtype_3D" }; - var knownGpuLuids = new[] { "0x00000000_0x00016e08" }; - - var result = GpuLoadSensor.FilterToKnownAdapters(instanceNames, knownGpuLuids); - - Assert.That(result, Is.EqualTo(instanceNames)); - } - - [Test] - public void FilterToKnownAdapters_NoKnownAdapters_ReturnsEmpty() - { - var instanceNames = new[] { "pid_1_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D" }; - - var result = GpuLoadSensor.FilterToKnownAdapters(instanceNames, Enumerable.Empty()); - - Assert.That(result, Is.Empty); - } - - [Test] - public void FilterToKnownAdapters_EmptyInstanceNames_ReturnsEmpty() - { - var result = GpuLoadSensor.FilterToKnownAdapters(Enumerable.Empty(), new[] { "0x00000000_0x00016e08" }); - - Assert.That(result, Is.Empty); - } - - [Test] - public void AggregateUsageByAdapter_GroupsByLuidNotPhysIndex() - { - // two different processes on the SAME adapter (same luid, both 'phys_0') must be summed together, - // while a process on a DIFFERENT adapter (different luid, also 'phys_0') must stay separate - var samples = new[] - { - ("pid_1_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D", 10f), - ("pid_2_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D", 5f), - ("pid_3_luid_0x00000000_0x000183dd_phys_0_eng_0_engtype_3D", 20f), - }; - - var result = GpuLoadSensor.AggregateUsageByAdapter(samples); - - Assert.That(result.Count, Is.EqualTo(2)); - Assert.That(result.Values, Has.Member(15f)); // 10 + 5, same adapter - Assert.That(result.Values, Has.Member(20f)); - } - - [Test] - public void AggregateUsageByAdapter_KeysResultByRawLuid() - { - var samples = new[] - { - ("pid_1_luid_0x00000000_0x000183dd_phys_0_eng_0_engtype_3D", 99f), - ("pid_2_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D", 11f), - }; - - var result = GpuLoadSensor.AggregateUsageByAdapter(samples); - - // keyed directly by the adapter's luid - the same id GetAvailableGpus() uses, so no separate - // index-matching scheme is needed to look up which gpu a given sample's value belongs to - Assert.That(result["0x00000000_0x00016e08"], Is.EqualTo(11f)); - Assert.That(result["0x00000000_0x000183dd"], Is.EqualTo(99f)); - } - - [Test] - public void AggregateUsageByAdapter_NoSamples_ReturnsEmpty() - { - var result = GpuLoadSensor.AggregateUsageByAdapter(Enumerable.Empty<(string, float)>()); - - Assert.That(result, Is.Empty); - } - - [Test] - public void AggregateUsageByAdapter_MixedLuidCasing_TreatsAsOneAdapter() - { - // same real-world scenario as GetAdapterLuid_DifferentCasingForSameAdapter: windows can emit counter - // instances for the same physical adapter with different hex capitalisation across processes - they must - // be summed into one adapter entry, not split into two separate adapter keys - var samples = new[] - { - ("pid_1_luid_0x00000000_0x00016E08_phys_0_eng_0_engtype_3D", 30f), // uppercase hex - ("pid_2_luid_0x00000000_0x00016e08_phys_0_eng_0_engtype_3D", 20f), // lowercase hex - }; - - var result = GpuLoadSensor.AggregateUsageByAdapter(samples); - - Assert.That(result.Count, Is.EqualTo(1)); - Assert.That(result.Values.Single(), Is.EqualTo(50f)); - } - - [Test] - public void GetState_SpecificGpuNotPresent_ReturnsZeroNotEmptyString() - { - // regression test: ToString("#.##") on exactly 0 produces an empty string in .NET, not "0" - this - // exercises the real GetState()/GetGPUUsage() path with a gpu id that can never match real hardware, - // so SelectGpuUsage deterministically falls through to 0 regardless of what's actually installed - var sensor = new GpuLoadSensor("999"); - - Assert.That(sensor.GetState(), Is.EqualTo("0")); - } - - [Test] - public void GetGPUUsage_NeverThrowsAndReturnsNonNegativeValue() - { - var sensor = new GpuLoadSensor(); - - float result = 0; - Assert.DoesNotThrow(() => result = sensor.GetGPUUsage()); - Assert.That(result, Is.GreaterThanOrEqualTo(0)); - } - - [Test] - public void GetAvailableGpus_NeverThrowsAndReturnsADictionary() - { - Dictionary? result = null; - Assert.DoesNotThrow(() => result = GpuLoadSensor.GetAvailableGpus()); - Assert.That(result, Is.Not.Null); - } - - // exercises the real 'GPU Engine' performance counters on whatever machine runs this test, rather than synthetic data - skips instead of failing on a machine with no gpu counters - [Test] - [Category("Hardware")] - public void GetAvailableGpus_OnThisMachine_EachGpuReportsAPlausibleLoad() - { - var sleep = 1000; - var gpus = GpuLoadSensor.GetAvailableGpus(); - if (gpus.Count == 0) - { - Assert.Ignore("No 'GPU Engine' performance counters were detected on this machine."); - return; - } - - TestContext.Out.WriteLine($"Detected {gpus.Count} GPU(s):"); - - // GpuLoadSensor caches its counters across calls, so the first read on a freshly-created sensor only primes them and carries little meaning on its own. - // Read once to prime, wait for a real time window to pass, then read again, since percentage-style performance counters need a meaningful delta between samples to be accurate. - var sensors = gpus.Select(gpu => (gpu.Key, gpu.Value, Sensor: new GpuLoadSensor(gpu.Key))).ToList(); - foreach (var (_, _, sensor) in sensors) - sensor.GetGPUUsage(); - - Thread.Sleep(sleep); - - foreach (var (gpuId, gpuName, sensor) in sensors) - { - var usage = sensor.GetGPUUsage(); - var state = sensor.GetState(); - - TestContext.Out.WriteLine($" [{gpuId}] {gpuName}: {state}% (raw value: {usage})"); - - Assert.That(usage, Is.GreaterThanOrEqualTo(0).And.LessThanOrEqualTo(100), - $"GPU '{gpuName}' (id {gpuId}) reported an out-of-range load: {usage}"); - } - - var allGpus = new GpuLoadSensor(); - allGpus.GetGPUUsage(); - Thread.Sleep(sleep); - var averageUsage = allGpus.GetGPUUsage(); - TestContext.Out.WriteLine($" [*] All GPUs (average): {allGpus.GetState()}% (raw value: {averageUsage})"); - - Assert.That(averageUsage, Is.GreaterThanOrEqualTo(0).And.LessThanOrEqualTo(100)); - } -} diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/HASS.Agent.UnitTests.csproj b/src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/HASS.Agent.UnitTests.csproj deleted file mode 100644 index 43a52583..00000000 --- a/src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/HASS.Agent.UnitTests.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - net8.0-windows10.0.22621.0 - latest - enable - enable - false - - false - - - - - - - - - - - - - - - - - - - diff --git a/src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/Settings/StoredSensorsTests.cs b/src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/Settings/StoredSensorsTests.cs deleted file mode 100644 index aa64b77b..00000000 --- a/src/HASS.Agent/UnitTests/HASS.Agent.UnitTests/Settings/StoredSensorsTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using HASS.Agent.Settings; -using HASS.Agent.Shared.Enums; -using HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.SingleValue; -using HASS.Agent.Shared.Models.Config; - -namespace HASS.Agent.UnitTests.Settings; - -public class StoredSensorsTests -{ - [TestCase("*")] - [TestCase("0")] - [TestCase("1")] - public void ConvertConfiguredToAbstractSingleValue_GpuLoadSensor_PassesQueryThroughAsGpuId(string query) - { - var configured = new ConfiguredSensor - { - Type = SensorType.GpuLoadSensor, - Id = Guid.NewGuid(), - EntityName = "gpuload", - Name = "GPU Load", - UpdateInterval = 30, - Query = query - }; - - var sensor = StoredSensors.ConvertConfiguredToAbstractSingleValue(configured); - - Assert.That(sensor, Is.InstanceOf()); - Assert.That(((GpuLoadSensor)sensor).GpuId, Is.EqualTo(query)); - } - - [Test] - public void ConvertConfiguredToAbstractSingleValue_GpuLoadSensor_EmptyQuery_DefaultsToAllGpus() - { - // sensors configured before per-gpu selection existed have an empty Query - they should keep working as 'all' - var configured = new ConfiguredSensor - { - Type = SensorType.GpuLoadSensor, - Id = Guid.NewGuid(), - EntityName = "gpuload", - Name = "GPU Load", - UpdateInterval = 30, - Query = string.Empty - }; - - var sensor = StoredSensors.ConvertConfiguredToAbstractSingleValue(configured); - - Assert.That(((GpuLoadSensor)sensor).GpuId, Is.EqualTo("*")); - } - - [Test] - public void ConvertConfiguredToAbstractSingleValue_GpuLoadSensor_PassesThroughOtherFields() - { - var id = Guid.NewGuid(); - var configured = new ConfiguredSensor - { - Type = SensorType.GpuLoadSensor, - Id = id, - EntityName = "my_gpu", - Name = "My GPU", - UpdateInterval = 45, - Query = "0" - }; - - var sensor = StoredSensors.ConvertConfiguredToAbstractSingleValue(configured); - - Assert.That(sensor.Id, Is.EqualTo(id.ToString())); - Assert.That(sensor.EntityName, Is.EqualTo("my_gpu")); - Assert.That(sensor.Name, Is.EqualTo("My GPU")); - Assert.That(sensor.UpdateIntervalSeconds, Is.EqualTo(45)); - } -}