feat(core): add sensor and GPU telemetry collector

This commit is contained in:
dualfroz committed 2026-08-23 21:20:19 +02:00
1 parent 5c8c6d565f
commit d43173fe42
6 files changed
+201

No files matched your search

@@ -0,0 +1,8 @@
using Vantage.Core.Models;
namespace Vantage.Core.Collectors.Sensors;
public interface ISensorProvider
{
Task<SensorSnapshot> GetSnapshotAsync(HostConfig host, CancellationToken cancellationToken);
}
@@ -0,0 +1,53 @@
using Vantage.Core.Models;
namespace Vantage.Core.Collectors.Sensors;
public sealed class MockSensorProvider : ISensorProvider
{
private readonly ConcurrentProfiles profiles = new();
private readonly Func<DateTimeOffset> clock;
public MockSensorProvider(Func<DateTimeOffset>? clock = null)
{
this.clock = clock ?? (() => DateTimeOffset.UtcNow);
}
public Task<SensorSnapshot> GetSnapshotAsync(HostConfig host, CancellationToken cancellationToken)
{
var seconds = clock().ToUnixTimeSeconds();
var wave = Math.Sin(seconds / 120.0) * 6;
var temperatures = new[]
{
new TemperatureReading("x86_pkg_temp", Math.Round(48 + wave, 1)),
new TemperatureReading("coretemp Core 0", Math.Round(46 + wave * 0.8, 1)),
new TemperatureReading("nvme Composite", Math.Round(38 + wave * 0.4, 1)),
new TemperatureReading("acpitz", Math.Round(41 + wave * 0.5, 1))
};
var gpus = profiles.HasGpu(host.Id)
? new[]
{
new GpuReading(
"NVIDIA GeForce RTX 3060",
Math.Round(52 + wave, 1),
Math.Round(Math.Clamp(30 + wave * 4, 0, 100), 1),
3_200L * 1024 * 1024,
12_288L * 1024 * 1024)
}
: Array.Empty<GpuReading>();
return Task.FromResult(new SensorSnapshot(temperatures, gpus));
}
private sealed class ConcurrentProfiles
{
public bool HasGpu(string hostId)
{
var hash = 0;
foreach (var c in hostId)
hash = hash * 31 + c;
return (hash & 1) == 0;
}
}
}
@@ -0,0 +1,38 @@
using System.Globalization;
namespace Vantage.Core.Collectors.Sensors;
public static class NvidiaSmiParser
{
public const string QueryArgs =
"--query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total " +
"--format=csv,noheader,nounits";
public static IReadOnlyList<GpuReading> Parse(string csvOutput)
{
var gpus = new List<GpuReading>();
foreach (var rawLine in csvOutput.Replace("\r\n", "\n").Split('\n'))
{
var line = rawLine.Trim();
if (line.Length == 0)
continue;
var fields = line.Split(',', StringSplitOptions.TrimEntries);
if (fields.Length < 5)
continue;
gpus.Add(new GpuReading(
Name: fields[0],
TemperatureCelsius: ParseDouble(fields[1]),
UtilizationPercent: ParseDouble(fields[2]),
MemoryUsedBytes: (long)(ParseDouble(fields[3]) * 1024 * 1024),
MemoryTotalBytes: (long)(ParseDouble(fields[4]) * 1024 * 1024)));
}
return gpus;
}
private static double ParseDouble(string value) =>
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed : 0;
}
@@ -0,0 +1,20 @@
namespace Vantage.Core.Collectors.Sensors;
public readonly record struct TemperatureReading(string Label, double Celsius);
public readonly record struct GpuReading(
string Name,
double TemperatureCelsius,
double UtilizationPercent,
long MemoryUsedBytes,
long MemoryTotalBytes);
public sealed record SensorSnapshot(
IReadOnlyList<TemperatureReading> Temperatures,
IReadOnlyList<GpuReading> Gpus)
{
public static readonly SensorSnapshot Empty =
new(Array.Empty<TemperatureReading>(), Array.Empty<GpuReading>());
public double? HottestCelsius => Temperatures.Count == 0 ? null : Temperatures.Max(t => t.Celsius);
}
@@ -0,0 +1,51 @@
using Vantage.Core.Models;
using Vantage.Core.Ssh;
namespace Vantage.Core.Collectors.Sensors;
public sealed class SshSensorProvider : ISensorProvider
{
public const string GpuMarker = "@@vantage:gpu";
private readonly ISshCommandRunner runner;
public SshSensorProvider(ISshCommandRunner runner)
{
this.runner = runner;
}
public static string BuildCommand() =>
"for z in /sys/class/thermal/thermal_zone*; do " +
"[ -r \"$z/temp\" ] && echo \"$(cat \"$z/type\" 2>/dev/null):$(cat \"$z/temp\" 2>/dev/null)\"; done ; " +
$"echo '{GpuMarker}' ; " +
$"(command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi {NvidiaSmiParser.QueryArgs} 2>/dev/null || true)";
public static SensorSnapshot ParseBundle(string bundle)
{
var normalized = bundle.Replace("\r\n", "\n");
var markerIndex = normalized.IndexOf(GpuMarker, StringComparison.Ordinal);
string thermalPart;
string gpuPart;
if (markerIndex < 0)
{
thermalPart = normalized;
gpuPart = "";
}
else
{
thermalPart = normalized[..markerIndex];
gpuPart = normalized[(markerIndex + GpuMarker.Length)..];
}
return new SensorSnapshot(
ThermalZoneParser.Parse(thermalPart),
NvidiaSmiParser.Parse(gpuPart));
}
public async Task<SensorSnapshot> GetSnapshotAsync(HostConfig host, CancellationToken cancellationToken)
{
var output = await runner.RunAsync(host, BuildCommand(), cancellationToken).ConfigureAwait(false);
return ParseBundle(output);
}
}
@@ -0,0 +1,31 @@
using System.Globalization;
namespace Vantage.Core.Collectors.Sensors;
public static class ThermalZoneParser
{
public static IReadOnlyList<TemperatureReading> Parse(string zoneOutput)
{
var readings = new List<TemperatureReading>();
foreach (var rawLine in zoneOutput.Replace("\r\n", "\n").Split('\n'))
{
var line = rawLine.Trim();
if (line.Length == 0)
continue;
var separator = line.LastIndexOf(':');
if (separator <= 0 || separator == line.Length - 1)
continue;
var label = line[..separator].Trim();
var rawValue = line[(separator + 1)..].Trim();
if (!long.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var milliCelsius))
continue;
readings.Add(new TemperatureReading(label, Math.Round(milliCelsius / 1000.0, 1)));
}
return readings;
}
}