v0.23.0 — SteamVR / OpenVR : check VrDevice avec batterie + tracking

Nouveau Kind « VrDevice » dans le bandeau de santé. Pour chaque appareil
SteamVR (HMD, manettes, trackers, base stations) on lit via OpenVR :
batterie %, état de charge, niveau d'activité (actif / veille / inactif)
et serial number. Severity du pill calculée sur le batterie : < 15% rouge,
< 30% orange, sinon vert ; charging force le vert.

Implémentation : P/Invoke direct dans openvr_api.dll (BSD-3) — pas de
NuGet, pas de bundling de la DLL. La runtime est résolue dynamiquement
via DllImportResolver depuis :
  1. %LocalAppData%\openvr\openvrpaths.vrpath (canon écrit par SteamVR),
  2. fallback C:\Program Files (x86)\Steam\steamapps\common\SteamVR\bin\win64\.

Si SteamVR n'est pas installé → pill « SteamVR non installé » (Unknown,
gris). Si SteamVR pas lancé → pill « SteamVR non lancé / aucun HMD »
(Error, rouge). Init en mode VRApplication_Background, donc on lit l'état
sans devenir scene application ni monopoliser le compositor.

Éditeur : nouveau radio « Appareil SteamVR » à côté de Process / Ping ;
quand il est sélectionné, le champ Target devient une ComboBox d'alias
(HMD, Manette gauche/droite, Tracker 1-3, Base station 1-2) au lieu du
TextBox libre. Le mapping alias → TrackedDeviceIndex se fait à chaque
query (controllers via GetTrackedDeviceIndexForControllerRole, trackers
et base stations par énumération avec compteur de classe).

Robustesse : init lazy une fois pour toute la vie du process (init/shutdown
répétés coûtent 100-500 ms). Vtable IVRSystem_022 résolue par index slot
dans une struct contiguë de IntPtr (pas de struct marshaling, donc immune
aux changements de slots qu'on n'utilise pas). Si SteamVR crash en cours
de session, le service détecte au prochain tick et marque la session
broken pour retenter au tick suivant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 19:29:57 +02:00
parent 211af9dd85
commit 4d0e287227
11 changed files with 824 additions and 29 deletions

View File

@@ -0,0 +1,49 @@
namespace PSLauncher.Core.Health.OpenVRInterop;
/// <summary>
/// Lecture en arrière-plan (sans démarrer SteamVR ni prendre le compositor)
/// de l'état des devices SteamVR : HMD, controllers, trackers, base stations.
/// Implémentation : <see cref="OpenVrService"/>.
/// </summary>
public interface IOpenVrService
{
/// <summary>
/// Interroge un device par alias :
/// <c>hmd</c>, <c>left_controller</c>, <c>right_controller</c>,
/// <c>tracker_1..N</c>, <c>base_station_1..N</c>.
/// </summary>
VrDeviceQueryResult Query(string deviceAlias);
}
/// <summary>
/// État de disponibilité de la runtime OpenVR. <c>RuntimeMissing</c> = openvr_api.dll
/// introuvable (SteamVR pas installé). <c>HmdAbsent</c> = SteamVR installé mais
/// vrserver pas lancé ou aucun HMD branché. <c>InitFailed</c> = API trouvée mais
/// VR_InitInternal2 a échoué (cas rare : permission denied, version mismatch).
/// <c>Ready</c> = OK, on peut lire les properties.
/// </summary>
public enum VrRuntimeState
{
Unknown,
RuntimeMissing,
HmdAbsent,
InitFailed,
Ready,
}
/// <summary>
/// Résultat d'une requête sur un device. <see cref="Found"/> est false quand
/// l'alias résolu ne correspond à aucun device connecté (ex. tracker_3 absent).
/// </summary>
public sealed record VrDeviceQueryResult(
VrRuntimeState RuntimeState,
bool Found,
string DeviceLabel, // ex. "HMD", "Manette gauche"
string? Serial, // serial number ou null
OpenVRNative.ETrackedDeviceClass DeviceClass,
bool? IsConnected,
bool? IsCharging,
bool? IsWireless,
float? BatteryPercentage, // 0.01.0, null si propriété non exposée par le device
OpenVRNative.EDeviceActivityLevel? ActivityLevel,
string? ErrorDetail);

View File

