Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/HASS.Agent.sln
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -68,7 +68,7 @@ Global
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C17243F4-9756-47EF-9AA8-86C64B7582A6}
VisualSVNWorkingCopyRoot = .
SolutionGuid = {C17243F4-9756-47EF-9AA8-86C64B7582A6}
EndGlobalSection
EndGlobal
3 changes: 3 additions & 0 deletions src/HASS.Agent/HASS.Agent.Satellite.Service/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("HASS.Agent.Satellite.Service.UnitTests")]
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/HASS.Agent/HASS.Agent.Shared/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("HASS.Agent.Shared.UnitTests")]
13 changes: 13 additions & 0 deletions src/HASS.Agent/HASS.Agent.Shared/Constants/SensorConstants.cs
Original file line number Diff line number Diff line change
@@ -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);
}
1 change: 1 addition & 0 deletions src/HASS.Agent/HASS.Agent.Shared/HASS.Agent.Shared.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
<PackageReference Include="System.Reactive" Version="6.0.1" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="9.0.6" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="9.0.6" />
<PackageReference Include="Vanara.PInvoke.DXGI" Version="4.2.1" />
<PackageReference Include="Vanara.PInvoke.PowrProf" Version="4.2.1" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -17,19 +18,20 @@ 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; }
private readonly bool _useSpecificCard = false;

public override sealed Dictionary<string, AbstractSingleValueSensor> Sensors { get; protected set; } = new Dictionary<string, AbstractSingleValueSensor>();

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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
using System.Diagnostics;
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.Threading;
using HASS.Agent.Shared.Managers;
using HASS.Agent.Shared.Models.HomeAssistant;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using Vanara.PInvoke;
using static Vanara.PInvoke.DXGI;

namespace HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.SingleValue;

Expand All @@ -12,13 +16,52 @@ namespace HASS.Agent.Shared.HomeAssistant.Sensors.GeneralSensors.SingleValue;
/// </summary>
public class GpuLoadSensor : AbstractSingleValueSensor
{
/// <summary>
/// The default entity name and friendly name for this sensor
/// </summary>
private const string DefaultName = "gpuload";

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)
{
/// <summary>
/// The special value for <see cref="GpuId"/> that indicates the sensor should average across all detected GPUs, rather than reporting on a specific adapter
/// </summary>
private const string AllGpus = SensorConstants.DropdownAll;

/// <summary>
/// The regex used to extract the adapter luid from a GPU Engine counter instance name (eg. 'luid_0x00000000_0x00016e08_engtype_3D').
/// </summary>
private static readonly Regex AdapterLuidRegex = SensorConstants.LuidRegex;

/// <summary>
/// The adapter luid (eg. '0x00000000_0x00016e08') to report on, or '*' to average across every detected gpu
/// </summary>
public string GpuId { get; protected set; }

/// <summary>
/// Indicates whether the sensor is reporting on a specific adapter (true) or averaging across all detected adapters (false)
/// </summary>
private readonly bool _useSpecificGpu;

/// <summary>
/// The cached 'GPU Engine' counters.
/// </summary>
private readonly Dictionary<string, PerformanceCounter> _engineCounterCache = new();

/// <summary>
/// Creates a new gpu load sensor
/// </summary>
/// <param name="gpuId">The adapter luid (eg. '0x00000000_0x00016e08') to report on, or '*' to average across every detected gpu</param>
/// <param name="updateInterval">How often, in seconds, the sensor's value is refreshed</param>
/// <param name="entityName">The entity's unique name, used in its mqtt topic</param>
/// <param name="name">The entity's friendly (display) name</param>
/// <param name="id">The entity's unique id</param>
/// <param name="advancedSettings">Serialized advanced settings (device class, unit of measurement, state class) overriding the auto discovery config</param>
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;
}

///<inheritdoc/>
public override DiscoveryConfigModel GetAutoDiscoveryConfig()
{
if (Variables.MqttManager == null)
Expand All @@ -41,32 +84,174 @@ public override DiscoveryConfigModel GetAutoDiscoveryConfig()
});
}

///<inheritdoc/>
public override string GetState()
{
return GetGPUUsage().ToString("#.##", CultureInfo.InvariantCulture);
return GetGPUUsage().ToString("0.##", CultureInfo.InvariantCulture);
}

///<inheritdoc/>
public override string GetAttributes() => string.Empty;

