From c236e53b3a7ded936546d4872a966e2cf28c2254 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:34:46 +0200 Subject: [PATCH 1/2] feat: support SteelSeries Arctis Nova 5 detection --- Instalar-PROX2-AutoSwitch.ps1 | 149 +++++++----- Runtime-PROX2-AutoSwitch.ps1 | 374 ++++++++++++++----------------- Verificar-PROX2-AutoSwitch.ps1 | 243 ++++++++++---------- docs/STEELSERIES-NOVA5.md | 52 +++++ lib/SteelSeriesNova5.psm1 | 189 ++++++++++++++++ tests/SteelSeriesNova5.Tests.ps1 | 113 ++++++++++ 6 files changed, 721 insertions(+), 399 deletions(-) create mode 100644 docs/STEELSERIES-NOVA5.md create mode 100644 lib/SteelSeriesNova5.psm1 create mode 100644 tests/SteelSeriesNova5.Tests.ps1 diff --git a/Instalar-PROX2-AutoSwitch.ps1 b/Instalar-PROX2-AutoSwitch.ps1 index 6e12dfc..bf03efe 100644 --- a/Instalar-PROX2-AutoSwitch.ps1 +++ b/Instalar-PROX2-AutoSwitch.ps1 @@ -11,12 +11,14 @@ $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" +$SteelSeriesModuleSrc = Join-Path $PackageDir "lib\SteelSeriesNova5.psm1" $HelperSrc = Join-Path $PackageDir "Toggle-AudioEnhancements.ps1" $IconSrc = Join-Path $PackageDir "assets\icon.ico" $MainScript = Join-Path $InstallDir "PROX2AutoSwitch.ps1" $ConfigPath = Join-Path $InstallDir "config.json" $SvclPath = Join-Path $InstallDir "svcl.exe" +$HeadsetControlPath = Join-Path $InstallDir "headsetcontrol.exe" $LauncherVbs = Join-Path $InstallDir "Iniciar-Oculto.vbs" $LogPath = Join-Path $InstallDir "autoswitch.log" $HelperPath = Join-Path $InstallDir "Toggle-AudioEnhancements.ps1" @@ -30,7 +32,7 @@ $SvclUrl = "https://www.nirsoft.net/utils/svcl-x64.zip" $ExpectedSha256 = "7ba008e9ece8b3eda323ef01711e4647eb7f40b28dc25f98b2ed6a738810bfcd" $ZipPath = Join-Path $env:TEMP "svcl-x64.zip" -foreach ($required in @($RuntimeSrc, $UninstallSrc, $VerifySrc, $ModuleSrc, $HelperSrc, $IconSrc)) { +foreach ($required in @($RuntimeSrc, $UninstallSrc, $VerifySrc, $ModuleSrc, $SteelSeriesModuleSrc, $HelperSrc, $IconSrc)) { if (-not (Test-Path $required)) { throw "A package file is missing: $required. Extract the full ZIP before installing." } @@ -38,6 +40,9 @@ foreach ($required in @($RuntimeSrc, $UninstallSrc, $VerifySrc, $ModuleSrc, $Hel # Shared logic (Item ID extraction, config validation, debounce). Import-Module $ModuleSrc -ErrorAction Stop +# SteelSeries provider is imported last because it extends Get-ConfigDetectionMode +# with the SteelSeriesNova5 mode while preserving the legacy migration behavior. +Import-Module $SteelSeriesModuleSrc -ErrorAction Stop if (-not [Environment]::Is64BitOperatingSystem) { throw "This package is built for Windows x64." @@ -109,6 +114,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 $SteelSeriesModuleSrc (Join-Path $InstallDir "lib\SteelSeriesNova5.psm1") -Force # --- G HUB functions for the installer --- $script:Ws = $null @@ -338,9 +344,11 @@ function Test-SetDefault { try { # --- Step 3: pick headset and fallback FIRST --- # DetectionMode is not decided until the device has been chosen and we - # have validated that Windows (or G HUB) can observe its physical state. + # have validated that Windows (or a device-specific provider) can observe + # its physical state. $DetectionMode = $null $ghubHeadset = $null + $steelSeriesProductId = $null Write-Host "[3/7] Selecting headset and fallback..." -ForegroundColor Yellow @@ -397,8 +405,6 @@ try { Write-Host " Fallback: $speakerName" -ForegroundColor Green # --- Validate the headset ON -> OFF -> ON cycle --- - # The headset must be ON now. We ask for OFF and then ON, and check that - # Windows reflects the change on each transition. Write-Host "" Write-Host "Checking that Windows reflects the physical state of the headset..." -ForegroundColor Cyan Write-Host " The headset must be ON now. Checking..." -ForegroundColor DarkGray @@ -421,9 +427,6 @@ try { $null -ne $id -and $id.Trim() -ieq $ItemId.Trim() } | Select-Object -First 1 - # Bluetooth can recreate an endpoint with a new Item ID after reconnect. - # Resolve the same Render endpoint by its real svcl identity rather than - # treating the user-facing "Device Name — Name" label as one column. if (-not $row -and (-not [string]::IsNullOrWhiteSpace($DeviceName) -or -not [string]::IsNullOrWhiteSpace($EndpointName))) { @@ -514,60 +517,94 @@ try { else { Write-Host " Windows does NOT reflect the physical cycle of this headset." -ForegroundColor DarkGray - # 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)" - if ($conf -match '^(s|si|sí|y|yes)$') { + $steelConf = Read-Host "Is this a SteelSeries Arctis Nova 5 or Nova 5X? (y/N)" + if ($steelConf -match '^(s|si|sí|y|yes)$') { try { - Connect-GHub - $devices = Invoke-GHubGet -Path "/devices/list" - $deviceInfos = @($devices.payload.deviceInfos) - - Write-Host "" - # Filter to ONLY PRO X 2 candidates: prevents accidentally - # picking a Logitech mouse/keyboard and watching its battery. - $ghubCandidates = @($deviceInfos | Where-Object { - $_.extendedDisplayName -match 'PRO\s*X\s*2' - }) - - if ($ghubCandidates.Count -eq 0) { - Write-Host " G HUB reports no PRO X 2." -ForegroundColor Red + Write-Host " Preparing the SteelSeries HID provider (HeadsetControl 4.0.0)..." -ForegroundColor Yellow + [void](Install-HeadsetControlPortable -DestinationPath $HeadsetControlPath) + $steelSeriesProductId = Get-SteelSeriesNova5ConnectedProductId -HeadsetControlPath $HeadsetControlPath + + if ($steelSeriesProductId) { + Write-Host (" Receiver detected: SteelSeries PID 0x{0:X4}." -f $steelSeriesProductId) -ForegroundColor Green + Write-Host "Turn the headset OFF and press ENTER..." -ForegroundColor Cyan + [void](Read-Host) + $steelOff = Wait-SteelSeriesNova5State -Expected 'Disconnected' -HeadsetControlPath $HeadsetControlPath -ProductId $steelSeriesProductId -TimeoutSeconds 15 + + Write-Host "Turn the headset back ON and press ENTER..." -ForegroundColor Cyan + [void](Read-Host) + $steelOn = Wait-SteelSeriesNova5State -Expected 'Connected' -HeadsetControlPath $HeadsetControlPath -ProductId $steelSeriesProductId -TimeoutSeconds 20 + + Write-Host (" SteelSeries state OFF: {0}" -f $steelOff) -ForegroundColor DarkGray + Write-Host (" SteelSeries state ON: {0}" -f $steelOn) -ForegroundColor DarkGray + + if ($steelOff -eq 'Disconnected' -and $steelOn -eq 'Connected') { + $DetectionMode = 'SteelSeriesNova5' + Write-Host " SteelSeries receiver reflects the physical state through HID." -ForegroundColor Green + } } else { - Write-Host "PRO X 2 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'" - $ghubValid = [int]::TryParse($gc, [ref]$ghubChoice) -and - $ghubChoice -ge 1 -and - $ghubChoice -le $ghubCandidates.Count - } until ($ghubValid) - - $ghubHeadset = $ghubCandidates[$ghubChoice - 1] - $DetectionMode = "LogitechGHub" - Write-Host " G HUB: $($ghubHeadset.extendedDisplayName)" -ForegroundColor Green + Write-Host " No unique connected Nova 5/5X receiver was found." -ForegroundColor Red } } catch { - Write-Host " Could not connect to G HUB. Detail: $($_.Exception.Message)" -ForegroundColor Red + Write-Host " SteelSeries provider check failed: $($_.Exception.Message)" -ForegroundColor Red + } + } + + # G HUB fallback: ONLY if SteelSeries was not validated and the user + # confirms that the chosen headset is a Logitech PRO X 2 listed by G HUB. + if (-not $DetectionMode) { + Write-Host "" + $conf = Read-Host "Is this headset a Logitech PRO X 2 detected by G HUB? (y/N)" + if ($conf -match '^(s|si|sí|y|yes)$') { + try { + Connect-GHub + $devices = Invoke-GHubGet -Path "/devices/list" + $deviceInfos = @($devices.payload.deviceInfos) + + Write-Host "" + $ghubCandidates = @($deviceInfos | Where-Object { + $_.extendedDisplayName -match 'PRO\s*X\s*2' + }) + + if ($ghubCandidates.Count -eq 0) { + Write-Host " G HUB reports no PRO X 2." -ForegroundColor Red + } + else { + Write-Host "PRO X 2 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'" + $ghubValid = [int]::TryParse($gc, [ref]$ghubChoice) -and + $ghubChoice -ge 1 -and + $ghubChoice -le $ghubCandidates.Count + } until ($ghubValid) + + $ghubHeadset = $ghubCandidates[$ghubChoice - 1] + $DetectionMode = "LogitechGHub" + Write-Host " G HUB: $($ghubHeadset.extendedDisplayName)" -ForegroundColor Green + } + } + catch { + Write-Host " Could not connect to G HUB. Detail: $($_.Exception.Message)" -ForegroundColor Red + } } } } if (-not $DetectionMode) { - throw "Windows cannot detect the physical state of this headset and there is no compatible method (nor a confirmed G HUB). Not installing." + throw "No compatible physical-state provider was validated for this headset. Not installing." } Write-Host "" Write-Host "[4/7] Calibrating Windows outputs..." -ForegroundColor Yellow Write-Host "No old IDs are kept: the current Windows ones are captured." -ForegroundColor DarkGray - # In both modes we already have the IDs captured from the Windows list. $headsetOutput = [pscustomobject]@{ Name = $headsetName ItemId = $headsetId @@ -580,20 +617,15 @@ try { Write-Host "" Write-Host "[5/7] Validating audio switches before installing..." -ForegroundColor Yellow - $okHeadset = Test-SetDefault ` - -Id $headsetOutput.ItemId ` - -Label $headsetOutput.Name - - $okSpeaker = Test-SetDefault ` - -Id $speakerOutput.ItemId ` - -Label $speakerOutput.Name + $okHeadset = Test-SetDefault -Id $headsetOutput.ItemId -Label $headsetOutput.Name + $okSpeaker = Test-SetDefault -Id $speakerOutput.ItemId -Label $speakerOutput.Name if (-not ($okHeadset -and $okSpeaker)) { throw "One of the real audio switch tests failed. AutoSwitch will not be installed." } $config = [ordered]@{ - Version = "1.2.0" + Version = "1.3.0" DetectionMode = $DetectionMode HeadsetName = [string]$headsetOutput.Name HeadsetId = [string]$headsetOutput.ItemId @@ -608,14 +640,16 @@ try { InstalledAt = (Get-Date).ToString("o") } - # G HUB mode specific fields. if ($DetectionMode -eq 'LogitechGHub' -and $ghubHeadset) { $config['GHubDisplayName'] = [string]$ghubHeadset.extendedDisplayName $config['GHubPort'] = 9010 } - # Ask whether to disable the headset's audio enhancements now (requires a - # one-off elevation via UAC). + if ($DetectionMode -eq 'SteelSeriesNova5' -and $steelSeriesProductId) { + $config['SteelSeriesProductId'] = [int]$steelSeriesProductId + $config['HeadsetControlPath'] = [string]$HeadsetControlPath + } + Write-Host "" Write-Host "Windows Audio Enhancements:" -ForegroundColor Yellow $enhChoice = Read-Host "Do you want to disable the headset's audio enhancements? (y/N)" @@ -625,8 +659,7 @@ try { Write-Host " They will be disabled (a UAC window may appear)..." -ForegroundColor DarkGray } - $config | ConvertTo-Json -Depth 10 | - Set-Content -Path $ConfigPath -Encoding UTF8 + $config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8 if ($config['DisableEnhancementsOnStart']) { try { @@ -665,8 +698,6 @@ Set shell = Nothing Set-Content -Path $LauncherVbs -Value $vbs -Encoding ASCII - # The shortcut launches wscript.exe, not PowerShell directly, so no - # console window remains visible at login. $WshShell = New-Object -ComObject WScript.Shell $Shortcut = $WshShell.CreateShortcut($ShortcutPath) $Shortcut.TargetPath = $WScriptExe @@ -676,7 +707,6 @@ Set shell = Nothing $Shortcut.Description = "PRO X 2 AutoSwitch - invisible startup" $Shortcut.Save() - # Start now, hidden. Start-Process -FilePath $WScriptExe -ArgumentList "`"$LauncherVbs`"" -WindowStyle Hidden Start-Sleep -Seconds 2 @@ -699,6 +729,7 @@ Set shell = Nothing Write-Host "" Write-Host "Headset ON -> $($headsetOutput.Name)" Write-Host "Headset OFF -> $($speakerOutput.Name)" + Write-Host "Detection -> $DetectionMode" Write-Host "" Write-Host "Installed at: $InstallDir" -ForegroundColor DarkGray Write-Host "Log: $LogPath" -ForegroundColor DarkGray diff --git a/Runtime-PROX2-AutoSwitch.ps1 b/Runtime-PROX2-AutoSwitch.ps1 index 2dce5bf..de7c4ab 100644 --- a/Runtime-PROX2-AutoSwitch.ps1 +++ b/Runtime-PROX2-AutoSwitch.ps1 @@ -10,6 +10,7 @@ $ConfigPath = Join-Path $InstallDir "config.json" $SvclPath = Join-Path $InstallDir "svcl.exe" $LogPath = Join-Path $InstallDir "autoswitch.log" $script:HelperPath = Join-Path $InstallDir "Toggle-AudioEnhancements.ps1" +$script:DefaultHeadsetControlPath = Join-Path $InstallDir "headsetcontrol.exe" if (-not (Test-Path $ConfigPath)) { exit 10 } if (-not (Test-Path $SvclPath)) { exit 11 } @@ -21,6 +22,12 @@ $ModulePath = Join-Path $InstallDir "lib\AutoSwitchCore.psm1" if (-not (Test-Path $ModulePath)) { exit 12 } Import-Module $ModulePath -ErrorAction Stop +# SteelSeries provider. It is imported last because it extends +# Get-ConfigDetectionMode with SteelSeriesNova5 while preserving legacy modes. +$SteelSeriesModulePath = Join-Path $InstallDir "lib\SteelSeriesNova5.psm1" +if (-not (Test-Path $SteelSeriesModulePath)) { exit 14 } +Import-Module $SteelSeriesModulePath -ErrorAction Stop + # Timeouts de G HUB (ms). Overridables desde config.json. $script:ConnectTimeoutMs = 5000 $script:ReceiveTimeoutMs = 5000 @@ -50,7 +57,7 @@ function Write-AutoSwitchLog { Add-Content -Path $LogPath -Value $line -Encoding UTF8 } -# Migracion de config v1.1.0 -> v1.2.0: se anade DetectionMode si falta. +# Migracion de config v1.1.0 -> v1.3.0: se anade DetectionMode si falta. if (-not $Config.PSObject.Properties['DetectionMode']) { try { $migrated = [ordered]@{} @@ -58,10 +65,10 @@ if (-not $Config.PSObject.Properties['DetectionMode']) { $migrated[$p.Name] = $p.Value } $migrated['DetectionMode'] = $script:DetectionMode - $migrated['Version'] = '1.2.0' + $migrated['Version'] = '1.3.0' $migrated | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8 - Write-AutoSwitchLog "Config migrated to v1.2.0 (DetectionMode=$script:DetectionMode)." + Write-AutoSwitchLog "Config migrated to v1.3.0 (DetectionMode=$script:DetectionMode)." } catch { } } @@ -191,8 +198,6 @@ function Receive-GHubText { if ($remainingMs -le 0) { throw "Timeout de peticion G HUB ($($script:RequestTimeoutMs) ms)." } - # El fragmento espera como mucho ReceiveTimeoutMs, nunca mas alla - # del deadline global de la peticion. $fragmentMs = [Math]::Min($script:ReceiveTimeoutMs, $remainingMs) $segment = New-Object 'System.ArraySegment[byte]' -ArgumentList (,$buffer) @@ -238,8 +243,6 @@ function Invoke-GHubGet { path = $Path } - # Limite global por peticion: aunque G HUB intercale eventos, - # la respuesta buscada debe llegar antes del deadline. $deadline = (Get-Date).AddMilliseconds($script:RequestTimeoutMs) while ($true) { @@ -249,14 +252,9 @@ function Invoke-GHubGet { $raw = Receive-GHubText -Deadline $deadline - try { - $message = $raw | ConvertFrom-Json - } - catch { - continue - } + try { $message = $raw | ConvertFrom-Json } + catch { continue } - # G HUB puede intercalar eventos asincronos. if (($message.msgId -eq $msgId) -or ($message.path -eq $Path)) { return $message } @@ -285,9 +283,28 @@ function Get-ProX2BatteryPath { return "/battery/$($headset.id)/state" } +function Get-SteelSeriesRuntimeConfig { + $path = $script:DefaultHeadsetControlPath + if ($Config.PSObject.Properties['HeadsetControlPath'] -and + -not [string]::IsNullOrWhiteSpace([string]$Config.HeadsetControlPath)) { + $path = [string]$Config.HeadsetControlPath + } + + if (-not $Config.PSObject.Properties['SteelSeriesProductId']) { + throw 'SteelSeriesProductId is missing from config.json.' + } + $productId = [int]$Config.SteelSeriesProductId + if ($productId -notin @(0x2232, 0x2253)) { + throw ("Unsupported SteelSeriesProductId: 0x{0:X4}" -f $productId) + } + + return [pscustomobject]@{ + HeadsetControlPath = $path + ProductId = $productId + } +} + function Get-DefaultRenderItemId { - # IMPORTANTE: /GetColumnValue ya escribe el valor en stdout. - # No agregar /Stdout aqui: /Stdout añade informacion del item y rompe el parseo. $raw = & $SvclPath /GetColumnValue "DefaultRenderDevice" "Item ID" 2>&1 $text = ($raw | Out-String).Trim() @@ -308,9 +325,7 @@ function Set-AudioOutput { $current = $null try { $current = Get-DefaultRenderItemId } catch {} - if ($current -and ($current -ieq $DeviceId)) { - return - } + if ($current -and ($current -ieq $DeviceId)) { return } & $SvclPath /SetDefault $DeviceId all | Out-Null Start-Sleep -Milliseconds 350 @@ -321,7 +336,6 @@ function Set-AudioOutput { return } - # Un reintento por si Windows estaba recreando el endpoint. Start-Sleep -Milliseconds 500 & $SvclPath /SetDefault $DeviceId all | Out-Null Start-Sleep -Milliseconds 350 @@ -336,31 +350,17 @@ function Set-AudioOutput { } # --- Estado del endpoint de Windows (DetectionMode=WindowsEndpoint) --- - function Get-SvclCsvExport { - # Exporta TODOS los items de sonido en CSV. /scomma "" lista todo. $raw = & $SvclPath /scomma "" 2>&1 return ($raw | Out-String).Trim() } function Get-HeadsetEndpointState { - <# - .SYNOPSIS - Devuelve 'Connected' / 'Disconnected' / 'Unknown' del endpoint del headset. - .DESCRIPTION - - Export valido + fila con Item ID coincidente -> estado normalizado. - - Export valido + fila ausente (endpoint no presente) -> Disconnected. - - Export invalido/vacio (svcl fallo, basura) -> Unknown (no tocar nada). - #> $csv = Get-SvclCsvExport - if (-not (Test-SvclExportValid -CsvText $csv)) { - # svcl no devolvio un export valido: estado desconocido, NO asumir off. - return 'Unknown' - } + if (-not (Test-SvclExportValid -CsvText $csv)) { return 'Unknown' } $rows = ConvertFrom-SvclCsv -Text $csv - $row = $rows | Where-Object { $id = Get-CsvColumn -Row $_ -Names @('Item ID') @@ -368,26 +368,15 @@ function Get-HeadsetEndpointState { } | Select-Object -First 1 - if (-not $row) { - # Export valido pero el endpoint no aparece: para Windows significa - # que no esta presente -> Disconnected. - return 'Disconnected' - } + if (-not $row) { return 'Disconnected' } $state = Get-CsvColumn -Row $row -Names @('Device State', 'State') - if ($null -eq $state) { - return 'Unknown' - } + if ($null -eq $state) { return 'Unknown' } return Resolve-EndpointState -State $state } # --- Tray icon + Timer (message pump) + worker process --- -# El polling corre en un proceso PowerShell aparte (worker) para no bloquear -# el hilo de WinForms. El Timer del hilo de UI solo marca estado; el worker -# hace polling con su propio guard anti-concurrencia (no lanza un poll si el -# anterior sigue en marcha). - $script:TrayIcon = $null $script:MenuItemAutoSwitch = $null $script:MenuItemEnhancements = $null @@ -398,13 +387,10 @@ $script:MenuItemInfoNext = $null $script:ReloadFlag = $null function Get-EnhancementsAction { - # Lee el estado actual de SysFx del headset y devuelve 'Disable' o 'Enable'. $fx = Get-EndpointFxState -DeviceId ([string]$Config.HeadsetId) - if ($null -eq $fx) { - return $null - } - if ($fx) { return 'Enable' } # ya deshabilitados -> ofrecer habilitar - return 'Disable' # habilitados -> ofrecer deshabilitar + if ($null -eq $fx) { return $null } + if ($fx) { return 'Enable' } + return 'Disable' } function Update-EnhancementsMenu { @@ -454,7 +440,6 @@ function Invoke-EnhancementsToggle { $proc.WaitForExit() } catch { - # UAC canceled or failed to launch. Write-AutoSwitchLog ("Enhancements: change canceled or failed ({0})." -f $_.Exception.Message) return } @@ -468,8 +453,6 @@ function Invoke-EnhancementsToggle { } } -# --- Info del tray: headset, fallback y a donde se cambiaria ahora --- - function Get-RenderDevicesFromCsv { $csv = Get-SvclCsvExport if (-not (Test-SvclExportValid -CsvText $csv)) { return @() } @@ -477,7 +460,6 @@ function Get-RenderDevicesFromCsv { } function Update-TrayInfo { - # Actualiza las lineas de informacion del menu de bandeja. $hs = if ($Config.HeadsetName) { [string]$Config.HeadsetName } else { [string]$Config.HeadsetId } $fb = if ($Config.SpeakerName) { [string]$Config.SpeakerName } else { [string]$Config.SpeakerId } if ($script:MenuItemInfoHeadset) { @@ -489,7 +471,6 @@ function Update-TrayInfo { $script:MenuItemInfoFallback.Enabled = $false } - # A donde se cambiaria ahora segun el default actual. $next = "Current: ?" try { $current = Get-DefaultRenderItemId @@ -503,9 +484,8 @@ function Update-TrayInfo { $next = "Current: $current" } } - catch { - $next = "Next switch: unknown" - } + catch { $next = "Next switch: unknown" } + if ($script:MenuItemInfoNext) { $script:MenuItemInfoNext.Text = $next $script:MenuItemInfoNext.Enabled = $false @@ -513,22 +493,11 @@ function Update-TrayInfo { } # --- Reconfigurar headset/fallback sin reinstalar --- - function Save-Config { $Config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8 } function Get-HeadsetStateForId { - <# - .SYNOPSIS - Igual que Get-HeadsetEndpointState pero para un Item ID arbitrario - (el del headset que se esta seleccionando en el wizard). - .DESCRIPTION - Bluetooth headsets can disappear from the svcl export while off and - come back with a DIFFERENT Item ID when reconnected. So when the row - is not found by ItemId, fall back to matching the Device Name/Name - (stable across reconnects). Returns Connected/Disconnected/Unknown. - #> param( [Parameter(Mandatory = $true)][string]$ItemId, [string]$DeviceName, @@ -550,10 +519,6 @@ function Get-HeadsetStateForId { } | Select-Object -First 1 - # Fallback de identidad: un endpoint Bluetooth puede reaparecer con otro - # Item ID. No usar la etiqueta visible "Device Name — Name" como si fuera - # una columna: resolver por las dos columnas reales evita además confundir - # dos endpoints Render del mismo dispositivo. if (-not $row -and (-not [string]::IsNullOrWhiteSpace($DeviceName) -or -not [string]::IsNullOrWhiteSpace($EndpointName))) { @@ -572,7 +537,6 @@ function Get-HeadsetStateForId { if (-not $row) { if ($Diagnose) { - # Que hay en la exportacion? Render devices con sus estados e IDs. $lines = @() foreach ($r in $rows) { $name = Get-CsvColumn -Row $r -Names @('Device Name', 'Name') @@ -598,15 +562,6 @@ function Get-HeadsetStateForId { } function Wait-ForHeadsetState { - <# - .SYNOPSIS - Hace polling del estado del endpoint hasta que coincida con el esperado - o expire el timeout. Un headset Bluetooth (p. ej. Jabra) puede tardar - varios segundos en reflejar el cambio fisico en Windows; una lectura - unica a los 500/800 ms lee demasiado pronto. Tambien puede desaparecer - y volver con otro Item ID -> se busca por nombre como respaldo y se - devuelve el Item ID observado. - #> param( [Parameter(Mandatory = $true)][string]$ItemId, [Parameter(Mandatory = $true)][string]$Expected, @@ -626,33 +581,20 @@ function Wait-ForHeadsetState { $last = $res.State if ($res.FoundId) { $foundId = $res.FoundId } } - else { - $last = $res - } + else { $last = $res } + if ($last -eq $Expected) { - return [pscustomobject]@{ - State = $last - FoundId = $foundId - } + return [pscustomobject]@{ State = $last; FoundId = $foundId } } Start-Sleep -Milliseconds $PollIntervalMs } while ((Get-Date) -lt $deadline) - # Timeout: el estado observado no alcanzo el esperado. Diagnostico con contexto. Write-AutoSwitchLog ("Wait-ForHeadsetState: timeout esperando '{0}' para {1}. Ultimo estado observado: {2}" -f $Expected, $ItemId, $last) [void](Get-HeadsetStateForId -ItemId $ItemId -DeviceName $DeviceName -EndpointName $EndpointName -Diagnose) - return [pscustomobject]@{ - State = $last - FoundId = $foundId - } + return [pscustomobject]@{ State = $last; FoundId = $foundId } } function Test-GHubProX2 { - <# - .SYNOPSIS - Conecta con G HUB y devuelve el candidato PRO X 2 que coincide con el - headset seleccionado en el wizard (o el unico, o $null si no hay). - #> try { Connect-GHub $devices = Invoke-GHubGet -Path "/devices/list" @@ -665,7 +607,6 @@ function Test-GHubProX2 { if ($candidates.Count -eq 0) { return $null } if ($candidates.Count -eq 1) { return $candidates[0] } - # Multiple PRO X 2 devices: never guess. Ask the user which one to use. foreach ($candidate in $candidates) { $answer = [System.Windows.Forms.MessageBox]::Show( ("Multiple PRO X 2 devices were found in G HUB.`n`nUse this one?`n{0} ({1})" -f $candidate.extendedDisplayName, $candidate.id), @@ -673,12 +614,8 @@ function Test-GHubProX2 { [System.Windows.Forms.MessageBoxButtons]::YesNoCancel, [System.Windows.Forms.MessageBoxIcon]::Question ) - if ($answer -eq [System.Windows.Forms.DialogResult]::Yes) { - return $candidate - } - if ($answer -eq [System.Windows.Forms.DialogResult]::Cancel) { - return $null - } + if ($answer -eq [System.Windows.Forms.DialogResult]::Yes) { return $candidate } + if ($answer -eq [System.Windows.Forms.DialogResult]::Cancel) { return $null } } return $null } @@ -688,6 +625,22 @@ function Test-GHubProX2 { } } +function Test-SteelSeriesNova5ForReconfigure { + try { + if (-not (Test-Path -LiteralPath $script:DefaultHeadsetControlPath)) { + # Reconfigure may be used after switching from another headset. + # Download the same pinned portable binary the installer uses. + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + [void](Install-HeadsetControlPortable -DestinationPath $script:DefaultHeadsetControlPath) + } + return Get-SteelSeriesNova5ConnectedProductId -HeadsetControlPath $script:DefaultHeadsetControlPath + } + catch { + Write-AutoSwitchLog ("Reconfigure: SteelSeries check failed: {0}" -f $_.Exception.Message) + return $null + } +} + function Show-ReconfigureDialog { $devices = Get-RenderDevicesFromCsv if ($devices.Count -lt 2) { @@ -718,8 +671,7 @@ function Show-ReconfigureDialog { $comboHeadset.Location = New-Object System.Drawing.Point(150, 20) $comboHeadset.Width = 280 foreach ($d in $devices) { - $label = Get-SvclDeviceLabel -Row $d - [void]$comboHeadset.Items.Add($label) + [void]$comboHeadset.Items.Add((Get-SvclDeviceLabel -Row $d)) } $lblFallback = New-Object System.Windows.Forms.Label @@ -732,11 +684,9 @@ function Show-ReconfigureDialog { $comboFallback.Location = New-Object System.Drawing.Point(150, 60) $comboFallback.Width = 280 foreach ($d in $devices) { - $label = Get-SvclDeviceLabel -Row $d - [void]$comboFallback.Items.Add($label) + [void]$comboFallback.Items.Add((Get-SvclDeviceLabel -Row $d)) } - # Preseleccionar los valores actuales. for ($i = 0; $i -lt $devices.Count; $i++) { $id = Get-CsvColumn -Row $devices[$i] -Names @('Item ID') if ($id -ieq [string]$Config.HeadsetId) { $comboHeadset.SelectedIndex = $i } @@ -792,10 +742,6 @@ function Show-ReconfigureDialog { $newHeadsetEndpointName = Get-CsvColumn -Row $selectedHeadsetRow -Names @('Name') $newSpeakerName = Get-SvclDeviceLabel -Row $devices[$comboFallback.SelectedIndex] - # --- Ciclo ON -> OFF -> ON del headset seleccionado --- - # El wizard hace polling porque Bluetooth/Core Audio puede tardar - # varios segundos en reflejar cada transicion. Si Windows recrea el - # endpoint, se re-resuelve por Device Name + Name y se persiste el ID. $lblStatus.Text = "Step 1/3: turn the headset ON, then click OK in the prompt." $lblStatus.Refresh() @@ -826,12 +772,9 @@ function Show-ReconfigureDialog { $s1 = $r1.State $s2 = $r2.State $s3 = $r3.State - $lblStatus.Text = "Step 2/3: ON=$s1 OFF=$s2 ON=$s3" $lblStatus.Refresh() - # Si el BT reaparecio con un Item ID nuevo, persistir el observado - # (el ultimo FoundId no nulo, preferentemente el del paso 3). $observedId = $null if ($r3.FoundId) { $observedId = $r3.FoundId } elseif ($r1.FoundId) { $observedId = $r1.FoundId } @@ -841,24 +784,62 @@ function Show-ReconfigureDialog { $newMode = $null $newGhubName = $null + $newSteelSeriesProductId = $null if ($s1 -eq 'Connected' -and $s2 -eq 'Disconnected' -and $s3 -eq 'Connected') { $newMode = "WindowsEndpoint" } else { - $answer = [System.Windows.Forms.MessageBox]::Show( - "Windows does not reflect the physical ON/OFF cycle of this headset.`n`nIs it a Logitech PRO X 2 detected by G HUB?", + $steelAnswer = [System.Windows.Forms.MessageBox]::Show( + "Windows does not reflect the physical ON/OFF cycle of this headset.`n`nIs it a SteelSeries Arctis Nova 5 or Nova 5X?", "Audio AutoSwitch", [System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Question ) - if ($answer -eq 'Yes') { - $lblStatus.Text = "Step 3/3: checking G HUB for PRO X 2..." + + if ($steelAnswer -eq [System.Windows.Forms.DialogResult]::Yes) { + $lblStatus.Text = "Step 3/3: checking SteelSeries receiver..." $lblStatus.Refresh() - $ghubDevice = Test-GHubProX2 - if ($ghubDevice) { - $newMode = "LogitechGHub" - $newGhubName = [string]$ghubDevice.extendedDisplayName + $newSteelSeriesProductId = Test-SteelSeriesNova5ForReconfigure + + if ($newSteelSeriesProductId) { + [System.Windows.Forms.MessageBox]::Show( + "SteelSeries receiver found. Turn the headset OFF now, then click OK.", + "Audio AutoSwitch", + [System.Windows.Forms.MessageBoxButtons]::OK, + [System.Windows.Forms.MessageBoxIcon]::Information + ) | Out-Null + $steelOff = Wait-SteelSeriesNova5State -Expected 'Disconnected' -HeadsetControlPath $script:DefaultHeadsetControlPath -ProductId $newSteelSeriesProductId -TimeoutSeconds 15 + + [System.Windows.Forms.MessageBox]::Show( + "Turn the headset back ON now, then click OK.", + "Audio AutoSwitch", + [System.Windows.Forms.MessageBoxButtons]::OK, + [System.Windows.Forms.MessageBoxIcon]::Information + ) | Out-Null + $steelOn = Wait-SteelSeriesNova5State -Expected 'Connected' -HeadsetControlPath $script:DefaultHeadsetControlPath -ProductId $newSteelSeriesProductId -TimeoutSeconds 20 + + if ($steelOff -eq 'Disconnected' -and $steelOn -eq 'Connected') { + $newMode = 'SteelSeriesNova5' + } + } + } + + if (-not $newMode) { + $answer = [System.Windows.Forms.MessageBox]::Show( + "No SteelSeries Nova 5/5X cycle was validated.`n`nIs this headset a Logitech PRO X 2 detected by G HUB?", + "Audio AutoSwitch", + [System.Windows.Forms.MessageBoxButtons]::YesNo, + [System.Windows.Forms.MessageBoxIcon]::Question + ) + if ($answer -eq [System.Windows.Forms.DialogResult]::Yes) { + $lblStatus.Text = "Step 3/3: checking G HUB for PRO X 2..." + $lblStatus.Refresh() + $ghubDevice = Test-GHubProX2 + if ($ghubDevice) { + $newMode = "LogitechGHub" + $newGhubName = [string]$ghubDevice.extendedDisplayName + } } } } @@ -873,15 +854,12 @@ function Show-ReconfigureDialog { return } - # --- Guardar config completa (modo incluido) --- $Config.HeadsetId = [string]$newHeadsetId $Config.SpeakerId = [string]$newFallbackId $Config.HeadsetName = $newHeadsetName $Config.SpeakerName = $newSpeakerName - - # Optional properties may not exist in WindowsEndpoint configs. - # Add-Member -Force updates existing values and safely creates missing ones. $Config | Add-Member -NotePropertyName DetectionMode -NotePropertyValue $newMode -Force + $Config | Add-Member -NotePropertyName Version -NotePropertyValue '1.3.0' -Force $Config | Add-Member -NotePropertyName EnhancementsDeviceId -NotePropertyValue ([string]$newHeadsetId) -Force if ($newMode -eq 'LogitechGHub') { @@ -897,8 +875,16 @@ function Show-ReconfigureDialog { $Config.PSObject.Properties.Remove('GHubPort') } + if ($newMode -eq 'SteelSeriesNova5') { + $Config | Add-Member -NotePropertyName SteelSeriesProductId -NotePropertyValue ([int]$newSteelSeriesProductId) -Force + $Config | Add-Member -NotePropertyName HeadsetControlPath -NotePropertyValue ([string]$script:DefaultHeadsetControlPath) -Force + } + else { + $Config.PSObject.Properties.Remove('SteelSeriesProductId') + $Config.PSObject.Properties.Remove('HeadsetControlPath') + } + Save-Config - # Pide al worker que recargue la config en el siguiente ciclo. try { New-Item -ItemType File -Path $script:ReloadFlag -Force | Out-Null } catch {} Write-AutoSwitchLog ("Reconfigured: headset={0} fallback={1} mode={2}" -f $Config.HeadsetName, $Config.SpeakerName, $newMode) @@ -923,9 +909,7 @@ function Show-ReconfigureDialog { [System.Windows.Forms.MessageBoxIcon]::Error ) | Out-Null } - finally { - $btnDetect.Enabled = $true - } + finally { $btnDetect.Enabled = $true } }) $form.Controls.Add($lblHeadset) @@ -943,11 +927,7 @@ function Invoke-Reconfigure { Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing - # Try/catch exterior: si el fallo ocurre al construir/abrir el dialogo - # (antes de entrar en "Detect mode..."), que quede registrado en el log. - try { - Show-ReconfigureDialog - } + try { Show-ReconfigureDialog } catch { Write-AutoSwitchLog ("Reconfigure failed: {0}" -f $_.Exception.Message) try { @@ -987,15 +967,6 @@ function Stop-Runtime { } # --- Worker: proceso separado que hace el polling --- -# El polling (svcl/G HUB, Set-AudioOutput) corre en un proceso PowerShell -# aparte (AUTOSWITCH_WORKER=1) para no bloquear el hilo de WinForms (tray). -# La comunicacion con el proceso principal es por archivos de control: -# $ControlDir\enabled.flag - el tray lo crea/borra (AutoSwitch ON/OFF) -# $ControlDir\stop.flag - el tray lo crea al salir -# El propio worker tiene un guard anti-polls-concurrentes: si una lectura -# (p. ej. un /SetDefault lento o G HUB en timeout) se pasa del intervalo, -# no se lanza otro poll hasta que termine. - $script:ControlDir = Join-Path $InstallDir "control" $script:EnabledFlag = Join-Path $script:ControlDir "enabled.flag" $script:StopFlag = Join-Path $script:ControlDir "stop.flag" @@ -1003,11 +974,7 @@ $script:ReloadFlag = Join-Path $script:ControlDir "reload.flag" $script:WorkerProcess = $null function Start-Worker { - # Lanza el mismo script en modo worker (env var). Asi el worker tiene - # acceso a TODAS las funciones (G HUB, audio, endpoint) sin duplicarlas. try { New-Item -ItemType Directory -Path $script:ControlDir -Force | Out-Null } catch {} - - # Estado inicial: AutoSwitch ON. try { New-Item -ItemType File -Path $script:EnabledFlag -Force | Out-Null } catch {} try { Remove-Item $script:StopFlag -Force -ErrorAction SilentlyContinue } catch {} @@ -1037,18 +1004,12 @@ function Stop-Worker { try { if ($script:WorkerProcess -and -not $script:WorkerProcess.HasExited) { $script:WorkerProcess.WaitForExit(5000) | Out-Null - if (-not $script:WorkerProcess.HasExited) { - $script:WorkerProcess.Kill() - } + if (-not $script:WorkerProcess.HasExited) { $script:WorkerProcess.Kill() } $script:WorkerProcess.Dispose() } } catch {} } -# --- Bucle del worker (solo cuando AUTOSWITCH_WORKER=1) --- -# Se ejecuta al final del script; las funciones G HUB/audio/endpoint ya estan -# definidas arriba porque es el mismo script. - function Start-WorkerLoop { $controlDir = $env:AUTOSWITCH_CONTROL $enabledFlag = Join-Path $controlDir "enabled.flag" @@ -1063,30 +1024,25 @@ function Start-WorkerLoop { Write-AutoSwitchLog "Worker started (mode $script:DetectionMode)." while (-not (Test-Path $stopFlag)) { - # El tray pidio recargar la config (reconfiguracion sin reinstalar). if (Test-Path $reloadFlag) { try { Remove-Item $reloadFlag -Force -ErrorAction SilentlyContinue $newConfig = Get-Content -Raw -Path $ConfigPath | ConvertFrom-Json if ($newConfig) { - # Actualiza la variable global del script (no crear local). Set-Variable -Name Config -Value $newConfig -Scope 1 - # Si la config trae otro modo, el worker lo respeta. $newMode = Get-ConfigDetectionMode -Config $newConfig if ($newMode) { $script:DetectionMode = $newMode } $lastState = $null $misses = 0 $script:WorkerBatteryPath = $null + $availabilityLogged = $false Write-AutoSwitchLog "Config reloaded (reconfigure)." } } - catch { - Write-AutoSwitchLog ("Could not reload config: {0}" -f $_.Exception.Message) - } + catch { Write-AutoSwitchLog ("Could not reload config: {0}" -f $_.Exception.Message) } } - $enabled = Test-Path $enabledFlag - if (-not $enabled) { + if (-not (Test-Path $enabledFlag)) { Start-Sleep -Milliseconds ([int]$Config.PollMilliseconds) continue } @@ -1099,7 +1055,6 @@ function Start-WorkerLoop { $endpoint = Get-HeadsetEndpointState if ($endpoint -eq 'Connected') { $isOn = $true; $known = $true } elseif ($endpoint -eq 'Disconnected') { $isOn = $false; $known = $true } - # Unknown -> no tocar nada. } catch { if (-not $availabilityLogged) { @@ -1108,21 +1063,43 @@ function Start-WorkerLoop { } } } + elseif ($script:DetectionMode -eq 'SteelSeriesNova5') { + try { + $steelConfig = Get-SteelSeriesRuntimeConfig + $steelState = Get-SteelSeriesNova5State -HeadsetControlPath $steelConfig.HeadsetControlPath -ProductId $steelConfig.ProductId + + if ($steelState -eq 'Connected') { + $isOn = $true + $known = $true + if ($availabilityLogged) { Write-AutoSwitchLog "SteelSeries HID state provider recovered." } + $availabilityLogged = $false + } + elseif ($steelState -eq 'Disconnected') { + $isOn = $false + $known = $true + if ($availabilityLogged) { Write-AutoSwitchLog "SteelSeries HID state provider recovered." } + $availabilityLogged = $false + } + elseif (-not $availabilityLogged) { + Write-AutoSwitchLog "SteelSeries HID state is Unknown. No audio change will be made until a known state returns." + $availabilityLogged = $true + } + } + catch { + if (-not $availabilityLogged) { + Write-AutoSwitchLog ("SteelSeries HID provider unavailable: {0}. Unknown state; no audio change." -f $_.Exception.Message) + $availabilityLogged = $true + } + } + } else { - # LogitechGHub: conexion PERSISTENTE. Se conecta una vez, se resuelve - # el batteryPath una vez, y solo se reconecta (re-resolviendo el - # deviceId, que puede cambiar tras una reconexion) si algo falla. try { if (-not $script:WorkerBatteryPath) { Connect-GHub $script:WorkerBatteryPath = Get-ProX2BatteryPath - if ($availabilityLogged) { - Write-AutoSwitchLog "G HUB connection recovered." - } - else { - Write-AutoSwitchLog "Connected to G HUB." - } + if ($availabilityLogged) { Write-AutoSwitchLog "G HUB connection recovered." } + else { Write-AutoSwitchLog "Connected to G HUB." } $availabilityLogged = $false } @@ -1144,7 +1121,6 @@ function Start-WorkerLoop { } if ($known) { - # Debounce: solo se decide OFF tras OffMissThreshold lecturas. $state = Resolve-HeadsetState ` -PayloadPresent $isOn ` -Misses $misses ` @@ -1161,9 +1137,7 @@ function Start-WorkerLoop { } $lastState = $state.IsOn } - catch { - Write-AutoSwitchLog ("No se pudo cambiar la salida de audio: {0}. Se reintentara." -f $_.Exception.Message) - } + catch { Write-AutoSwitchLog ("No se pudo cambiar la salida de audio: {0}. Se reintentara." -f $_.Exception.Message) } } } @@ -1180,27 +1154,16 @@ function Initialize-TrayAndTimer { $script:TrayIcon = New-Object System.Windows.Forms.NotifyIcon - # Icono propio de la app (icono de auricular azul). Si el .ico no existe - # en disco, caemos a SystemIcons.Application para que siempre haya uno. try { $iconFile = Join-Path $InstallDir "icon.ico" - if (Test-Path $iconFile) { - $script:TrayIcon.Icon = [System.Drawing.Icon]::new($iconFile) - } - else { - $script:TrayIcon.Icon = [System.Drawing.SystemIcons]::Application - } - } - catch { - $script:TrayIcon.Icon = [System.Drawing.SystemIcons]::Application + if (Test-Path $iconFile) { $script:TrayIcon.Icon = [System.Drawing.Icon]::new($iconFile) } + else { $script:TrayIcon.Icon = [System.Drawing.SystemIcons]::Application } } + catch { $script:TrayIcon.Icon = [System.Drawing.SystemIcons]::Application } $script:TrayIcon.Text = "Audio AutoSwitch" $script:TrayIcon.Visible = $true - # Form invisible que sostiene el message pump de WinForms. Application.Run() - # sin Form no siempre mantiene el NotifyIcon visible activo en todos los - # builds de .NET; con un Form oculto el pump es robusto y la bandeja no se cae. $script:MainForm = New-Object System.Windows.Forms.Form $script:MainForm.WindowState = 'Minimized' $script:MainForm.ShowInTaskbar = $false @@ -1209,7 +1172,6 @@ function Initialize-TrayAndTimer { $menu = New-Object System.Windows.Forms.ContextMenuStrip - # Lineas de informacion (Headset / Fallback / Next switch). $script:MenuItemInfoHeadset = New-Object System.Windows.Forms.ToolStripMenuItem $script:MenuItemInfoHeadset.Text = "Headset: ..." $script:MenuItemInfoHeadset.Enabled = $false @@ -1225,8 +1187,7 @@ function Initialize-TrayAndTimer { $script:MenuItemInfoNext.Enabled = $false [void]$menu.Items.Add($script:MenuItemInfoNext) - $sep = New-Object System.Windows.Forms.ToolStripSeparator - [void]$menu.Items.Add($sep) + [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) $script:MenuItemAutoSwitch = New-Object System.Windows.Forms.ToolStripMenuItem $script:MenuItemAutoSwitch.Text = "AutoSwitch: Enabled" @@ -1239,7 +1200,6 @@ function Initialize-TrayAndTimer { $script:MenuItemEnhancements.Add_Click({ Invoke-EnhancementsToggle }) [void]$menu.Items.Add($script:MenuItemEnhancements) - # Submenu de reconfiguracion (elegir headset/fallback sin reinstalar). $reconfigureItem = New-Object System.Windows.Forms.ToolStripMenuItem $reconfigureItem.Text = "Reconfigure..." $reconfigureItem.Add_Click({ Invoke-Reconfigure }) @@ -1252,9 +1212,6 @@ function Initialize-TrayAndTimer { $script:TrayIcon.ContextMenuStrip = $menu - # Refresca el texto del menu de enhancements periodicamente (el estado - # SysFx puede cambiar desde el instalador o un helper externo). Se actualiza - # en el hilo de UI; Get-EndpointFxState es una lectura COM ligera. $script:MenuTimer = New-Object System.Windows.Forms.Timer $script:MenuTimer.Interval = 5000 $script:MenuTimer.Add_Tick({ @@ -1271,7 +1228,6 @@ function Initialize-TrayAndTimer { Write-AutoSwitchLog "PRO X 2 AutoSwitch started (mode $script:DetectionMode)." if ($env:AUTOSWITCH_WORKER -eq '1') { - # Modo worker: solo el bucle de polling. No toca la bandeja. Start-WorkerLoop exit 0 } @@ -1288,8 +1244,6 @@ try { } } - # Message pump principal sostenido por un Form invisible. La bandeja de - # notificaciones procesa eventos aqui; se mantiene activa hasta Exit. [System.Windows.Forms.Application]::Run($script:MainForm) Write-AutoSwitchLog "Message pump finished (Exit)." } diff --git a/Verificar-PROX2-AutoSwitch.ps1 b/Verificar-PROX2-AutoSwitch.ps1 index e8482b5..7a5c55c 100644 --- a/Verificar-PROX2-AutoSwitch.ps1 +++ b/Verificar-PROX2-AutoSwitch.ps1 @@ -1,165 +1,148 @@ #requires -Version 5.1 -$ErrorActionPreference = "Continue" - -$InstallDir = Join-Path $env:LOCALAPPDATA "PROX2AutoSwitch" -$MainScript = Join-Path $InstallDir "PROX2AutoSwitch.ps1" -$ConfigPath = Join-Path $InstallDir "config.json" -$SvclPath = Join-Path $InstallDir "svcl.exe" -$LogPath = Join-Path $InstallDir "autoswitch.log" -$ShortcutPath = Join-Path ([Environment]::GetFolderPath("Startup")) "PRO X 2 AutoSwitch.lnk" - -Write-Host "" -Write-Host "=== Audio AutoSwitch verification ===" -ForegroundColor Cyan -Write-Host "" - +$ErrorActionPreference = 'Continue' + +$InstallDir = Join-Path $env:LOCALAPPDATA 'PROX2AutoSwitch' +$MainScript = Join-Path $InstallDir 'PROX2AutoSwitch.ps1' +$ConfigPath = Join-Path $InstallDir 'config.json' +$SvclPath = Join-Path $InstallDir 'svcl.exe' +$LogPath = Join-Path $InstallDir 'autoswitch.log' +$CoreModule = Join-Path $InstallDir 'lib\AutoSwitchCore.psm1' +$SteelSeriesModule = Join-Path $InstallDir 'lib\SteelSeriesNova5.psm1' +$DefaultHeadsetControlPath = Join-Path $InstallDir 'headsetcontrol.exe' +$ShortcutPath = Join-Path ([Environment]::GetFolderPath('Startup')) 'PRO X 2 AutoSwitch.lnk' + +$script:Failed = $false function Show-Test { param([string]$Label, [bool]$Ok, [string]$Detail) + if ($Ok) { Write-Host "[OK] $Label" -ForegroundColor Green } + else { Write-Host "[FAIL] $Label" -ForegroundColor Red; $script:Failed = $true } + if ($Detail) { Write-Host " $Detail" -ForegroundColor DarkGray } +} - if ($Ok) { - Write-Host "[OK] $Label" -ForegroundColor Green - } - else { - Write-Host "[FAIL] $Label" -ForegroundColor Red - } +Write-Host '' +Write-Host '=== Audio AutoSwitch verification ===' -ForegroundColor Cyan +Write-Host '' - if ($Detail) { - Write-Host " $Detail" -ForegroundColor DarkGray - } -} +Show-Test 'Install directory' (Test-Path -LiteralPath $InstallDir) $InstallDir +Show-Test 'Runtime' (Test-Path -LiteralPath $MainScript) $MainScript +Show-Test 'Logic module (lib)' (Test-Path -LiteralPath $CoreModule) $CoreModule +Show-Test 'SteelSeries provider module' (Test-Path -LiteralPath $SteelSeriesModule) $SteelSeriesModule +Show-Test 'Configuration' (Test-Path -LiteralPath $ConfigPath) $ConfigPath +Show-Test 'svcl.exe' (Test-Path -LiteralPath $SvclPath) $SvclPath +Show-Test 'Invisible autostart' (Test-Path -LiteralPath $ShortcutPath) $ShortcutPath -Show-Test "Install directory" (Test-Path $InstallDir) $InstallDir -Show-Test "Runtime" (Test-Path $MainScript) $MainScript -Show-Test "Logic module (lib)" (Test-Path (Join-Path $InstallDir "lib\AutoSwitchCore.psm1")) (Join-Path $InstallDir "lib\AutoSwitchCore.psm1") -Show-Test "Configuration" (Test-Path $ConfigPath) $ConfigPath -Show-Test "svcl.exe" (Test-Path $SvclPath) $SvclPath -Show-Test "Invisible autostart" (Test-Path $ShortcutPath) $ShortcutPath - -# Module functions (Get-ConfigDetectionMode, ConvertFrom-SvclCsv, Get-EndpointFxState). -$ModulePath = Join-Path $InstallDir "lib\AutoSwitchCore.psm1" -if (Test-Path $ModulePath) { - Import-Module $ModulePath -ErrorAction SilentlyContinue +if (Test-Path -LiteralPath $CoreModule) { + Import-Module $CoreModule -Force -ErrorAction SilentlyContinue +} +if (Test-Path -LiteralPath $SteelSeriesModule) { + Import-Module $SteelSeriesModule -Force -ErrorAction SilentlyContinue } $escaped = [regex]::Escape($MainScript) $process = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match $escaped } | Select-Object -First 1 - -$processDetail = "Not found" -if ($process) { - $processDetail = "PID $($process.ProcessId)" -} -Show-Test "AutoSwitch process" ($null -ne $process) $processDetail +Show-Test 'AutoSwitch process' ($null -ne $process) $(if ($process) { "PID $($process.ProcessId)" } else { 'Not found' }) $cfg = $null $mode = $null -if (Test-Path $ConfigPath) { +if (Test-Path -LiteralPath $ConfigPath) { try { - $cfg = Get-Content -Raw $ConfigPath | ConvertFrom-Json + $cfg = Get-Content -Raw -LiteralPath $ConfigPath | ConvertFrom-Json $mode = Get-ConfigDetectionMode -Config $cfg } - catch {} + catch { } } +Show-Test 'Detection mode' ($mode -in @('WindowsEndpoint', 'LogitechGHub', 'SteelSeriesNova5')) $(if ($mode) { $mode } else { 'Invalid or unreadable config' }) -# G HUB is a requirement only for the Logitech-specific detection path. A -# generic WindowsEndpoint installation must not show a scary false failure. -if ($mode -eq 'LogitechGHub') { - $ghubPort = $false - $ghubPortToTest = 9010 - if ($cfg -and $cfg.PSObject.Properties['GHubPort'] -and $cfg.GHubPort) { - $ghubPortToTest = [int]$cfg.GHubPort - } - +$currentState = $null +if ($mode -eq 'WindowsEndpoint' -and $cfg.HeadsetId -and (Test-Path -LiteralPath $SvclPath)) { try { - $client = New-Object System.Net.Sockets.TcpClient - $iar = $client.BeginConnect("127.0.0.1", $ghubPortToTest, $null, $null) - $ghubPort = $iar.AsyncWaitHandle.WaitOne(1000, $false) - if ($ghubPort) { - $client.EndConnect($iar) + $csv = (& $SvclPath /scomma '' 2>&1 | Out-String).Trim() + if (Test-SvclExportValid -CsvText $csv) { + $rows = @(ConvertFrom-SvclCsv -Text $csv) + $row = $rows | Where-Object { + $id = Get-CsvColumn -Row $_ -Names @('Item ID') + $null -ne $id -and $id.Trim().ToLowerInvariant() -eq ([string]$cfg.HeadsetId).Trim().ToLowerInvariant() + } | Select-Object -First 1 + + if ($row) { + $rawState = Get-CsvColumn -Row $row -Names @('Device State', 'State') + $currentState = Resolve-EndpointState -State $rawState + } + else { + $currentState = 'Disconnected' + } } - $client.Close() - } - catch { - $ghubPort = $false } + catch { } - $ghubDetail = if ($ghubPort) { "Port reachable" } else { "Open Logitech G HUB" } - Show-Test "G HUB localhost:$ghubPortToTest" $ghubPort $ghubDetail -} -elseif ($mode -eq 'WindowsEndpoint') { - Write-Host "[SKIP] G HUB (not used in WindowsEndpoint mode)" -ForegroundColor DarkGray -} -else { - Show-Test "Detection mode" $false "Could not read a valid DetectionMode from config.json" + Show-Test 'Windows headset state' ($currentState -in @('Connected', 'Disconnected')) $(if ($currentState) { "State: $currentState" } else { 'Unreadable' }) } - -if (Test-Path $ConfigPath) { +elseif ($mode -eq 'LogitechGHub') { + $port = 9010 + if ($cfg.PSObject.Properties['GHubPort'] -and $cfg.GHubPort) { $port = [int]$cfg.GHubPort } + $reachable = $false try { - if (-not $cfg) { - $cfg = Get-Content -Raw $ConfigPath | ConvertFrom-Json - } - - Write-Host "" - Write-Host "Configuration:" -ForegroundColor Yellow - - $mode = Get-ConfigDetectionMode -Config $cfg - Write-Host " Detection mode: $mode" - - if ($cfg.HeadsetName) { Write-Host " Headset: $($cfg.HeadsetName)" } - if ($cfg.SpeakerName) { Write-Host " Fallback: $($cfg.SpeakerName)" } - - if ($mode -eq 'LogitechGHub' -and $cfg.GHubDisplayName) { - Write-Host " G HUB: $($cfg.GHubDisplayName)" + $reachable = Test-NetConnection -ComputerName '127.0.0.1' -Port $port -InformationLevel Quiet -WarningAction SilentlyContinue + } + catch { $reachable = $false } + Show-Test "G HUB localhost:$port" ([bool]$reachable) $(if ($reachable) { 'Port reachable' } else { 'Open Logitech G HUB' }) +} +elseif ($mode -eq 'SteelSeriesNova5') { + $hcPath = $DefaultHeadsetControlPath + if ($cfg.PSObject.Properties['HeadsetControlPath'] -and + -not [string]::IsNullOrWhiteSpace([string]$cfg.HeadsetControlPath)) { + $hcPath = [string]$cfg.HeadsetControlPath + } + Show-Test 'HeadsetControl provider' (Test-Path -LiteralPath $hcPath) $hcPath + + $pid = 0 + $pidOk = $false + if ($cfg.PSObject.Properties['SteelSeriesProductId']) { + try { + $pid = [int]$cfg.SteelSeriesProductId + $pidOk = $pid -in @(0x2232, 0x2253) } + catch { } + } + Show-Test 'SteelSeries Nova 5/5X PID' $pidOk $(if ($pidOk) { '0x{0:X4}' -f $pid } else { 'Missing or unsupported PID' }) - # Current state of the headset endpoint (WindowsEndpoint only). - if ($mode -eq 'WindowsEndpoint' -and $cfg.HeadsetId) { - try { - $csv = (& $SvclPath /scomma "" 2>&1 | Out-String).Trim() - $rows = @(ConvertFrom-SvclCsv -Text $csv) - $row = $rows | Where-Object { - $id = Get-CsvColumn -Row $_ -Names @('Item ID') - $null -ne $id -and $id.Trim().ToLowerInvariant() -eq [string]$cfg.HeadsetId - } | Select-Object -First 1 - if ($row) { - $st = Get-CsvColumn -Row $row -Names @('Device State', 'State') - Write-Host " Headset state: $st" - } - else { - Write-Host " Headset state: (endpoint not present)" -ForegroundColor DarkGray - } - } - catch { - Write-Host " Headset state: unreadable" -ForegroundColor DarkGray - } + if ((Test-Path -LiteralPath $hcPath) -and $pidOk -and + (Get-Command Get-SteelSeriesNova5State -ErrorAction SilentlyContinue)) { + try { + $currentState = Get-SteelSeriesNova5State -HeadsetControlPath $hcPath -ProductId $pid } + catch { $currentState = 'Unknown' } + Show-Test 'SteelSeries physical state' ($currentState -in @('Connected', 'Disconnected')) "State: $currentState" + } +} - # Enhancements state (non-elevated read). - if ($cfg.HeadsetId) { - $fx = Get-EndpointFxState -DeviceId ([string]$cfg.HeadsetId) - if ($null -eq $fx) { - Write-Host " Enhancements: unreadable" -ForegroundColor DarkGray - } - elseif ($fx) { - Write-Host " Enhancements: DISABLED" -ForegroundColor Green - } - else { - Write-Host " Enhancements: enabled" -ForegroundColor Yellow - } - } +if ($cfg) { + Write-Host '' + Write-Host 'Configuration:' -ForegroundColor Yellow + Write-Host " Detection mode: $mode" + if ($cfg.HeadsetName) { Write-Host " Headset: $($cfg.HeadsetName)" } + if ($cfg.SpeakerName) { Write-Host " Fallback: $($cfg.SpeakerName)" } + if ($mode -eq 'LogitechGHub' -and $cfg.GHubDisplayName) { Write-Host " G HUB: $($cfg.GHubDisplayName)" } + if ($mode -eq 'SteelSeriesNova5' -and $cfg.PSObject.Properties['SteelSeriesProductId']) { + Write-Host (' SteelSeries PID: 0x{0:X4}' -f ([int]$cfg.SteelSeriesProductId)) } - catch { - Write-Warning "config.json could not be read: $($_.Exception.Message)" + if ($currentState) { Write-Host " Headset state: $currentState" } + + if ($cfg.HeadsetId -and (Get-Command Get-EndpointFxState -ErrorAction SilentlyContinue)) { + $fx = Get-EndpointFxState -DeviceId ([string]$cfg.HeadsetId) + if ($null -eq $fx) { Write-Host ' Enhancements: unreadable' -ForegroundColor DarkGray } + elseif ($fx) { Write-Host ' Enhancements: DISABLED' -ForegroundColor Green } + else { Write-Host ' Enhancements: enabled' -ForegroundColor Yellow } } } -Write-Host "" -Write-Host "Last log lines:" -ForegroundColor Yellow -if (Test-Path $LogPath) { - Get-Content $LogPath -Tail 15 -} -else { - Write-Host "(log does not exist yet)" -ForegroundColor DarkGray -} +Write-Host '' +Write-Host 'Last log lines:' -ForegroundColor Yellow +if (Test-Path -LiteralPath $LogPath) { Get-Content -LiteralPath $LogPath -Tail 15 } +else { Write-Host '(log does not exist yet)' -ForegroundColor DarkGray } +Write-Host '' -Write-Host "" +if ($script:Failed) { exit 1 } +exit 0 diff --git a/docs/STEELSERIES-NOVA5.md b/docs/STEELSERIES-NOVA5.md new file mode 100644 index 0000000..a466b0a --- /dev/null +++ b/docs/STEELSERIES-NOVA5.md @@ -0,0 +1,52 @@ +# SteelSeries Arctis Nova 5 / 5X detection + +This provider exists for headsets whose USB audio receiver stays visible to Windows while the physical headset is powered off. + +## Upstream protocol evidence + +The implementation relies on the open-source HeadsetControl project rather than duplicating its HID protocol inside AutoSwitch: + +- HeadsetControl repository: https://github.com/Sapd/HeadsetControl +- Nova 5 implementation: https://github.com/Sapd/HeadsetControl/blob/master/lib/devices/steelseries_arctis_nova_5.hpp +- Shared SteelSeries Nova protocol: https://github.com/Sapd/HeadsetControl/blob/master/lib/devices/protocols/steelseries_protocol.hpp +- Original Nova 5 support PR: https://github.com/Sapd/HeadsetControl/pull/361 + +Verified on 2026-08-13: + +- SteelSeries vendor ID: `0x1038`. +- Nova 5 receiver PID: `0x2232`. +- Nova 5X receiver PID: `0x2253`. +- HeadsetControl's Nova 5 status handling treats connection-status value `0x02` as the headset being offline. +- The CLI's `--timeout` value is expressed in milliseconds. + +AutoSwitch requests HeadsetControl battery status in JSON for one exact VID/PID. It maps: + +- `BATTERY_AVAILABLE` -> `Connected` +- `BATTERY_CHARGING` -> `Connected` +- Nova-specific `Headset not connected` error -> `Disconnected` +- malformed output, unrelated errors, missing provider or ambiguous devices -> `Unknown` + +`Unknown` is fail-safe: AutoSwitch does not change the audio output until a known state returns. + +## Pinned Windows provider + +The installer uses the portable Windows executable from the official HeadsetControl 4.0.0 release only when the SteelSeries fallback is selected and validated. + +```text +Version: 4.0.0 +Release date: 2026-07-23 +Asset: headsetcontrol-windows-x86_64.exe +SHA256: d78a86cc0f44403d2bcb16294f8f2d91cc2f9f343adb09907a8cef8278309be8 +``` + +The binary is not stored in this repository. The installer verifies the pinned SHA-256 before using the downloaded executable. + +## Why not `--connected`? + +HeadsetControl currently implements its generic connected check via battery status and returns true only for `BATTERY_AVAILABLE`. A physically connected headset that is charging can report `BATTERY_CHARGING`, so AutoSwitch uses the richer battery JSON instead of that boolean. + +## Validation flow + +The normal Windows endpoint cycle is always tried first. SteelSeries-specific detection is offered only if Windows fails to reflect ON -> OFF -> ON. The installer then validates a real SteelSeries OFF -> ON cycle before saving `DetectionMode = SteelSeriesNova5`. + +The runtime still applies the existing two-reading OFF debounce before switching to the fallback output. diff --git a/lib/SteelSeriesNova5.psm1 b/lib/SteelSeriesNova5.psm1 new file mode 100644 index 0000000..b979933 --- /dev/null +++ b/lib/SteelSeriesNova5.psm1 @@ -0,0 +1,189 @@ +#requires -Version 5.1 +# SteelSeriesNova5.psm1 - physical-state provider for SteelSeries Arctis Nova 5/5X. +# Uses the upstream HeadsetControl CLI, which queries the receiver over HID. + +Set-StrictMode -Version Latest + +function Get-ConfigDetectionMode { + <# + .SYNOPSIS + Extended DetectionMode resolver including SteelSeriesNova5. + .DESCRIPTION + This module is imported after AutoSwitchCore so this command extends + the existing resolver without modifying the shared COM-heavy module. + #> + [CmdletBinding()] + param([Parameter(Mandatory = $true)][object]$Config) + + $property = $Config.PSObject.Properties['DetectionMode'] + $mode = if ($null -ne $property) { [string]$property.Value } else { $null } + + if ([string]::IsNullOrWhiteSpace($mode)) { return 'LogitechGHub' } + if ($mode -ieq 'WindowsEndpoint') { return 'WindowsEndpoint' } + if ($mode -ieq 'LogitechGHub') { return 'LogitechGHub' } + if ($mode -ieq 'SteelSeriesNova5') { return 'SteelSeriesNova5' } + return $null +} + +function Get-HeadsetControlPortableInfo { + [CmdletBinding()] + param() + + return [pscustomobject]@{ + Version = '4.0.0' + Url = 'https://github.com/Sapd/HeadsetControl/releases/download/4.0.0/headsetcontrol-windows-x86_64.exe' + Sha256 = 'd78a86cc0f44403d2bcb16294f8f2d91cc2f9f343adb09907a8cef8278309be8' + } +} + +function Install-HeadsetControlPortable { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$DestinationPath + ) + + $info = Get-HeadsetControlPortableInfo + + if (Test-Path -LiteralPath $DestinationPath) { + try { + $existingHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $DestinationPath).Hash.ToLowerInvariant() + if ($existingHash -eq $info.Sha256) { + return $DestinationPath + } + } + catch { } + } + + $tempPath = Join-Path $env:TEMP ('headsetcontrol-{0}-{1}.exe' -f $info.Version, [guid]::NewGuid().ToString('N')) + try { + Invoke-WebRequest -UseBasicParsing -Uri $info.Url -OutFile $tempPath -ErrorAction Stop + $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $tempPath).Hash.ToLowerInvariant() + if ($actualHash -ne $info.Sha256) { + throw "HeadsetControl SHA-256 mismatch. Expected $($info.Sha256), got $actualHash." + } + + $parent = Split-Path -Parent $DestinationPath + if (-not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + Move-Item -LiteralPath $tempPath -Destination $DestinationPath -Force + return $DestinationPath + } + finally { + Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue + } +} + +function Resolve-SteelSeriesNova5HeadsetControlJson { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$JsonText, + [Parameter(Mandatory = $true)] + [ValidateSet(0x2232, 0x2253)] + [int]$ProductId + ) + + if ([string]::IsNullOrWhiteSpace($JsonText)) { return 'Unknown' } + + try { $data = $JsonText | ConvertFrom-Json -ErrorAction Stop } + catch { return 'Unknown' } + + $devices = @($data.devices) + if ($devices.Count -ne 1) { return 'Unknown' } + + $device = $devices[0] + $expectedProduct = ('0x{0:x4}' -f $ProductId) + if ([string]$device.id_vendor -ine '0x1038' -or + [string]$device.id_product -ine $expectedProduct) { + return 'Unknown' + } + + if ($null -ne $device.battery) { + $batteryStatus = [string]$device.battery.status + if ($batteryStatus -ieq 'BATTERY_AVAILABLE' -or + $batteryStatus -ieq 'BATTERY_CHARGING') { + return 'Connected' + } + } + + if ($null -ne $device.errors) { + foreach ($property in $device.errors.PSObject.Properties) { + $message = [string]$property.Value + if ($message -match '(?i)headset\s+not\s+connected') { + return 'Disconnected' + } + } + } + + return 'Unknown' +} + +function Get-SteelSeriesNova5State { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$HeadsetControlPath, + [Parameter(Mandatory = $true)] + [ValidateSet(0x2232, 0x2253)] + [int]$ProductId, + [ValidateRange(100, 5000)] + [int]$TimeoutMilliseconds = 1000 + ) + + if (-not (Test-Path -LiteralPath $HeadsetControlPath)) { return 'Unknown' } + + $deviceFilter = '0x1038:0x{0:x4}' -f $ProductId + try { + $raw = & $HeadsetControlPath --device $deviceFilter --battery --output JSON --timeout $TimeoutMilliseconds 2>&1 + $text = ($raw | Out-String).Trim() + return Resolve-SteelSeriesNova5HeadsetControlJson -JsonText $text -ProductId $ProductId + } + catch { return 'Unknown' } +} + +function Get-SteelSeriesNova5ConnectedProductId { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$HeadsetControlPath, + [ValidateRange(100, 5000)][int]$TimeoutMilliseconds = 1000 + ) + + $connected = @() + foreach ($productId in @(0x2232, 0x2253)) { + $state = Get-SteelSeriesNova5State -HeadsetControlPath $HeadsetControlPath -ProductId $productId -TimeoutMilliseconds $TimeoutMilliseconds + if ($state -eq 'Connected') { $connected += $productId } + } + + if ($connected.Count -eq 1) { return [int]$connected[0] } + return $null +} + +function Wait-SteelSeriesNova5State { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Connected', 'Disconnected')] + [string]$Expected, + [Parameter(Mandatory = $true)][string]$HeadsetControlPath, + [Parameter(Mandatory = $true)] + [ValidateSet(0x2232, 0x2253)] + [int]$ProductId, + [ValidateRange(1, 60)][int]$TimeoutSeconds = 15, + [ValidateRange(100, 5000)][int]$PollIntervalMilliseconds = 500, + [ValidateRange(100, 5000)][int]$ProbeTimeoutMilliseconds = 1000 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastState = 'Unknown' + + do { + $lastState = Get-SteelSeriesNova5State -HeadsetControlPath $HeadsetControlPath -ProductId $ProductId -TimeoutMilliseconds $ProbeTimeoutMilliseconds + if ($lastState -eq $Expected) { return $lastState } + Start-Sleep -Milliseconds $PollIntervalMilliseconds + } while ((Get-Date) -lt $deadline) + + return $lastState +} + +Export-ModuleMember -Function Get-ConfigDetectionMode, Get-HeadsetControlPortableInfo, Install-HeadsetControlPortable, Resolve-SteelSeriesNova5HeadsetControlJson, Get-SteelSeriesNova5State, Get-SteelSeriesNova5ConnectedProductId, Wait-SteelSeriesNova5State diff --git a/tests/SteelSeriesNova5.Tests.ps1 b/tests/SteelSeriesNova5.Tests.ps1 new file mode 100644 index 0000000..bdf8a52 --- /dev/null +++ b/tests/SteelSeriesNova5.Tests.ps1 @@ -0,0 +1,113 @@ +#requires -Version 5.1 + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..\lib\SteelSeriesNova5.psm1') -Force +} + +Describe 'SteelSeries Nova 5 HeadsetControl provider' { + It 'recognizes SteelSeriesNova5 as a valid detection mode' { + $cfg = [pscustomobject]@{ DetectionMode = 'SteelSeriesNova5' } + Get-ConfigDetectionMode -Config $cfg | Should -Be 'SteelSeriesNova5' + } + + It 'preserves the legacy detection-mode migration default' { + $cfg = [pscustomobject]@{} + Get-ConfigDetectionMode -Config $cfg | Should -Be 'LogitechGHub' + } + + It 'pins HeadsetControl 4.0.0 and its verified portable hash' { + $info = Get-HeadsetControlPortableInfo + $info.Version | Should -Be '4.0.0' + $info.Url | Should -Match '/Sapd/HeadsetControl/releases/download/4\.0\.0/headsetcontrol-windows-x86_64\.exe$' + $info.Sha256 | Should -Be 'd78a86cc0f44403d2bcb16294f8f2d91cc2f9f343adb09907a8cef8278309be8' + } + + It 'maps BATTERY_AVAILABLE to Connected' { + $json = @' +{ + "devices": [ + { + "id_vendor": "0x1038", + "id_product": "0x2232", + "battery": { "status": "BATTERY_AVAILABLE", "level": 76 } + } + ] +} +'@ + Resolve-SteelSeriesNova5HeadsetControlJson -JsonText $json -ProductId 0x2232 | + Should -Be 'Connected' + } + + It 'maps BATTERY_CHARGING to Connected' { + $json = @' +{ + "devices": [ + { + "id_vendor": "0x1038", + "id_product": "0x2253", + "battery": { "status": "BATTERY_CHARGING", "level": 42 } + } + ] +} +'@ + Resolve-SteelSeriesNova5HeadsetControlJson -JsonText $json -ProductId 0x2253 | + Should -Be 'Connected' + } + + It 'maps the Nova offline error to Disconnected' { + $json = @' +{ + "devices": [ + { + "id_vendor": "0x1038", + "id_product": "0x2232", + "status": "partial", + "battery": { "status": "BATTERY_UNAVAILABLE", "level": -1 }, + "errors": { "Battery status": "Headset not connected" } + } + ] +} +'@ + Resolve-SteelSeriesNova5HeadsetControlJson -JsonText $json -ProductId 0x2232 | + Should -Be 'Disconnected' + } + + It 'keeps an unrelated battery error Unknown' { + $json = @' +{ + "devices": [ + { + "id_vendor": "0x1038", + "id_product": "0x2232", + "status": "partial", + "battery": { "status": "BATTERY_UNAVAILABLE", "level": -1 }, + "errors": { "Battery status": "Could not open device" } + } + ] +} +'@ + Resolve-SteelSeriesNova5HeadsetControlJson -JsonText $json -ProductId 0x2232 | + Should -Be 'Unknown' + } + + It 'keeps malformed JSON Unknown' { + Resolve-SteelSeriesNova5HeadsetControlJson -JsonText '{broken' -ProductId 0x2232 | + Should -Be 'Unknown' + } + + It 'rejects a different product ID' { + $json = @' +{ + "devices": [ + { + "id_vendor": "0x1038", + "id_product": "0x2253", + "battery": { "status": "BATTERY_AVAILABLE", "level": 90 } + } + ] +} +'@ + Resolve-SteelSeriesNova5HeadsetControlJson -JsonText $json -ProductId 0x2232 | + Should -Be 'Unknown' + } +} From c8970606c55518f65453f4ea61c9cef3ad6ce42a Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:12:19 +0200 Subject: [PATCH 2/2] fix SteelSeries verifier lint --- Verificar-PROX2-AutoSwitch.ps1 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Verificar-PROX2-AutoSwitch.ps1 b/Verificar-PROX2-AutoSwitch.ps1 index 7a5c55c..9554da7 100644 --- a/Verificar-PROX2-AutoSwitch.ps1 +++ b/Verificar-PROX2-AutoSwitch.ps1 @@ -97,21 +97,21 @@ elseif ($mode -eq 'SteelSeriesNova5') { } Show-Test 'HeadsetControl provider' (Test-Path -LiteralPath $hcPath) $hcPath - $pid = 0 + $steelSeriesPid = 0 $pidOk = $false if ($cfg.PSObject.Properties['SteelSeriesProductId']) { try { - $pid = [int]$cfg.SteelSeriesProductId - $pidOk = $pid -in @(0x2232, 0x2253) + $steelSeriesPid = [int]$cfg.SteelSeriesProductId + $pidOk = $steelSeriesPid -in @(0x2232, 0x2253) } catch { } } - Show-Test 'SteelSeries Nova 5/5X PID' $pidOk $(if ($pidOk) { '0x{0:X4}' -f $pid } else { 'Missing or unsupported PID' }) + Show-Test 'SteelSeries Nova 5/5X PID' $pidOk $(if ($pidOk) { '0x{0:X4}' -f $steelSeriesPid } else { 'Missing or unsupported PID' }) if ((Test-Path -LiteralPath $hcPath) -and $pidOk -and (Get-Command Get-SteelSeriesNova5State -ErrorAction SilentlyContinue)) { try { - $currentState = Get-SteelSeriesNova5State -HeadsetControlPath $hcPath -ProductId $pid + $currentState = Get-SteelSeriesNova5State -HeadsetControlPath $hcPath -ProductId $steelSeriesPid } catch { $currentState = 'Unknown' } Show-Test 'SteelSeries physical state' ($currentState -in @('Connected', 'Disconnected')) "State: $currentState"