@@ -0,0 +1,237 @@
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
namespace PSLauncher.Core.Health.OpenVRInterop;
/// <summary>
/// P/Invoke minimal vers <c>openvr_api.dll</c> (SDK Valve OpenVR, BSD-3).
/// On NE bundle PAS la DLL : on la résout dynamiquement via la copie installée
/// par SteamVR (chemin canonique <c>%LocalAppData%\openvr\openvrpaths.vrpath</c>),
/// donc :
/// - SteamVR pas installé → la DLL n'est pas trouvée et init renvoie une erreur claire,
/// - SteamVR mis à jour → on profite automatiquement de la dernière openvr_api.dll
/// sans avoir à respinner un build du launcher.
///
/// Le SDK OpenVR expose une "flat C API" : quelques fonctions globales pour
/// init/shutdown, et une <c>GetGenericInterface("FnTable:IVRSystem_022")</c>
/// qui renvoie un pointeur sur une struct C de pointeurs de fonction (vtable).
/// Cette classe expose juste ce qu'on consomme dans <see cref="OpenVrService"/> :
/// présence de la runtime, présence d'un HMD, et la vtable IVRSystem.
/// </summary>
public static class OpenVRNative
{
private const string DllName = "openvr_api";
/// <summary>
/// EVRApplicationType passé à VR_InitInternal2. <c>Background = 4</c> : on
/// veut LIRE l'état de SteamVR sans déclencher de rendu, sans devenir une
/// scene application, sans monopoliser le compositor.
/// </summary>
public const int VRApplicationBackground = 4;
/// <summary>Indices de slots dans la vtable IVRSystem_022. Stables depuis SteamVR 1.10 (2019).</summary>
public static class IVRSystemSlot
{
public const int GetTrackedDeviceActivityLevel = 16;
public const int GetTrackedDeviceIndexForControllerRole = 18;
public const int GetTrackedDeviceClass = 20;
public const int IsTrackedDeviceConnected = 21;
public const int GetBoolTrackedDeviceProperty = 22;
public const int GetFloatTrackedDeviceProperty = 23;
public const int GetStringTrackedDeviceProperty = 28;
}
/// <summary>
/// Indices ETrackedDeviceProperty les plus utiles pour notre usage. Liste
/// complète dans <c>openvr.h</c> ; on en garde un sous-ensemble pour ne pas
/// dupliquer 800 enums dont 99% inutiles.
/// </summary>
public static class DeviceProperty
{
public const int Prop_DeviceIsWireless_Bool = 1003;
public const int Prop_DeviceIsCharging_Bool = 1004;
public const int Prop_DeviceProvidesBatteryStatus_Bool = 1026; // varie par version, fallback safe sur GetBool
public const int Prop_DeviceBatteryPercentage_Float = 1029;
public const int Prop_SerialNumber_String = 1002;
public const int Prop_TrackingSystemName_String = 1000;
public const int Prop_ModelNumber_String = 1001;
}
/// <summary>Classe OpenVR d'un device tracké. 1=HMD, 2=Controller, 3=GenericTracker, 4=TrackingReference.</summary>
public enum ETrackedDeviceClass
{
Invalid = 0,
HMD = 1,
Controller = 2,
GenericTracker = 3,
TrackingReference = 4,
DisplayRedirect = 5,
}
public enum ETrackedControllerRole
{
Invalid = 0,
LeftHand = 1,
RightHand = 2,
OptOut = 3,
Treadmill = 4,
Stylus = 5,
}
/// <summary>
/// Niveau d'activité du device. Idle = en veille, n'envoie plus de pose.
/// Standby = posé mais réveille rapidement. UserInteraction = en main.
/// </summary>
public enum EDeviceActivityLevel
{
Unknown = -1,
Idle = 0,
UserInteraction = 1,
UserInteraction_Timeout = 2,
Standby = 3,
Idle_Timeout = 4,
}
[DllImport(DllName, EntryPoint = "VR_IsRuntimeInstalled", CallingConvention = CallingConvention.Cdecl)]
public static extern bool VR_IsRuntimeInstalled();
[DllImport(DllName, EntryPoint = "VR_IsHmdPresent", CallingConvention = CallingConvention.Cdecl)]
public static extern bool VR_IsHmdPresent();
[DllImport(DllName, EntryPoint = "VR_InitInternal2", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
public static extern uint VR_InitInternal2(out int peError, int eApplicationType, [MarshalAs(UnmanagedType.LPStr)] string? pStartupInfo);
[DllImport(DllName, EntryPoint = "VR_ShutdownInternal", CallingConvention = CallingConvention.Cdecl)]
public static extern void VR_ShutdownInternal();
[DllImport(DllName, EntryPoint = "VR_GetGenericInterface", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
public static extern IntPtr VR_GetGenericInterface([MarshalAs(UnmanagedType.LPStr)] string pchInterfaceVersion, out int peError);
[DllImport(DllName, EntryPoint = "VR_GetVRInitErrorAsEnglishDescription", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
public static extern IntPtr VR_GetVRInitErrorAsEnglishDescription(int error);
/// <summary>
/// Récupère un pointeur de fonction depuis la vtable IVRSystem (struct
/// contiguë de IntPtr à <paramref name="vtablePtr"/>) au slot demandé.
/// </summary>
public static IntPtr GetVTableSlot(IntPtr vtablePtr, int slotIndex)
{
unsafe
{
return ((IntPtr*)vtablePtr)[slotIndex];
}
}
public static string GetInitErrorDescription(int error)
{
try
{
var ptr = VR_GetVRInitErrorAsEnglishDescription(error);
return ptr == IntPtr.Zero ? $"unknown ({error})" : Marshal.PtrToStringAnsi(ptr) ?? $"unknown ({error})";
}
catch { return $"error code {error}"; }
}
// ============= DLL RESOLUTION =============
private static int _resolverInstalled;
/// <summary>
/// Installe (idempotent) un <see cref="DllImportResolver"/> qui, lorsque
/// .NET demande "openvr_api", le résout vers la DLL de l'install SteamVR
/// active. Pas d'effet de bord si SteamVR est absent — la DllImport
/// échouera ensuite comme d'habitude et le service catch.
/// </summary>
public static void EnsureResolverInstalled()
{
if (Interlocked.Exchange(ref _resolverInstalled, 1) == 1) return;
try
{
NativeLibrary.SetDllImportResolver(typeof(OpenVRNative).Assembly, ResolveOpenVrDll);
}
catch
{
// Déjà installé ailleurs, ou plateforme qui ne supporte pas. On laisse
// le DllImport faire son travail (cherchera dans le PATH).
}
}
private static IntPtr ResolveOpenVrDll(string libraryName, System.Reflection.Assembly assembly, DllImportSearchPath? searchPath)
{
if (!string.Equals(libraryName, DllName, StringComparison.OrdinalIgnoreCase))
return IntPtr.Zero;
var path = LocateOpenVrDll();
if (path is null) return IntPtr.Zero;
try { return NativeLibrary.Load(path); }
catch { return IntPtr.Zero; }
}
/// <summary>
/// Cherche openvr_api.dll dans cet ordre :
/// 1. <c>%LocalAppData%\openvr\openvrpaths.vrpath</c> (canon écrit par SteamVR au setup).
/// 2. Dossier Steam standard : <c>C:\Program Files (x86)\Steam\steamapps\common\SteamVR\bin\win64\</c>.
/// </summary>
private static string? LocateOpenVrDll()
{
// 1. Lire openvrpaths.vrpath (JSON)
try
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var pathsFile = Path.Combine(localAppData, "openvr", "openvrpaths.vrpath");
if (File.Exists(pathsFile))
{
var json = File.ReadAllText(pathsFile, Encoding.UTF8);
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("runtime", out var runtimes) && runtimes.ValueKind == JsonValueKind.Array)
{
foreach (var elt in runtimes.EnumerateArray())
{
var dir = elt.GetString();
if (string.IsNullOrEmpty(dir)) continue;
var dll = Path.Combine(dir, "bin", "win64", "openvr_api.dll");
if (File.Exists(dll)) return dll;
}
}
}
}
catch { /* fallback */ }
// 2. Chemin Steam standard
try
{
var steamPath = @"C:\Program Files (x86)\Steam\steamapps\common\SteamVR\bin\win64\openvr_api.dll";
if (File.Exists(steamPath)) return steamPath;
}
catch { }
return null;
}
// ============= DELEGATES POUR LES SLOTS UTILISÉS =============
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate EDeviceActivityLevel GetTrackedDeviceActivityLevelFn(uint deviceIndex);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate uint GetTrackedDeviceIndexForControllerRoleFn(ETrackedControllerRole role);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate ETrackedDeviceClass GetTrackedDeviceClassFn(uint deviceIndex);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
[return: MarshalAs(UnmanagedType.I1)]
public delegate bool IsTrackedDeviceConnectedFn(uint deviceIndex);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
[return: MarshalAs(UnmanagedType.I1)]
public delegate bool GetBoolTrackedDevicePropertyFn(uint deviceIndex, int property, out int peError);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate float GetFloatTrackedDevicePropertyFn(uint deviceIndex, int property, out int peError);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate uint GetStringTrackedDevicePropertyFn(uint deviceIndex, int property, IntPtr buffer, uint bufferSize, out int peError);
}

View File

@@ -0,0 +1,335 @@
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Extensions.Logging;
namespace PSLauncher.Core.Health.OpenVRInterop;
/// <summary>
/// Wrapper haut niveau autour de <see cref="OpenVRNative"/>. Singleton
/// thread-safe : init paresseuse au premier appel <see cref="Query"/>, état
/// mémorisé pour les appels suivants. La vtable IVRSystem reste vivante toute
/// la durée de vie du process — coût CPU négligeable au repos, init-shutdown
/// répété coûte cher (centaines de ms).
///
/// Robustesse : tout va dans des try/catch. SteamVR crashé / fermé pendant
/// la session → le service détecte au prochain Query (vtable call lève) et
/// repasse en <see cref="VrRuntimeState.HmdAbsent"/> proprement, prêt à
/// re-tenter au tick suivant.
/// </summary>
public sealed class OpenVrService : IOpenVrService, IDisposable
{
private const string IVRSystemInterfaceVersion = "FnTable:IVRSystem_022";
private readonly ILogger<OpenVrService> _logger;
private readonly object _lock = new();
private bool _resolverEnsured;
private bool _initAttempted;
private uint _initToken;
private IntPtr _vrSystemVTable = IntPtr.Zero;
// Delegates marshalés une seule fois après init pour éviter le coût
// GetDelegateForFunctionPointer à chaque tick.
private OpenVRNative.GetTrackedDeviceActivityLevelFn? _fnActivity;
private OpenVRNative.GetTrackedDeviceIndexForControllerRoleFn? _fnRoleToIndex;
private OpenVRNative.GetTrackedDeviceClassFn? _fnDeviceClass;
private OpenVRNative.IsTrackedDeviceConnectedFn? _fnIsConnected;
private OpenVRNative.GetBoolTrackedDevicePropertyFn? _fnGetBool;
private OpenVRNative.GetFloatTrackedDevicePropertyFn? _fnGetFloat;
private OpenVRNative.GetStringTrackedDevicePropertyFn? _fnGetString;
public OpenVrService(ILogger<OpenVrService> logger)
{
_logger = logger;
}
public VrDeviceQueryResult Query(string deviceAlias)
{
lock (_lock)
{
var state = EnsureReady();
var label = ResolveLabel(deviceAlias);
if (state != VrRuntimeState.Ready)
{
return new VrDeviceQueryResult(state, false, label, null,
OpenVRNative.ETrackedDeviceClass.Invalid,
null, null, null, null, null,
state switch
{
VrRuntimeState.RuntimeMissing => "SteamVR n'est pas installé sur cette machine",
VrRuntimeState.HmdAbsent => "SteamVR n'est pas lancé ou aucun HMD n'est branché",
VrRuntimeState.InitFailed => "Échec d'initialisation OpenVR",
_ => null,
});
}
try
{
var index = ResolveDeviceIndex(deviceAlias);
if (index is null)
{
return new VrDeviceQueryResult(state, false, label, null,
OpenVRNative.ETrackedDeviceClass.Invalid,
false, null, null, null, null,
$"Appareil « {deviceAlias} » non détecté");
}
var idx = index.Value;
var connected = _fnIsConnected!(idx);
if (!connected)
{
return new VrDeviceQueryResult(state, true, label, null,
_fnDeviceClass!(idx),
false, null, null, null, null,
"Appareil déconnecté");
}
var deviceClass = _fnDeviceClass!(idx);
var activity = _fnActivity!(idx);
var serial = ReadStringProperty(idx, OpenVRNative.DeviceProperty.Prop_SerialNumber_String);
bool? wireless = ReadBoolProperty(idx, OpenVRNative.DeviceProperty.Prop_DeviceIsWireless_Bool);
bool? charging = ReadBoolProperty(idx, OpenVRNative.DeviceProperty.Prop_DeviceIsCharging_Bool);
float? battery = ReadFloatProperty(idx, OpenVRNative.DeviceProperty.Prop_DeviceBatteryPercentage_Float);
return new VrDeviceQueryResult(state, true, label, serial,
deviceClass, true, charging, wireless, battery, activity, null);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OpenVR query failed for {Alias}, marking session unhealthy", deviceAlias);
// Marquer l'état comme cassé pour qu'au prochain Query on retente l'init.
MarkSessionBroken();
return new VrDeviceQueryResult(VrRuntimeState.HmdAbsent, false, label, null,
OpenVRNative.ETrackedDeviceClass.Invalid,
null, null, null, null, null,
$"Erreur OpenVR : {ex.Message}");
}
}
}
private VrRuntimeState EnsureReady()
{
if (!_resolverEnsured)
{
OpenVRNative.EnsureResolverInstalled();
_resolverEnsured = true;
}
if (_vrSystemVTable != IntPtr.Zero) return VrRuntimeState.Ready;
// Probe runtime + HMD ; les deux sont des appels rapides qui ne déclenchent
// pas l'init du compositor même si SteamVR est éteint.
try
{
if (!OpenVRNative.VR_IsRuntimeInstalled())
return VrRuntimeState.RuntimeMissing;
}
catch (DllNotFoundException) { return VrRuntimeState.RuntimeMissing; }
catch (Exception ex)
{
_logger.LogDebug(ex, "VR_IsRuntimeInstalled threw");
return VrRuntimeState.RuntimeMissing;
}
try
{
if (!OpenVRNative.VR_IsHmdPresent()) return VrRuntimeState.HmdAbsent;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "VR_IsHmdPresent threw");
return VrRuntimeState.HmdAbsent;
}
// Init en mode Background : on lit les states sans devenir une scene app.
// L'init peut prendre 100-500 ms la première fois ; bloquer le caller est OK
// car le health loop tourne sur thread dédié.
if (_initAttempted && _initToken == 0) return VrRuntimeState.InitFailed;
_initAttempted = true;
try
{
_initToken = OpenVRNative.VR_InitInternal2(out var initError, OpenVRNative.VRApplicationBackground, null);
if (initError != 0 || _initToken == 0)
{
_logger.LogWarning("VR_InitInternal2 failed: {Err}", OpenVRNative.GetInitErrorDescription(initError));
_initToken = 0;
return VrRuntimeState.InitFailed;
}
_vrSystemVTable = OpenVRNative.VR_GetGenericInterface(IVRSystemInterfaceVersion, out var ifaceError);
if (_vrSystemVTable == IntPtr.Zero || ifaceError != 0)
{
_logger.LogWarning("VR_GetGenericInterface({Iface}) failed: {Err}",
IVRSystemInterfaceVersion, OpenVRNative.GetInitErrorDescription(ifaceError));
ShutdownInternal();
return VrRuntimeState.InitFailed;
}
BindVTableDelegates();
return VrRuntimeState.Ready;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OpenVR init threw");
ShutdownInternal();
return VrRuntimeState.InitFailed;
}
}
private void BindVTableDelegates()
{
IntPtr P(int slot) => OpenVRNative.GetVTableSlot(_vrSystemVTable, slot);
_fnActivity = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetTrackedDeviceActivityLevelFn>(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceActivityLevel));
_fnRoleToIndex = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetTrackedDeviceIndexForControllerRoleFn>(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceIndexForControllerRole));
_fnDeviceClass = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetTrackedDeviceClassFn>(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceClass));
_fnIsConnected = Marshal.GetDelegateForFunctionPointer<OpenVRNative.IsTrackedDeviceConnectedFn>(P(OpenVRNative.IVRSystemSlot.IsTrackedDeviceConnected));
_fnGetBool = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetBoolTrackedDevicePropertyFn>(P(OpenVRNative.IVRSystemSlot.GetBoolTrackedDeviceProperty));
_fnGetFloat = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetFloatTrackedDevicePropertyFn>(P(OpenVRNative.IVRSystemSlot.GetFloatTrackedDeviceProperty));
_fnGetString = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetStringTrackedDevicePropertyFn>(P(OpenVRNative.IVRSystemSlot.GetStringTrackedDeviceProperty));
}
private uint? ResolveDeviceIndex(string alias)
{
if (_fnRoleToIndex is null || _fnDeviceClass is null) return null;
var a = alias.Trim().ToLowerInvariant();
const uint K_unMaxTrackedDeviceCount = 64;
const uint K_InvalidIndex = 0xFFFFFFFF;
switch (a)
{
case "hmd":
return 0; // index 0 = HMD principal par convention OpenVR
case "left_controller":
{
var idx = _fnRoleToIndex(OpenVRNative.ETrackedControllerRole.LeftHand);
return idx == K_InvalidIndex ? null : idx;
}
case "right_controller":
{
var idx = _fnRoleToIndex(OpenVRNative.ETrackedControllerRole.RightHand);
return idx == K_InvalidIndex ? null : idx;
}
}
// tracker_N / base_station_N : on énumère tous les devices et on prend le N-ième de la classe demandée
if (TryParseIndexed(a, "tracker_", out var trackerN))
return FindNthOfClass(OpenVRNative.ETrackedDeviceClass.GenericTracker, trackerN);
if (TryParseIndexed(a, "base_station_", out var stationN))
return FindNthOfClass(OpenVRNative.ETrackedDeviceClass.TrackingReference, stationN);
// Fallback : si l'utilisateur a tapé un index brut "0", "1", … on le respecte
if (uint.TryParse(a, out var raw) && raw < K_unMaxTrackedDeviceCount)
return raw;
return null;
}
private static bool TryParseIndexed(string alias, string prefix, out int n)
{
n = 0;
if (!alias.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
var tail = alias[prefix.Length..];
return int.TryParse(tail, out n) && n >= 1;
}
private uint? FindNthOfClass(OpenVRNative.ETrackedDeviceClass targetClass, int n)
{
if (_fnDeviceClass is null) return null;
const uint K_unMaxTrackedDeviceCount = 64;
var found = 0;
for (uint i = 0; i < K_unMaxTrackedDeviceCount; i++)
{
if (_fnDeviceClass(i) == targetClass)
{
found++;
if (found == n) return i;
}
}
return null;
}
private static string ResolveLabel(string alias) => alias.Trim().ToLowerInvariant() switch
{
"hmd" => "HMD",
"left_controller" => "Manette gauche",
"right_controller" => "Manette droite",
var a when a.StartsWith("tracker_") => $"Tracker {a[8..]}",
var a when a.StartsWith("base_station_") => $"Base station {a[13..]}",
_ => alias,
};
private bool? ReadBoolProperty(uint deviceIndex, int property)
{
try
{
var v = _fnGetBool!(deviceIndex, property, out var err);
return err == 0 ? v : null;
}
catch { return null; }
}
private float? ReadFloatProperty(uint deviceIndex, int property)
{
try
{
var v = _fnGetFloat!(deviceIndex, property, out var err);
return err == 0 ? v : null;
}
catch { return null; }
}
private string? ReadStringProperty(uint deviceIndex, int property)
{
try
{
var buf = Marshal.AllocHGlobal(256);
try
{
var written = _fnGetString!(deviceIndex, property, buf, 256, out var err);
if (err != 0 || written == 0) return null;
// OpenVR renvoie un C-string ANSI null-terminé
return Marshal.PtrToStringAnsi(buf, (int)Math.Min(written, 256u)).TrimEnd('\0');
}
finally
{
Marshal.FreeHGlobal(buf);
}
}
catch { return null; }
}
private void MarkSessionBroken()
{
ShutdownInternal();
_initAttempted = false; // permettre une retente au prochain tick
}
private void ShutdownInternal()
{
_vrSystemVTable = IntPtr.Zero;
_fnActivity = null;
_fnRoleToIndex = null;
_fnDeviceClass = null;
_fnIsConnected = null;
_fnGetBool = null;
_fnGetFloat = null;
_fnGetString = null;
if (_initToken != 0)
{
try { OpenVRNative.VR_ShutdownInternal(); } catch { /* ignore */ }
_initToken = 0;
}
}
public void Dispose()
{
lock (_lock) ShutdownInternal();
}
}

View File

@@ -1,5 +1,6 @@
using System.Net.NetworkInformation;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Health.OpenVRInterop;
using PSLauncher.Models;
// NB : on n'importe PAS System.Diagnostics car le namespace PSLauncher.Core.Process
// existe déjà et provoque une ambiguïté avec System.Diagnostics.Process.
@@ -17,24 +18,31 @@ namespace PSLauncher.Core.Health;
public sealed class SystemHealthService : ISystemHealthService
{
private readonly ILogger<SystemHealthService> _logger;
private readonly IOpenVrService _openVr;
public SystemHealthService(ILogger<SystemHealthService> logger)
public SystemHealthService(ILogger<SystemHealthService> logger, IOpenVrService openVr)
{
_logger = logger;
_openVr = openVr;
}
public async Task<HealthResult> RunCheckAsync(HealthCheckEntry entry, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(entry.Target))
var kind = entry.Kind?.Trim().ToLowerInvariant();
// VrDevice n'a PAS besoin d'un Target non vide (HMD = index 0 implicite),
// donc on le sort du gate Target-vide.
if (kind != "vrdevice" && string.IsNullOrWhiteSpace(entry.Target))
return new HealthResult(HealthSeverity.Unknown, "Non configuré (Target vide)");
try
{
return entry.Kind?.Trim().ToLowerInvariant() switch
return kind switch
{
"ping" => await CheckPingAsync(entry, ct).ConfigureAwait(false),
"process" => CheckProcess(entry),
_ => new HealthResult(HealthSeverity.Unknown, $"Kind inconnu : '{entry.Kind}'"),
"ping" => await CheckPingAsync(entry, ct).ConfigureAwait(false),
"process" => CheckProcess(entry),
"vrdevice" => CheckVrDevice(entry),
_ => new HealthResult(HealthSeverity.Unknown, $"Kind inconnu : '{entry.Kind}'"),
};
}
catch (OperationCanceledException) { throw; }
@@ -107,4 +115,76 @@ public sealed class SystemHealthService : ISystemHealthService
foreach (var p in procs) p.Dispose();
}
}
/// <summary>
/// Lit l'état d'un device SteamVR via OpenVR :
/// - Runtime absent → Unknown + "SteamVR non installé"
/// - HMD absent → Unknown + "SteamVR non lancé / aucun HMD branché"
/// - Device cherché non trouvé → Error + "Appareil non détecté"
/// - Battery &lt; 15% (et pas en charge) → Error
/// - Battery 15-30% (et pas en charge) → Warning
/// - Sinon → Ok avec le % batterie
///
/// Pour les <c>TrackingReference</c> (base stations) qui n'exposent pas de
/// batterie, on tombe sur l'état connecté/en veille uniquement.
/// </summary>
private HealthResult CheckVrDevice(HealthCheckEntry entry)
{
// Si Target vide pour VrDevice on assume "hmd" — c'est le cas le plus courant
var alias = string.IsNullOrWhiteSpace(entry.Target) ? "hmd" : entry.Target.Trim();
var result = _openVr.Query(alias);
if (result.RuntimeState == VrRuntimeState.RuntimeMissing)
return new HealthResult(HealthSeverity.Unknown, "SteamVR n'est pas installé sur cette machine");
if (result.RuntimeState == VrRuntimeState.HmdAbsent)
return new HealthResult(HealthSeverity.Error, "SteamVR n'est pas lancé ou aucun HMD n'est branché");
if (result.RuntimeState == VrRuntimeState.InitFailed)
return new HealthResult(HealthSeverity.Error,
$"Échec init OpenVR{(result.ErrorDetail is null ? "" : " : " + result.ErrorDetail)}");
if (!result.Found || result.IsConnected != true)
return new HealthResult(HealthSeverity.Error,
result.ErrorDetail ?? $"{result.DeviceLabel} non détecté");
// Device présent : on assemble un résumé batterie + activité
var battery = result.BatteryPercentage;
var pctText = battery is null ? "—" : $"{Math.Round(battery.Value * 100)}%";
var chargeIcon = result.IsCharging == true ? " ⚡" : "";
var serial = string.IsNullOrEmpty(result.Serial) ? "" : $" · {result.Serial}";
var activityText = result.ActivityLevel switch
{
OpenVRNative.EDeviceActivityLevel.UserInteraction => "actif",
OpenVRNative.EDeviceActivityLevel.UserInteraction_Timeout => "actif (en pause)",
OpenVRNative.EDeviceActivityLevel.Standby => "en veille",
OpenVRNative.EDeviceActivityLevel.Idle => "inactif",
OpenVRNative.EDeviceActivityLevel.Idle_Timeout => "inactif",
_ => null,
};
var detail = $"{result.DeviceLabel} : {pctText}{chargeIcon}" +
(activityText is null ? "" : $" — {activityText}") +
serial;
// Severity : prioriser charging et class spéciale (base stations sans batterie)
if (result.DeviceClass == OpenVRNative.ETrackedDeviceClass.TrackingReference && battery is null)
{
// Base station : connectée = OK (même sans info batterie)
return new HealthResult(HealthSeverity.Ok, detail);
}
if (result.IsCharging == true)
return new HealthResult(HealthSeverity.Ok, detail);
if (battery is null)
{
// Device sans batterie exposée (ex. base station filaire) : on rapporte juste "connecté"
return new HealthResult(HealthSeverity.Ok, detail);
}
var pct = battery.Value;
if (pct < 0.15f) return new HealthResult(HealthSeverity.Error, detail + " (seuil rouge < 15%)");
if (pct < 0.30f) return new HealthResult(HealthSeverity.Warning, detail + " (seuil orange < 30%)");
return new HealthResult(HealthSeverity.Ok, detail);
}
}

