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:
335
src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs
Normal file
335
src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user