/// <summary>
/// Returns the current GPU usage, either for a specific adapter (if <see cref="GpuId"/> is set to a luid) or averaged across all detected adapters (if <see cref="GpuId"/> is set to '*')
/// </summary>
/// <returns>The current GPU usage as a float value</returns>
public float GetGPUUsage()
{
try
{
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();
return SelectGpuUsage(GetPerGpuUsage(), GpuId, _useSpecificGpu);
}
catch
{
return 0;
}
}

/// <summary>
/// Pure selection logic: picks a single GPU's usage, or averages across all of them when none is specified
/// </summary>
internal static float SelectGpuUsage(IReadOnlyDictionary<string, float> perGpuUsage, string gpuId, bool useSpecificGpu)
{
if (perGpuUsage.Count == 0)
return 0;

if (useSpecificGpu)
return perGpuUsage.TryGetValue(gpuId, out var usage) ? usage : 0;

return perGpuUsage.Values.Average();
}

/// <summary>
/// Reads the 'GPU Engine' 3D counters and sums them per physical adapter (identified by its luid).
/// Counter objects are kept in <see cref="_engineCounterCache"/> 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
/// </summary>
private Dictionary<string, float> GetPerGpuUsage()
{
// Get the list of current GPU Engine counters
var category = new PerformanceCounterCategory("GPU Engine");
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<PerformanceCounter>();
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);
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Lowercased because Windows doesn't consistently capitalize the hex digits across different instance names for the same adapter, and this must match <see cref="FormatLuid"/>'s output exactly
/// </remarks>
internal static string GetAdapterLuid(string instanceName)
{
var match = AdapterLuidRegex.Match(instanceName);
return match.Success ? match.Groups[1].Value.ToLowerInvariant() : instanceName;
}

/// <summary>
/// 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 <see cref="GetAvailableGpus"/> doesn't know about.
/// Otherwise an unselectable phantom adapter could still silently skew the 'all gpus' average.
/// </summary>
internal static IEnumerable<string> FilterToKnownAdapters(IEnumerable<string> instanceNames, IEnumerable<string> knownGpuLuids)
{
var knownSet = knownGpuLuids is ISet<string> set ? set : new HashSet<string>(knownGpuLuids);
return instanceNames.Where(name => knownSet.Contains(GetAdapterLuid(name)));
}

/// <summary>
/// Pure aggregation logic: sums each sample's value per adapter, grouped by its luid (the same luid <see cref="GetAvailableGpus"/> uses as the GpuId, so no separate index-matching scheme is needed)
/// </summary>
internal static Dictionary<string, float> AggregateUsageByAdapter(IEnumerable<(string InstanceName, float Value)> samples)
{
return samples
.GroupBy(s => GetAdapterLuid(s.InstanceName))
.ToDictionary(g => g.Key, g => g.Sum(s => s.Value));
}

/// <summary>
/// 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
/// </summary>
public static Dictionary<string, string> GetAvailableGpus()
{
var gpus = new Dictionary<string, string>();

IDXGIFactory1 factory = null;
try
{
var factoryResult = CreateDXGIFactory1(typeof(IDXGIFactory1).GUID, out var factoryObj);
if (factoryResult.Failed || factoryObj is not IDXGIFactory1 dxgiFactory)
return gpus;

factory = dxgiFactory;

gpuCounters.ForEach(x => { _ = x.NextValue(); });
Thread.Sleep(10); //TODO(Amadeo): fix this
for (uint adapterIndex = 0; ; adapterIndex++)
{
IDXGIAdapter1 adapter = null;
try
{
if (factory.EnumAdapters1(adapterIndex, out adapter).Failed || adapter == null)
break;

return gpuCounters.Sum(x => x.NextValue());
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
{
return 0;
// best effort, no gpus found
}
finally
{
if (factory != null)
Marshal.ReleaseComObject(factory);
}

return gpus;
}

/// <summary>
/// Formats a DXGI LUID exactly as it appears inside a 'GPU Engine' counter instance name (eg. '0x00000000_0x00016e08').
/// Lowercase 'x8' to match <see cref="GetAdapterLuid"/>'s normalization - the two must always agree
/// </summary>
private static string FormatLuid(LUID luid) => $"0x{(uint)luid.HighPart:x8}_0x{luid.LowPart:x8}";
}
3 changes: 3 additions & 0 deletions src/HASS.Agent/HASS.Agent/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("HASS.Agent.UnitTests")]
Loading