View File

@@ -992,6 +992,21 @@ public static class Strings
public static string HealthEditorKind => T("TYPE DE VÉRIFICATION", "CHECK TYPE", "检查类型", "ประเภทการตรวจสอบ", "نوع الفحص");
public static string HealthEditorKindProcess => T("Processus (Windows)", "Process (Windows)", "进程 (Windows)", "โปรเซส (Windows)", "عملية (Windows)");
public static string HealthEditorKindPing => T("Ping (réseau ICMP)", "Ping (ICMP network)", "Ping (ICMP 网络)", "Ping (เครือข่าย ICMP)", "Ping (شبكة ICMP)");
public static string HealthEditorKindVrDevice => T(
"Appareil SteamVR",
"SteamVR device",
"SteamVR 设备",
"อุปกรณ์ SteamVR",
"جهاز SteamVR"
);
public static string HealthEditorTargetVrDevice => T("APPAREIL VR", "VR DEVICE", "VR 设备", "อุปกรณ์ VR", "جهاز VR");
public static string HealthEditorTargetVrDeviceHint => T(
"Lit batterie + état de charge + tracking via OpenVR. SteamVR doit tourner. La runtime est trouvée via la copie installée par SteamVR — rien à bundler.",
"Reads battery + charging + tracking via OpenVR. SteamVR must be running. Runtime is found via SteamVR's installed copy — nothing to bundle.",
"通过 OpenVR 读取电池 + 充电 + 跟踪状态。SteamVR 必须在运行。运行时通过 SteamVR 的安装副本查找 — 无需打包。",
"อ่านแบตเตอรี่ + การชาร์จ + การติดตามผ่าน OpenVR ต้อง SteamVR ทำงาน รันไทม์ค้นหาผ่านสำเนาที่ติดตั้งของ SteamVR — ไม่ต้องบรรจุเอง",
"يقرأ البطارية + الشحن + التتبع عبر OpenVR. يجب تشغيل SteamVR. يتم العثور على وقت التشغيل من خلال نسخة SteamVR المثبتة — لا حاجة للتجميع."
);
public static string HealthEditorTargetProcess => T("CIBLE — NOM DE PROCESSUS", "TARGET — PROCESS NAME", "目标 — 进程名", "เป้าหมาย — ชื่อโปรเซส", "الهدف — اسم العملية");
public static string HealthEditorTargetPing => T("CIBLE — IP OU HOSTNAME", "TARGET — IP OR HOSTNAME", "目标 — IP 或主机名", "เป้าหมาย — IP หรือชื่อโฮสต์", "الهدف — IP أو اسم المضيف");
public static string HealthEditorTargetProcessHint => T(

View File

@@ -5,6 +5,8 @@
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<!-- unsafe requis pour le déréférencement de la vtable OpenVR (Health/OpenVR/OpenVRNative.cs) -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>