diff --git a/CHANGELOG.md b/CHANGELOG.md index 59ece5c..2df4343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/). +## [1.4.0] - 2026-08-13 + +### Added +- **SteelSeries Arctis Nova 5/5X provider** (`SteelSeriesNova5` detection mode): reads the headset physical state directly over HID (P/Invoke to `hid.dll`/`setupapi.dll`, no SteelSeries GG or third-party software). Adds `lib/SteelSeriesNova5.psm1`, Pester tests and a diagnostic tool (`tools/Test-SteelSeriesNova5Hid.ps1`) to watch the receiver state live. +- Installer offers a "SteelSeries Arctis Nova 5/5X" option when picking the headset type; it verifies the HID receiver is present and writes `DetectionMode = SteelSeriesNova5`. +- Runtime worker supports the `SteelSeriesNova5` mode (state read every poll; `Unknown` never switches). Verifier shows the receiver presence for this mode. +- The release package now bundles the SteelSeries module and the diagnostic tool. + ## [1.3.0] - 2026-08-13 ### Changed diff --git a/Instalar-PROX2-AutoSwitch.ps1 b/Instalar-PROX2-AutoSwitch.ps1 index 96cfc97..a57b788 100644 --- a/Instalar-PROX2-AutoSwitch.ps1 +++ b/Instalar-PROX2-AutoSwitch.ps1 @@ -7,6 +7,7 @@ $RuntimeSrc = Join-Path $PackageDir "Runtime-PROX2-AutoSwitch.ps1" $UninstallSrc = Join-Path $PackageDir "Desinstalar-PROX2-AutoSwitch.ps1" $VerifySrc = Join-Path $PackageDir "Verificar-PROX2-AutoSwitch.ps1" $ModuleSrc = Join-Path $PackageDir "lib\AutoSwitchCore.psm1" +$SteelModuleSrc = Join-Path $PackageDir "lib\SteelSeriesNova5.psm1" $HelperSrc = Join-Path $PackageDir "Toggle-AudioEnhancements.ps1" $IconSrc = Join-Path $PackageDir "assets\icon.ico" @@ -20,7 +21,7 @@ $StartupDir = [Environment]::GetFolderPath("Startup") $ShortcutPath = Join-Path $StartupDir "PRO X 2 AutoSwitch.lnk" -foreach ($required in @($RuntimeSrc, $UninstallSrc, $VerifySrc, $ModuleSrc, $HelperSrc, $IconSrc)) { +foreach ($required in @($RuntimeSrc, $UninstallSrc, $VerifySrc, $ModuleSrc, $SteelModuleSrc, $HelperSrc, $IconSrc)) { if (-not (Test-Path $required)) { throw "A package file is missing: $required. Extract the full ZIP before installing." } @@ -84,6 +85,7 @@ Copy-Item $HelperSrc (Join-Path $InstallDir "Toggle-AudioEnhancements.ps1") -For Copy-Item $IconSrc (Join-Path $InstallDir "icon.ico") -Force New-Item -ItemType Directory -Path (Join-Path $InstallDir "lib") -Force | Out-Null Copy-Item $ModuleSrc (Join-Path $InstallDir "lib\AutoSwitchCore.psm1") -Force +Copy-Item $SteelModuleSrc (Join-Path $InstallDir "lib\SteelSeriesNova5.psm1") -Force # --- G HUB functions for the installer --- $script:Ws = $null @@ -378,18 +380,22 @@ try { Write-Host " -> Use WindowsEndpoint: Windows detects the endpoint as" -ForegroundColor DarkGray Write-Host " Active when ON and Unplugged/absent when OFF." -ForegroundColor DarkGray Write-Host "" - Write-Host " [2] Logitech PRO X 2 (LIGHTSPEED dongle stays plugged in)" -ForegroundColor White + Write-Host " [2] Logitech PRO X (PRO X 2 / PRO X Wireless)" -ForegroundColor White Write-Host " -> Use LogitechGHub: Windows keeps the endpoint Active even" -ForegroundColor DarkGray Write-Host " when OFF, so detection uses the G HUB battery signal." -ForegroundColor DarkGray Write-Host "" Write-Host " [3] Not sure - validate automatically" -ForegroundColor White Write-Host " -> Run the ON->OFF->ON cycle and auto-detect the mode" -ForegroundColor DarkGray Write-Host " (recommended if this is a new headset)." -ForegroundColor DarkGray + Write-Host "" + Write-Host " [4] SteelSeries Arctis Nova 5/5X" -ForegroundColor White + Write-Host " -> Use SteelSeriesNova5: reads the headset state over HID" -ForegroundColor DarkGray + Write-Host " (no SteelSeries GG or third-party software needed)." -ForegroundColor DarkGray $cycleChoice = 0 do { - $cc = Read-Host "Choose 1, 2 or 3" + $cc = Read-Host "Choose 1, 2, 3 or 4" [int]::TryParse($cc, [ref]$cycleChoice) | Out-Null - } until ($cycleChoice -ge 1 -and $cycleChoice -le 3) + } until ($cycleChoice -ge 1 -and $cycleChoice -le 4) if ($cycleChoice -eq 1) { # Standard wireless headset: assume WindowsEndpoint. The selected endpoints @@ -400,18 +406,18 @@ try { } elseif ($cycleChoice -eq 2) { # Logitech PRO X 2: needs G HUB. The endpoint alone cannot tell ON from OFF. - Write-Host " Assuming a Logitech PRO X 2. Looking up the headset in G HUB..." -ForegroundColor Yellow + Write-Host " Assuming a Logitech PRO X. Looking up the headset in G HUB..." -ForegroundColor Yellow try { Connect-GHub $devices = Invoke-GHubGet -Path "/devices/list" $deviceInfos = @($devices.payload.deviceInfos) $ghubCandidates = @($deviceInfos | Where-Object { - $_.extendedDisplayName -match 'PRO\s*X\s*2' + Test-LogitechProXDeviceName -Name $_.extendedDisplayName }) if ($ghubCandidates.Count -eq 0) { - Write-Host " G HUB reports no PRO X 2. Try option 3 (auto-detect)." -ForegroundColor Red + Write-Host " G HUB reports no Logitech PRO X headset. Try option 3 (auto-detect)." -ForegroundColor Red } else { $ghubHeadset = $ghubCandidates[0] @@ -422,7 +428,7 @@ try { } $ghubChoice = 0 do { - $gc = Read-Host "Enter the number of the PRO X 2 that matches '$headsetName'" + $gc = Read-Host "Enter the number of the PRO X headset that matches '$headsetName'" $ghubValid = [int]::TryParse($gc, [ref]$ghubChoice) -and $ghubChoice -ge 1 -and $ghubChoice -le $ghubCandidates.Count @@ -441,6 +447,28 @@ try { Write-Host " No G HUB association was set; the config will not be written." -ForegroundColor DarkGray } } + elseif ($cycleChoice -eq 4) { + # SteelSeries Arctis Nova 5/5X: HID receiver detection via the module. + $steelModule = Join-Path $InstallDir "lib\SteelSeriesNova5.psm1" + if (-not (Test-Path $steelModule)) { + Write-Host " SteelSeries module not present in the package; cannot use this mode." -ForegroundColor Red + } + else { + try { + Import-Module $steelModule -ErrorAction Stop + if (Test-SteelSeriesNova5Receiver) { + $DetectionMode = "SteelSeriesNova5" + Write-Host " SteelSeries Nova 5/5X receiver detected over HID." -ForegroundColor Green + } + else { + Write-Host " No compatible SteelSeries Nova 5/5X receiver found." -ForegroundColor Red + } + } + catch { + Write-Host " SteelSeries check failed: $($_.Exception.Message)" -ForegroundColor Red + } + } + } else { # --- Validate the headset ON -> OFF -> ON cycle --- # The headset must be ON now. We ask for OFF and then ON, and check that @@ -563,7 +591,7 @@ try { # G HUB fallback: ONLY if the user confirms that the chosen headset is # a Logitech PRO X 2 listed by G HUB. Never associate $logi[0]. Write-Host "" - $conf = Read-Host "Is this headset a Logitech PRO X 2 detected by G HUB? (y/N)" + $conf = Read-Host "Is this headset a Logitech PRO X (PRO X 2 / PRO X Wireless) detected by G HUB? (y/N)" if ($conf -match '^(s|si|sí|y|yes)$') { try { Connect-GHub @@ -571,24 +599,24 @@ try { $deviceInfos = @($devices.payload.deviceInfos) Write-Host "" - # Filter to ONLY PRO X 2 candidates: prevents accidentally + # Filter to ONLY Logitech PRO X candidates: prevents accidentally # picking a Logitech mouse/keyboard and watching its battery. $ghubCandidates = @($deviceInfos | Where-Object { - $_.extendedDisplayName -match 'PRO\s*X\s*2' + Test-LogitechProXDeviceName -Name $_.extendedDisplayName }) if ($ghubCandidates.Count -eq 0) { - Write-Host " G HUB reports no PRO X 2." -ForegroundColor Red + Write-Host " G HUB reports no Logitech PRO X headset." -ForegroundColor Red } else { - Write-Host "PRO X 2 headsets detected by G HUB:" -ForegroundColor Yellow + Write-Host "Logitech PRO X headsets detected by G HUB:" -ForegroundColor Yellow for ($i = 0; $i -lt $ghubCandidates.Count; $i++) { Write-Host (" [{0}] {1} ({2})" -f ($i + 1), $ghubCandidates[$i].extendedDisplayName, $ghubCandidates[$i].id) } $ghubChoice = 0 do { - $gc = Read-Host "Enter the number of the PRO X 2 that matches '$headsetName'" + $gc = Read-Host "Enter the number of the PRO X headset that matches '$headsetName'" $ghubValid = [int]::TryParse($gc, [ref]$ghubChoice) -and $ghubChoice -ge 1 -and $ghubChoice -le $ghubCandidates.Count @@ -640,7 +668,7 @@ try { } $config = [ordered]@{ - Version = "1.3.0" + Version = "1.4.0" DetectionMode = $DetectionMode HeadsetName = [string]$headsetOutput.Name HeadsetId = [string]$headsetOutput.ItemId diff --git a/Runtime-PROX2-AutoSwitch.ps1 b/Runtime-PROX2-AutoSwitch.ps1 index 0bab6d5..86254d2 100644 --- a/Runtime-PROX2-AutoSwitch.ps1 +++ b/Runtime-PROX2-AutoSwitch.ps1 @@ -19,6 +19,20 @@ $ModulePath = Join-Path $InstallDir "lib\AutoSwitchCore.psm1" if (-not (Test-Path $ModulePath)) { exit 12 } Import-Module $ModulePath -ErrorAction Stop +# SteelSeries Nova 5/5X HID detection (optional provider module). +$script:SteelSeriesModulePath = Join-Path $InstallDir "lib\SteelSeriesNova5.psm1" +$script:SteelSeriesAvailable = $false +if (Test-Path $script:SteelSeriesModulePath) { + try { + Import-Module $script:SteelSeriesModulePath -ErrorAction Stop + $script:SteelSeriesAvailable = $true + } + catch { + Write-AutoSwitchLog ("SteelSeries module present but failed to import: {0}" -f $_.Exception.Message) + $script:SteelSeriesAvailable = $false + } +} + # Timeouts de G HUB (ms). Overridables desde config.json. $script:ConnectTimeoutMs = 5000 $script:ReceiveTimeoutMs = 5000 @@ -271,15 +285,15 @@ function Get-ProX2BatteryPath { if (-not $headset) { $headset = $all | - Where-Object { $_.extendedDisplayName -match "PRO\s*X\s*2" } | + Where-Object { Test-LogitechProXDeviceName -Name $_.extendedDisplayName } | Select-Object -First 1 } if (-not $headset) { - throw "G HUB did not return the PRO X 2 in /devices/list." + throw "G HUB did not return a Logitech PRO X headset in /devices/list." } - Write-AutoSwitchLog ("PRO X 2 detected by G HUB: {0} ({1})" -f $headset.extendedDisplayName, $headset.id) + Write-AutoSwitchLog ("Logitech PRO X detected by G HUB: {0} ({1})" -f $headset.extendedDisplayName, $headset.id) return "/battery/$($headset.id)/state" } @@ -1095,6 +1109,29 @@ function Start-WorkerLoop { } } } + elseif ($script:DetectionMode -eq 'SteelSeriesNova5') { + # SteelSeries Arctis Nova 5/5X HID state via the optional module. + if (-not $script:SteelSeriesAvailable) { + if (-not $availabilityLogged) { + Write-AutoSwitchLog "SteelSeries module not available; not switching." + $availabilityLogged = $true + } + } + else { + try { + $steelState = Get-SteelSeriesNova5State -TimeoutMilliseconds 800 + if ($steelState -eq 'Connected') { $isOn = $true; $known = $true } + elseif ($steelState -eq 'Disconnected') { $isOn = $false; $known = $true } + # Unknown -> no tocar nada. + } + catch { + if (-not $availabilityLogged) { + Write-AutoSwitchLog ("SteelSeries unavailable: {0}. It will retry." -f $_.Exception.Message) + $availabilityLogged = $true + } + } + } + } else { # LogitechGHub: PERSISTENT connection. Connect once and resolve # batteryPath once; reconnect only on failure (re-resolving the diff --git a/Verificar-PROX2-AutoSwitch.ps1 b/Verificar-PROX2-AutoSwitch.ps1 index cb6204e..9851c1d 100644 --- a/Verificar-PROX2-AutoSwitch.ps1 +++ b/Verificar-PROX2-AutoSwitch.ps1 @@ -102,6 +102,28 @@ if ($mode -eq 'LogitechGHub') { elseif ($mode -eq 'WindowsEndpoint') { Write-Host "[SKIP] G HUB (not used in WindowsEndpoint mode)" -ForegroundColor DarkGray } +elseif ($mode -eq 'SteelSeriesNova5') { + Write-Host "[SKIP] G HUB (not used in SteelSeriesNova5 mode)" -ForegroundColor DarkGray + # SteelSeries receiver presence over HID. + $steelModule = Join-Path $InstallDir "lib\SteelSeriesNova5.psm1" + if (Test-Path $steelModule) { + try { + Import-Module $steelModule -ErrorAction Stop + if (Test-SteelSeriesNova5Receiver) { + Show-Test "SteelSeries Nova 5/5X receiver" $true "HID receiver found" + } + else { + Show-Test "SteelSeries Nova 5/5X receiver" $false "No compatible HID receiver found" + } + } + catch { + Show-Test "SteelSeries Nova 5/5X receiver" $false $_.Exception.Message + } + } + else { + Show-Test "SteelSeries Nova 5/5X receiver" $false "Module not installed" + } +} else { Show-Test "Detection mode" $false "Could not read a valid DetectionMode from config.json" } diff --git a/lib/AutoSwitchCore.psm1 b/lib/AutoSwitchCore.psm1 index 168e2bb..054fb4b 100644 --- a/lib/AutoSwitchCore.psm1 +++ b/lib/AutoSwitchCore.psm1 @@ -977,10 +977,32 @@ function Get-ConfigDetectionMode { return 'LogitechGHub' } - if ($mode -ieq 'WindowsEndpoint') { return 'WindowsEndpoint' } - if ($mode -ieq 'LogitechGHub') { return 'LogitechGHub' } + if ($mode -ieq 'WindowsEndpoint') { return 'WindowsEndpoint' } + if ($mode -ieq 'LogitechGHub') { return 'LogitechGHub' } + if ($mode -ieq 'SteelSeriesNova5') { return 'SteelSeriesNova5' } return $null } -Export-ModuleMember -Function Get-RenderItemIdFromText, Resolve-HeadsetState, Test-ValidAudioConfig, New-GHubTimeoutToken, ConvertFrom-SvclCsv, ConvertFrom-CsvLine, Get-CsvColumn, Resolve-EndpointState, Resolve-DetectedState, Test-SvclExportValid, Get-SvclRenderDevice, Get-SvclDeviceLabel, Find-SvclRenderDeviceByIdentity, Get-EndpointFxState, Get-ConfigDetectionMode, Initialize-CoreAudioBackend, Get-CoreAudioRenderDevices, Get-CoreAudioDefaultRenderDeviceId, Get-CoreAudioDefaultRenderDeviceIds, Test-CoreAudioDefaultRenderDevice, Set-CoreAudioDefaultRenderDevice +function Test-LogitechProXDeviceName { + <# + .SYNOPSIS + True if a G HUB extendedDisplayName matches a Logitech PRO X headset + (PRO X 2, PRO X Wireless, PRO X, etc.). PRO X 2 and PRO X Wireless + both expose a LIGHTSPEED endpoint that stays Active when off, so the + G HUB battery signal is the ON/OFF source for all of them. + .DESCRIPTION + Logitech mice are named "G PRO X Superlight" etc.; "PRO X" alone is a + headset family, so require the "PRO X" token and exclude mouse names. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$Name + ) + + if ($Name -match '(?i)superlight|mouse') { return $false } + + return ($Name -match '\bPRO\s*X(?:\s*2|\s*Wireless)?\b') +} + +Export-ModuleMember -Function Get-RenderItemIdFromText, Resolve-HeadsetState, Test-ValidAudioConfig, New-GHubTimeoutToken, ConvertFrom-SvclCsv, ConvertFrom-CsvLine, Get-CsvColumn, Resolve-EndpointState, Resolve-DetectedState, Test-SvclExportValid, Get-SvclRenderDevice, Get-SvclDeviceLabel, Find-SvclRenderDeviceByIdentity, Get-EndpointFxState, Get-ConfigDetectionMode, Test-LogitechProXDeviceName, Initialize-CoreAudioBackend, Get-CoreAudioRenderDevices, Get-CoreAudioDefaultRenderDeviceId, Get-CoreAudioDefaultRenderDeviceIds, Test-CoreAudioDefaultRenderDevice, Set-CoreAudioDefaultRenderDevice diff --git a/lib/SteelSeriesNova5.psm1 b/lib/SteelSeriesNova5.psm1 new file mode 100644 index 0000000..f407eb2 --- /dev/null +++ b/lib/SteelSeriesNova5.psm1 @@ -0,0 +1,396 @@ +#requires -Version 5.1 +# SteelSeriesNova5.psm1 - physical ON/OFF detection for Arctis Nova 5/5X. +# +# Protocol reference (reverse engineered by HeadsetControl): +# VID 0x1038 +# PID 0x2232 (Nova 5), 0x2253 (Nova 5X) +# HID UsagePage 0xFFC0, Usage 0x0001 +# status request: 00 B0 +# response byte[1] == 0x02 => headset offline +# +# No SteelSeries GG or third-party DLL is required. The C# helper uses the +# Windows HID/SetupAPI APIs already present in the operating system. + +Set-StrictMode -Version Latest + +function Resolve-SteelSeriesNova5Status { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [byte[]]$Data + ) + + # HeadsetControl rejects responses shorter than 16 bytes. Do the same: + # a malformed/partial response is Unknown, never interpreted as OFF. + if ($null -eq $Data -or $Data.Length -lt 16) { + return 'Unknown' + } + + if ($Data[1] -eq 0x02) { + return 'Disconnected' + } + + return 'Connected' +} + +function Initialize-SteelSeriesNova5Hid { + if ('AutoSwitch.SteelSeriesNova5Hid' -as [type]) { + return + } + + Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace AutoSwitch +{ + public static class SteelSeriesNova5Hid + { + private const ushort VID_STEELSERIES = 0x1038; + private const ushort PID_NOVA5 = 0x2232; + private const ushort PID_NOVA5X = 0x2253; + private const ushort USAGE_PAGE = 0xFFC0; + private const ushort USAGE = 0x0001; + + private const uint DIGCF_PRESENT = 0x00000002; + private const uint DIGCF_DEVICEINTERFACE = 0x00000010; + private const uint GENERIC_READ = 0x80000000; + private const uint GENERIC_WRITE = 0x40000000; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint OPEN_EXISTING = 3; + private const uint FILE_FLAG_OVERLAPPED = 0x40000000; + private static readonly IntPtr INVALID_HANDLE_VALUE = new IntPtr(-1); + + [StructLayout(LayoutKind.Sequential)] + private struct SP_DEVICE_INTERFACE_DATA + { + public int cbSize; + public Guid InterfaceClassGuid; + public int Flags; + public IntPtr Reserved; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct SP_DEVICE_INTERFACE_DETAIL_DATA + { + public int cbSize; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 512)] + public string DevicePath; + } + + [StructLayout(LayoutKind.Sequential)] + private struct HIDD_ATTRIBUTES + { + public int Size; + public ushort VendorID; + public ushort ProductID; + public ushort VersionNumber; + } + + [StructLayout(LayoutKind.Sequential)] + private struct HIDP_CAPS + { + public ushort Usage; + public ushort UsagePage; + public ushort InputReportByteLength; + public ushort OutputReportByteLength; + public ushort FeatureReportByteLength; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 17)] + public ushort[] Reserved; + + public ushort NumberLinkCollectionNodes; + public ushort NumberInputButtonCaps; + public ushort NumberInputValueCaps; + public ushort NumberInputDataIndices; + public ushort NumberOutputButtonCaps; + public ushort NumberOutputValueCaps; + public ushort NumberOutputDataIndices; + public ushort NumberFeatureButtonCaps; + public ushort NumberFeatureValueCaps; + public ushort NumberFeatureDataIndices; + } + + [DllImport("hid.dll")] + private static extern void HidD_GetHidGuid(out Guid HidGuid); + + [DllImport("hid.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool HidD_GetAttributes(SafeFileHandle HidDeviceObject, ref HIDD_ATTRIBUTES Attributes); + + [DllImport("hid.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool HidD_GetPreparsedData(SafeFileHandle HidDeviceObject, out IntPtr PreparsedData); + + [DllImport("hid.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool HidD_FreePreparsedData(IntPtr PreparsedData); + + [DllImport("hid.dll")] + private static extern int HidP_GetCaps(IntPtr PreparsedData, out HIDP_CAPS Capabilities); + + [DllImport("setupapi.dll", SetLastError = true)] + private static extern IntPtr SetupDiGetClassDevs( + ref Guid ClassGuid, + IntPtr Enumerator, + IntPtr hwndParent, + uint Flags); + + [DllImport("setupapi.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetupDiEnumDeviceInterfaces( + IntPtr DeviceInfoSet, + IntPtr DeviceInfoData, + ref Guid InterfaceClassGuid, + uint MemberIndex, + ref SP_DEVICE_INTERFACE_DATA DeviceInterfaceData); + + [DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetupDiGetDeviceInterfaceDetail( + IntPtr DeviceInfoSet, + ref SP_DEVICE_INTERFACE_DATA DeviceInterfaceData, + ref SP_DEVICE_INTERFACE_DETAIL_DATA DeviceInterfaceDetailData, + uint DeviceInterfaceDetailDataSize, + out uint RequiredSize, + IntPtr DeviceInfoData); + + [DllImport("setupapi.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetupDiDestroyDeviceInfoList(IntPtr DeviceInfoSet); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + IntPtr hTemplateFile); + + private sealed class Candidate : IDisposable + { + public SafeFileHandle Handle; + public HIDP_CAPS Caps; + + public void Dispose() + { + if (Handle != null && !Handle.IsInvalid) + Handle.Dispose(); + } + } + + private static bool IsSupportedPid(ushort pid) + { + return pid == PID_NOVA5 || pid == PID_NOVA5X; + } + + private static Candidate OpenReceiver() + { + Guid hidGuid; + HidD_GetHidGuid(out hidGuid); + + IntPtr set = SetupDiGetClassDevs( + ref hidGuid, + IntPtr.Zero, + IntPtr.Zero, + DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); + + if (set == INVALID_HANDLE_VALUE) + return null; + + try + { + for (uint index = 0; ; index++) + { + SP_DEVICE_INTERFACE_DATA iface = new SP_DEVICE_INTERFACE_DATA(); + iface.cbSize = Marshal.SizeOf(typeof(SP_DEVICE_INTERFACE_DATA)); + + if (!SetupDiEnumDeviceInterfaces(set, IntPtr.Zero, ref hidGuid, index, ref iface)) + break; + + SP_DEVICE_INTERFACE_DETAIL_DATA detail = new SP_DEVICE_INTERFACE_DETAIL_DATA(); + detail.cbSize = IntPtr.Size == 8 ? 8 : 6; + + uint required; + uint detailSize = (uint)Marshal.SizeOf(typeof(SP_DEVICE_INTERFACE_DETAIL_DATA)); + if (!SetupDiGetDeviceInterfaceDetail(set, ref iface, ref detail, detailSize, out required, IntPtr.Zero)) + continue; + + SafeFileHandle handle = CreateFile( + detail.DevicePath, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, + OPEN_EXISTING, + FILE_FLAG_OVERLAPPED, + IntPtr.Zero); + + if (handle == null || handle.IsInvalid) + { + if (handle != null) handle.Dispose(); + continue; + } + + HIDD_ATTRIBUTES attrs = new HIDD_ATTRIBUTES(); + attrs.Size = Marshal.SizeOf(typeof(HIDD_ATTRIBUTES)); + if (!HidD_GetAttributes(handle, ref attrs) || + attrs.VendorID != VID_STEELSERIES || + !IsSupportedPid(attrs.ProductID)) + { + handle.Dispose(); + continue; + } + + IntPtr preparsed = IntPtr.Zero; + try + { + if (!HidD_GetPreparsedData(handle, out preparsed) || preparsed == IntPtr.Zero) + { + handle.Dispose(); + continue; + } + + HIDP_CAPS caps; + // HIDP_STATUS_SUCCESS == 0x00110000. + if (HidP_GetCaps(preparsed, out caps) != 0x00110000 || + caps.UsagePage != USAGE_PAGE || caps.Usage != USAGE) + { + handle.Dispose(); + continue; + } + + Candidate candidate = new Candidate(); + candidate.Handle = handle; + candidate.Caps = caps; + return candidate; + } + finally + { + if (preparsed != IntPtr.Zero) + HidD_FreePreparsedData(preparsed); + } + } + } + finally + { + SetupDiDestroyDeviceInfoList(set); + } + + return null; + } + + public static bool ReceiverPresent() + { + using (Candidate receiver = OpenReceiver()) + { + return receiver != null; + } + } + + private static bool WaitAndComplete(IAsyncResult asyncResult, int timeoutMs, Action complete) + { + if (!asyncResult.AsyncWaitHandle.WaitOne(timeoutMs)) + return false; + + complete(asyncResult); + return true; + } + + // Returns the raw HID status response, or null when the receiver cannot + // be queried safely. Callers must treat null as Unknown, never as OFF. + public static byte[] QueryStatus(int timeoutMs) + { + if (timeoutMs < 100) timeoutMs = 100; + + using (Candidate receiver = OpenReceiver()) + { + if (receiver == null) + return null; + + int outputLength = Math.Max(2, (int)receiver.Caps.OutputReportByteLength); + int inputLength = Math.Max(128, (int)receiver.Caps.InputReportByteLength); + + byte[] request = new byte[outputLength]; + request[0] = 0x00; // report ID + request[1] = 0xB0; // SteelSeries Nova status request + + byte[] response = new byte[inputLength]; + + try + { + using (FileStream stream = new FileStream(receiver.Handle, FileAccess.ReadWrite, 4096, true)) + { + // Ownership moves to FileStream; prevent Candidate from + // closing the handle twice. + receiver.Handle = null; + + IAsyncResult write = stream.BeginWrite(request, 0, request.Length, null, null); + if (!WaitAndComplete(write, timeoutMs, stream.EndWrite)) + return null; + + IAsyncResult read = stream.BeginRead(response, 0, response.Length, null, null); + int count = -1; + if (!read.AsyncWaitHandle.WaitOne(timeoutMs)) + return null; + count = stream.EndRead(read); + + if (count <= 0) + return null; + + byte[] exact = new byte[count]; + Buffer.BlockCopy(response, 0, exact, 0, count); + return exact; + } + } + catch + { + return null; + } + } + } + } +} +'@ -ErrorAction Stop +} + +function Test-SteelSeriesNova5Receiver { + [CmdletBinding()] + param() + + try { + Initialize-SteelSeriesNova5Hid + return [AutoSwitch.SteelSeriesNova5Hid]::ReceiverPresent() + } + catch { + return $false + } +} + +function Get-SteelSeriesNova5State { + [CmdletBinding()] + param( + [int]$TimeoutMilliseconds = 800 + ) + + try { + Initialize-SteelSeriesNova5Hid + $data = [AutoSwitch.SteelSeriesNova5Hid]::QueryStatus($TimeoutMilliseconds) + if ($null -eq $data) { + return 'Unknown' + } + return Resolve-SteelSeriesNova5Status -Data $data + } + catch { + return 'Unknown' + } +} + +Export-ModuleMember -Function Resolve-SteelSeriesNova5Status, Test-SteelSeriesNova5Receiver, Get-SteelSeriesNova5State diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 37d2bc1..59a25f9 100644 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -9,7 +9,7 @@ python3 - "$ROOT_DIR" "$ARCHIVE" "$VERSION" <<'PY' import pathlib, stat, sys, zipfile root=pathlib.Path(sys.argv[1]); archive=pathlib.Path(sys.argv[2]); version=sys.argv[3] prefix=f'PROX2-AutoSwitch-v{version}' -paths=['Install.cmd','Verify.cmd','Uninstall.cmd','Install-AutoSwitch.ps1','Verify-AutoSwitch.ps1','Uninstall-AutoSwitch.ps1','Instalar-PROX2-AutoSwitch.ps1','Verificar-PROX2-AutoSwitch.ps1','Desinstalar-PROX2-AutoSwitch.ps1','Runtime-PROX2-AutoSwitch.ps1','Toggle-AudioEnhancements.ps1','install.ps1','lib/AutoSwitchCore.psm1','assets/icon.ico','README.md','AGENT.md','SOURCES.md','SECURITY.md','SUPPORT.md','CONTRIBUTING.md','CHANGELOG.md','LICENSE'] +paths=['Install.cmd','Verify.cmd','Uninstall.cmd','Install-AutoSwitch.ps1','Verify-AutoSwitch.ps1','Uninstall-AutoSwitch.ps1','Instalar-PROX2-AutoSwitch.ps1','Verificar-PROX2-AutoSwitch.ps1','Desinstalar-PROX2-AutoSwitch.ps1','Runtime-PROX2-AutoSwitch.ps1','Toggle-AudioEnhancements.ps1','install.ps1','lib/AutoSwitchCore.psm1','lib/SteelSeriesNova5.psm1','tools/Test-SteelSeriesNova5Hid.ps1','assets/icon.ico','README.md','AGENT.md','SOURCES.md','SECURITY.md','SUPPORT.md','CONTRIBUTING.md','CHANGELOG.md','LICENSE'] with zipfile.ZipFile(archive,'w',zipfile.ZIP_DEFLATED,compresslevel=9) as zf: for rel in sorted(paths,key=str.casefold): p=root/rel diff --git a/tests/AutoSwitchCore.Tests.ps1 b/tests/AutoSwitchCore.Tests.ps1 index 2936dde..37b35df 100644 --- a/tests/AutoSwitchCore.Tests.ps1 +++ b/tests/AutoSwitchCore.Tests.ps1 @@ -278,6 +278,30 @@ Describe 'Get-ConfigDetectionMode' { $cfg = [pscustomobject]@{ DetectionMode = 'Foo'; HeadsetId = 'x' } Get-ConfigDetectionMode -Config $cfg | Should -BeNullOrEmpty } + + It 'returns SteelSeriesNova5 when set' { + $cfg = [pscustomobject]@{ DetectionMode = 'SteelSeriesNova5'; HeadsetId = 'x' } + Get-ConfigDetectionMode -Config $cfg | Should -Be 'SteelSeriesNova5' + } +} + +Describe 'Test-LogitechProXDeviceName' { + It 'matches PRO X 2' { + Test-LogitechProXDeviceName -Name 'PRO X 2 Lightspeed Gaming Headset' | Should -Be $true + } + + It 'matches PRO X Wireless' { + Test-LogitechProXDeviceName -Name 'PRO X Wireless Gaming Headset' | Should -Be $true + } + + It 'matches plain PRO X' { + Test-LogitechProXDeviceName -Name 'PRO X Gaming Headset' | Should -Be $true + } + + It 'rejects non-PRO-X devices' { + Test-LogitechProXDeviceName -Name 'G PRO X Superlight Mouse' | Should -Be $false + Test-LogitechProXDeviceName -Name 'G733 Gaming Headset' | Should -Be $false + } } diff --git a/tests/SteelSeriesNova5.Tests.ps1 b/tests/SteelSeriesNova5.Tests.ps1 new file mode 100644 index 0000000..674d15f --- /dev/null +++ b/tests/SteelSeriesNova5.Tests.ps1 @@ -0,0 +1,23 @@ +#requires -Version 5.1 + +$module = Join-Path $PSScriptRoot '..\lib\SteelSeriesNova5.psm1' +Import-Module $module -Force + +Describe 'SteelSeries Arctis Nova 5 HID status mapping' { + It 'maps the documented offline marker to Disconnected' { + $data = New-Object byte[] 16 + $data[1] = 0x02 + Resolve-SteelSeriesNova5Status -Data $data | Should -Be 'Disconnected' + } + + It 'maps a normal status response to Connected' { + $data = New-Object byte[] 16 + $data[1] = 0x00 + Resolve-SteelSeriesNova5Status -Data $data | Should -Be 'Connected' + } + + It 'treats a short response as Unknown' { + $data = New-Object byte[] 8 + Resolve-SteelSeriesNova5Status -Data $data | Should -Be 'Unknown' + } +} diff --git a/tools/Test-SteelSeriesNova5Hid.ps1 b/tools/Test-SteelSeriesNova5Hid.ps1 new file mode 100644 index 0000000..294b367 --- /dev/null +++ b/tools/Test-SteelSeriesNova5Hid.ps1 @@ -0,0 +1,32 @@ +#requires -Version 5.1 +$ErrorActionPreference = 'Stop' + +$module = Join-Path (Split-Path -Parent $PSScriptRoot) 'lib\SteelSeriesNova5.psm1' +Import-Module $module -Force + +Write-Host 'SteelSeries Arctis Nova 5/5X HID diagnostic' -ForegroundColor Cyan +Write-Host '' + +if (-not (Test-SteelSeriesNova5Receiver)) { + Write-Host 'No compatible Nova 5/5X receiver was found.' -ForegroundColor Red + exit 2 +} + +Write-Host 'Compatible receiver found.' -ForegroundColor Green +Write-Host 'Press Ctrl+C to stop. Turn the headset ON and OFF and watch the state.' -ForegroundColor DarkGray +Write-Host '' + +$last = $null +while ($true) { + $state = Get-SteelSeriesNova5State -TimeoutMilliseconds 800 + if ($state -ne $last) { + $color = switch ($state) { + 'Connected' { 'Green' } + 'Disconnected' { 'Yellow' } + default { 'Red' } + } + Write-Host ('[{0}] {1}' -f (Get-Date -Format 'HH:mm:ss.fff'), $state) -ForegroundColor $color + $last = $state + } + Start-Sleep -Milliseconds 500 +}