Logitech G933 Keeps Turning Off? How to Permanently Disable the Shutdown Timer

A 5‑minute "power‑saving" timer that wouldn't stay disabled sent me into Logitech G HUB's hidden local WebSocket API. Here's the tiny script that finally killed it.

Share
Logitech G933 Keeps Turning Off? How to Permanently Disable the Shutdown Timer
Photo by Alvaro Reyes / Unsplash

My Logitech G933 wireless headset powers itself off after 5 minutes of silence. There's a setting for it in Logitech G HUB — Shutdown Timer → Never — and it works… until the next reboot, or until I turn the headset off and back on. Then I'm back to a dead headset mid-call, and back into G HUB to flip the same toggle. Again.

If that sounds familiar, here's the good news: you can make "Never" actually mean never. The bad news is that the obvious fixes don't work, because the setting isn't stored where you'd think. I went down the rabbit hole so you don't have to — and ended up with a tiny script that keeps the timer disabled for good.

Why the obvious fix doesn't stick

The first thing I checked was where G HUB keeps this setting. It uses a SQLite database at %LOCALAPPDATA%\LGHUB\settings.db. I dug through it — EQ profiles, lighting, sidetone, all there. The shutdown timer? Not a single key for it.

That's the whole problem. The timer isn't a software setting that gets saved and restored. It's a command G HUB sends to the headset's firmware the moment you pick a value. The headset remembers it until it loses power — and on every power-cycle it reverts to the firmware default of 5 minutes. G HUB never re-sends the command when the headset reconnects, so your "Never" quietly evaporates.

(For the record, my G933 is on the latest firmware, 98.3.27 — this isn't a firmware bug you can update away. It's a missing re-apply.)

So the fix can't be "save the setting better." It has to be "re-send the disable command whenever the headset connects" — without me clicking through G HUB every time.

Finding G HUB's hidden API

G HUB is an Electron app; the window you see is just a front-end. The actual device communication is done by a background process, lghub_agent.exe. So I asked Windows what it's listening on:

Get-NetTCPConnection -State Listen |
  Where-Object { $_.OwningProcess -eq (Get-Process lghub_agent).Id }
# -> 127.0.0.1 : 9010

There it is: a local WebSocket on 127.0.0.1:9010. The G HUB UI drives the agent over this socket with a simple JSON verb/path protocol. The handshake only needs an Origin: file:// header and the json subprotocol. Once connected, you can ask it to list devices:

{ "msgId": "1", "verb": "GET", "path": "/devices/list" }

…and it returns every connected device, including my headset's internal id and — crucially — its real, live firmware values. Reading the timer for the headset:

GET /battery/{id}/sleep_timer  ->  { "shutdown": 5, "shallow": 0, "deep": 0 }

