From d43173fe4272a674e95203abbc01e16603f1a9da Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 23 Aug 2026 21:20:19 +0200 Subject: [PATCH] feat(core): add sensor and GPU telemetry collector --- .../Collectors/Sensors/ISensorProvider.cs | 8 +++ .../Collectors/Sensors/MockSensorProvider.cs | 53 +++++++++++++++++++ .../Collectors/Sensors/NvidiaSmiParser.cs | 38 +++++++++++++ .../Collectors/Sensors/SensorModels.cs | 20 +++++++ .../Collectors/Sensors/SshSensorProvider.cs | 51 ++++++++++++++++++ .../Collectors/Sensors/ThermalZoneParser.cs | 31 +++++++++++ 6 files changed, 201 insertions(+) create mode 100644 src/Vantage.Core/Collectors/Sensors/ISensorProvider.cs create mode 100644 src/Vantage.Core/Collectors/Sensors/MockSensorProvider.cs create mode 100644 src/Vantage.Core/Collectors/Sensors/NvidiaSmiParser.cs create mode 100644 src/Vantage.Core/Collectors/Sensors/SensorModels.cs create mode 100644 src/Vantage.Core/Collectors/Sensors/SshSensorProvider.cs create mode 100644 src/Vantage.Core/Collectors/Sensors/ThermalZoneParser.cs diff --git a/src/Vantage.Core/Collectors/Sensors/ISensorProvider.cs b/src/Vantage.Core/Collectors/Sensors/ISensorProvider.cs new file mode 100644 index 0000000..92033d4 --- /dev/null +++ b/src/Vantage.Core/Collectors/Sensors/ISensorProvider.cs @@ -0,0 +1,8 @@ +using Vantage.Core.Models; + +namespace Vantage.Core.Collectors.Sensors; + +public interface ISensorProvider +{ + Task GetSnapshotAsync(HostConfig host, CancellationToken cancellationToken); +} diff --git a/src/Vantage.Core/Collectors/Sensors/MockSensorProvider.cs b/src/Vantage.Core/Collectors/Sensors/MockSensorProvider.cs new file mode 100644 index 0000000..40af85b --- /dev/null +++ b/src/Vantage.Core/Collectors/Sensors/MockSensorProvider.cs @@ -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 clock; + + public MockSensorProvider(Func? clock = null) + { + this.clock = clock ?? (() => DateTimeOffset.UtcNow); + } + + public Task 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(); + + 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; + } + } +} diff --git a/src/Vantage.Core/Collectors/Sensors/NvidiaSmiParser.cs b/src/Vantage.Core/Collectors/Sensors/NvidiaSmiParser.cs new file mode 100644 index 0000000..43975c5 --- /dev/null +++ b/src/Vantage.Core/Collectors/Sensors/NvidiaSmiParser.cs @@ -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 Parse(string csvOutput) + { + var gpus = new List(); + + 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; +} diff --git a/src/Vantage.Core/Collectors/Sensors/SensorModels.cs b/src/Vantage.Core/Collectors/Sensors/SensorModels.cs new file mode 100644 index 0000000..b7b38e3 --- /dev/null +++ b/src/Vantage.Core/Collectors/Sensors/SensorModels.cs @@ -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 Temperatures, + IReadOnlyList Gpus) +{ + public static readonly SensorSnapshot Empty = + new(Array.Empty(), Array.Empty()); + + public double? HottestCelsius => Temperatures.Count == 0 ? null : Temperatures.Max(t => t.Celsius); +} diff --git a/src/Vantage.Core/Collectors/Sensors/SshSensorProvider.cs b/src/Vantage.Core/Collectors/Sensors/SshSensorProvider.cs new file mode 100644 index 0000000..96f4c34 --- /dev/null +++ b/src/Vantage.Core/Collectors/Sensors/SshSensorProvider.cs @@ -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 GetSnapshotAsync(HostConfig host, CancellationToken cancellationToken) + { + var output = await runner.RunAsync(host, BuildCommand(), cancellationToken).ConfigureAwait(false); + return ParseBundle(output); + } +} diff --git a/src/Vantage.Core/Collectors/Sensors/ThermalZoneParser.cs b/src/Vantage.Core/Collectors/Sensors/ThermalZoneParser.cs new file mode 100644 index 0000000..7e54881 --- /dev/null +++ b/src/Vantage.Core/Collectors/Sensors/ThermalZoneParser.cs @@ -0,0 +1,31 @@ +using System.Globalization; + +namespace Vantage.Core.Collectors.Sensors; + +public static class ThermalZoneParser +{ + public static IReadOnlyList Parse(string zoneOutput) + { + var readings = new List(); + + 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; + } +}