shutdown: 5. So that's where the 5 minutes lives. And because I can read it back, I can verify the true state instead of trusting G HUB's UI (which, as we'll see, may lie).

Reading the protocol

Now I needed the exact message to disable it. Rather than guess, I pulled the field names straight out of G HUB's own front-end bundle (resources\app.asar). Searching it for the sleep-timer code revealed exactly what the UI sends when you change the dropdown:

// from G HUB's app.asar — what the UI sends when you set the timer
s.shutdown = ...; s.shallow = ...; s.deep = ...;
xX.send(SET, "/battery/%(id)s/sleep_timer", s)

Three integer fields — shutdown, shallow, deep — all measured in minutes, where 0 means "Never." (On the original G933 only shutdown is actually honored; the other two are for newer models. I set all three to be safe.)

So the magic command — byte-for-byte identical to picking "Never" in the UI — is:

SET /battery/{id}/sleep_timer  { "shutdown": 0, "shallow": 0, "deep": 0 }

I fired it at the agent and got back code: SUCCESS. Timer disabled, no UI, in about a second.

The fix: read, then fix

A lazy script could just spam the disable command on a timer. But I wanted it to (a) only act when it actually needs to, and (b) reliably recover after any power-cycle. The trick is to read the live value first and only re-send "Never" if the firmware has reverted:

$ws   = Connect-Agent   # ws://127.0.0.1:9010, Origin file://, subprotocol json
$head = (Invoke-Agent $ws 'GET' '/devices/list').payload.deviceInfos |
          Where-Object deviceType -eq 'HEADSET' | Select-Object -First 1

$t = (Invoke-Agent $ws 'GET' "/battery/$($head.id)/sleep_timer").payload
if ($t.shutdown -or $t.shallow -or $t.deep) {
    Invoke-Agent $ws 'SET' "/battery/$($head.id)/sleep_timer" '{"shutdown":0,"shallow":0,"deep":0}'
    "Re-disabled (was shutdown=$($t.shutdown))" | Add-Content $log
}

Reading first means it self-corrects no matter when the headset reconnected — a power-cycle that happens between checks can't slip through. (An earlier version of mine that only acted on a freshly-detected connection actually did miss those. Read-then-fix is the robust pattern.)

Running it invisibly

I run this at logon and every few minutes via Task Scheduler. Two gotchas are worth knowing:

1. -WindowStyle Hidden still flashes a console. The clean fix is a one-line VBScript launcher run by wscript, which has no console window at all:

' run-hidden.vbs
CreateObject("WScript.Shell").Run _
  "powershell -NoProfile -ExecutionPolicy Bypass -File ""C:\Tools\G933KeepAwake\g933-keep-awake.ps1""", 0, True

Window style 0 is hidden, and the True makes it wait — otherwise Task Scheduler kills the PowerShell child the instant the launcher returns.

2. Pick the interval with the 5-minute timer in mind. It's tempting to run every 4 minutes ("4 < 5, done"). But the schedule isn't aligned to when you turn the headset on, so the worst-case latency is nearly a full interval after you connect — plus a few seconds for the headset to enumerate. I run every 3 minutes, which keeps a comfortable cushion under the 5-minute deadline.

Verifying it (don't trust the UI)

Here's the one trap that'll make you think it isn't working: G HUB's settings page may cache the value and only refresh when you reopen the device page. It'll happily show "5 minutes" long after the timer is actually disabled. So don't use it to verify.

Instead, ask the agent directly. The script has a -Check switch that reads the live value:

> g933-keep-awake.ps1 -Check
G933 [ACTIVE]: shutdown=0 shallow=0 deep=0  ->  DISABLED (Never)

That's the source of truth. The real-world test is even simpler: turn the headset on, walk away for ten minutes, and come back to a headset that's still alive.

The complete solution

Three files in one folder (I use C:\Tools\G933KeepAwake — adjust the paths if you use another).

1. g933-keep-awake.ps1 — the script:

[CmdletBinding()]
param([switch]$Check)   # -Check prints the live timer value and exits

$ErrorActionPreference = 'Stop'
$AgentUri = [Uri]'ws://127.0.0.1:9010'
$DisablePayload = '{"@type":"type.googleapis.com/logi.protocol.devices.SleepTimer","shutdown":0,"shallow":0,"deep":0}'
$Dir     = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Definition }
$LogFile = Join-Path $Dir 'g933-keep-awake.log'

function Write-Log([string]$Message) {
    $line = '{0}  {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
    try {
        if ((Test-Path $LogFile) -and ((Get-Item $LogFile).Length -gt 262144)) {
            Set-Content -Path $LogFile -Value (Get-Content $LogFile -Tail 300) -Encoding UTF8
        }
        Add-Content -Path $LogFile -Value $line -Encoding UTF8
    } catch {}
}

function Connect-Agent {
    $ws = New-Object System.Net.WebSockets.ClientWebSocket
    $ws.Options.AddSubProtocol('json')
    try { $ws.Options.SetRequestHeader('Origin', 'file://') } catch {}
    try {
        if ($ws.ConnectAsync($AgentUri, [System.Threading.CancellationToken]::None).Wait(5000) -and $ws.State -eq 'Open') {
            return $ws
        }
    } catch {}
    try { $ws.Dispose() } catch {}
    return $null
}

function Send-Json($ws, [string]$json) {
    $bytes = [Text.Encoding]::UTF8.GetBytes($json)
    $seg = New-Object System.ArraySegment[byte] (, $bytes)
    [void]$ws.SendAsync($seg, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, [System.Threading.CancellationToken]::None).Wait(3000)
}

function Receive-Json($ws, [int]$timeoutMs) {
    $buf = New-Object byte[] 131072
    $sb = New-Object System.Text.StringBuilder
    do {
        $seg = New-Object System.ArraySegment[byte] (, $buf)
        $task = $ws.ReceiveAsync($seg, [System.Threading.CancellationToken]::None)
        if (-not $task.Wait($timeoutMs)) { return $null }
        $res = $task.Result
        if ($res.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) { return $null }
        [void]$sb.Append([Text.Encoding]::UTF8.GetString($buf, 0, $res.Count))
    } while (-not $res.EndOfMessage)
    return $sb.ToString()
}

function Invoke-AgentRequest($ws, [string]$verb, [string]$path, [string]$payloadJson, [string]$msgId) {
    if ($payloadJson) {
        $msg = '{"msgId":"' + $msgId + '","verb":"' + $verb + '","path":"' + $path + '","payload":' + $payloadJson + '}'
    } else {
        $msg = '{"msgId":"' + $msgId + '","verb":"' + $verb + '","path":"' + $path + '"}'
    }
    Send-Json $ws $msg
    for ($i = 0; $i -lt 12; $i++) {
        $r = Receive-Json $ws 4000
        if ($null -eq $r) { return $null }
        if ($r -match ('"msgId":\s*"' + [regex]::Escape($msgId) + '"')) { return $r }   # skip unsolicited pushes
    }
    return $null
}

function Get-Headsets($ws) {
    $resp = Invoke-AgentRequest $ws 'GET' '/devices/list' $null 'list'
    if (-not $resp) { return @() }
    try { $obj = $resp | ConvertFrom-Json } catch { return @() }
    if (-not $obj.payload -or -not $obj.payload.deviceInfos) { return @() }
    return @($obj.payload.deviceInfos | Where-Object { $_.deviceType -eq 'HEADSET' })
}

function Get-Timer($ws, [string]$id) {
    $resp = Invoke-AgentRequest $ws 'GET' "/battery/$id/sleep_timer" $null 'gett'
    if (-not $resp) { return $null }
    try { $p = ($resp | ConvertFrom-Json).payload } catch { return $null }
    $get = { param($o, $n) if ($o.PSObject.Properties.Name -contains $n) { [int]$o.$n } else { 0 } }
    return @{ shutdown = & $get $p 'shutdown'; shallow = & $get $p 'shallow'; deep = & $get $p 'deep' }
}

function Set-Disabled($ws, [string]$id) {
    $resp = Invoke-AgentRequest $ws 'SET' "/battery/$id/sleep_timer" $DisablePayload 'sett'
    return ($resp -and $resp -match '"code":\s*"SUCCESS"')
}

# ---- main ----
$ws = Connect-Agent
if (-not $ws) { if ($Check) { Write-Output 'lghub_agent not reachable (is G HUB running?).' }; exit 0 }
try {
    $heads = Get-Headsets $ws
    if ($Check) {
        if (-not $heads -or $heads.Count -eq 0) { Write-Output 'No headset is currently connected.' }
        foreach ($h in $heads) {
            $t = Get-Timer $ws $h.id
            if ($null -eq $t) { Write-Output ("{0}: could not read timer." -f $h.displayName); continue }
            $status = if ($t.shutdown -eq 0 -and $t.shallow -eq 0 -and $t.deep -eq 0) { 'DISABLED (Never)' } else { 'ENABLED' }
            Write-Output ("{0} [{1}]: shutdown={2} shallow={3} deep={4}  ->  {5}" -f $h.displayName, $h.state, $t.shutdown, $t.shallow, $t.deep, $status)
        }
        exit 0
    }
    foreach ($h in $heads) {
        if ($h.state -ne 'ACTIVE') { continue }
        $t = Get-Timer $ws $h.id
        if ($null -eq $t) { continue }
        if ($t.shutdown -gt 0 -or $t.shallow -gt 0 -or $t.deep -gt 0) {
            if (Set-Disabled $ws $h.id) { Write-Log ("Re-disabled timer on {0} (was shutdown={1})" -f $h.displayName, $t.shutdown) }
        }
    }
} finally { try { $ws.Dispose() } catch {} }
exit 0

2. run-hidden.vbs — launches it with no window (edit the path to match yours):

CreateObject("WScript.Shell").Run _
  "powershell -NoProfile -ExecutionPolicy Bypass -File ""C:\Tools\G933KeepAwake\g933-keep-awake.ps1""", 0, True

3. Register the scheduled task (run once in PowerShell — at logon + every 3 minutes):

$me      = "$env:USERDOMAIN\$env:USERNAME"
$action  = New-ScheduledTaskAction -Execute 'wscript.exe' -Argument '"C:\Tools\G933KeepAwake\run-hidden.vbs"'
$logon   = New-ScheduledTaskTrigger -AtLogOn -User $me
$repeat  = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 3)
$set     = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew
$princ   = New-ScheduledTaskPrincipal -UserId $me -LogonType Interactive -RunLevel Limited
Register-ScheduledTask -TaskName 'G933 Keep Awake' -Action $action -Trigger @($logon,$repeat) -Settings $set -Principal $princ -Force

That's it. Check it with g933-keep-awake.ps1 -Check, and to remove it later: Unregister-ScheduledTask -TaskName 'G933 Keep Awake' -Confirm:$false.

Takeaway

A "feature" I couldn't switch off turned into a nice reminder: a lot of desktop apps expose a local control plane — here, a localhost WebSocket — that you can script against once you watch what the UI actually does. Twenty lines of PowerShell and a scheduled task later, my headset stays on, and I never have to open G HUB again.

If you've got a G935, G533, G633, or a PRO X Wireless, the same approach should work — the field names and the /battery/{id}/sleep_timer path are shared across the family; just confirm which of shutdown/shallow/deep your model honors by reading the value first.