Windows system-tray app that shows battery levels for paired Bluetooth devices and vendor-specific HID devices (Razer mice, Sony DualSense).
- Floats a transparent panel on your desktop showing the battery level of every connected Bluetooth device
- Sends a Windows notification when a device drops below its configured threshold
- Supports Razer wireless mice (via USB dongle) and Sony DualSense controllers, whose battery level Windows does not expose through the standard Bluetooth API
- Shows charging state (charging / discharging / idle) for each device
- Estimates time remaining to empty, to the low-battery threshold, or to full charge, based on the average rate observed since the device last changed direction
- Lives entirely in the system tray — no taskbar presence
- Windows 10 or 11
- Bluetooth adapter (for Bluetooth devices)
- No runtime dependencies —
devicehelper.exeis a self-contained .NET 10 binary
- Download the NSIS installer from the Releases page and run it.
- The app starts automatically and places an icon in the system tray.
- The app starts in the system tray. Left-click the tray icon to show/hide the panel; right-click for Settings, About, and Quit. The hotkey Alt+B also toggles the panel; Alt+Shift+B toggles between list and compact view.
The floating panel appears in a corner of the screen and lists every device that is currently connected and configured to be shown. Toggle it with a left-click on the tray icon or with Alt+B. Two display modes are available, switchable via Alt+Shift+B or the "Compact panel view" checkbox in Settings:
- List view (default) — device name + battery percentage with a coloured battery icon
- Compact view — a horizontal strip of circular battery rings (configurable size)
The panel snaps to a screen corner. You can drag the corner and offset positions in Settings.
Once a device has been discharging (or charging) for at least a few minutes with an observed change in level, the panel shows an estimate on its own line below the device row: time to reach the low-battery threshold and time to reach 0% while discharging, or time to reach 100% while charging. The estimate is the average rate over the current discharge/charge streak (not a single instantaneous sample), and it counts down continuously between actual battery readings — in both the floating panel and the Settings devices table. Starting to charge discards any accumulated discharge statistics, and vice versa. Toggle this off in Settings if you'd rather not see it in the full (non-compact) panel; in compact ring mode the estimate is always available via hover tooltip regardless of this setting, but is never shown inside the ring itself.
Each device also remembers a long-term average rate from past discharge/charge streaks. Right after a streak resets — e.g. the device just started discharging again — the app shows this remembered average immediately, marked with a ~ prefix (e.g. ~2h 15m to empty), instead of leaving the estimate blank for the first few minutes. Once the current streak itself has enough data, the ~ disappears and the estimate switches to the live figure.
| Setting | Default | Description |
|---|---|---|
| Refresh interval | 60 s | How often battery levels are polled |
| Dynamic indicator color | Off | Interpolates battery color smoothly from red (≤15%) through orange to green (100%); when off, fixed thresholds apply |
| Show estimated time remaining | On | Shows time-to-empty / time-to-low / time-to-full text under each device row, once enough discharge/charge data has been observed. Only affects the full (non-compact) panel — compact ring mode always exposes the estimate via hover tooltip instead |
| Low-battery threshold | 20 % | Battery icon turns red at or below this level; also the default warning threshold for newly discovered devices (fixed-threshold mode only) |
| Warn color threshold | 40 % | Battery icon turns orange between the low and warn thresholds (fixed-threshold mode only) |
| Panel background opacity | 85 % | Transparency of the floating panel |
| Panel corner | Bottom-right | Which screen corner the panel snaps to |
| Horizontal offset | 8 px | Distance from the left/right screen edge |
| Vertical offset | 8 px | Distance from the taskbar/top edge |
| Compact panel view | Off | Switch to circular ring display mode |
| Compact ring size | 48 px | Diameter of each ring in compact mode (24–96 px) |
| Start automatically | On | Launch the app when you sign in to Windows |
Each paired device appears in a table. Per-device options:
| Column | Description |
|---|---|
| Monitor | Show/hide on the panel |
| State | Online / offline indicator |
| Type | Device icon (keyboard, mouse, headphones, controller) — click to cycle |
| Device / alias | Click to rename; the system name is shown in a tooltip |
| Battery | Last known level |
| Low warning | Enable/disable and set the threshold for this device |
Drag rows to reorder. Click Refresh now to force an immediate poll.
| Category | How it works |
|---|---|
| Standard Bluetooth (keyboards, headphones, mice, …) | WinRT DeviceWatcher for presence; battery via CM_Get_DevNode_Property (DEVPKEY_Bluetooth_Battery) |
| Razer wireless mice (USB dongle) | HID feature report (class 0x07, cmd 0x80=battery); may not work when Razer Synapse is running |
| Sony DualSense (USB or Bluetooth) | HID input report 0x01 (USB, byte 53) or 0x31 (BT, byte 54) |
Devices whose battery Windows cannot read are still listed in the Devices tab (so you can hide them) but are hidden from the panel by default.
| Layer | Technology |
|---|---|
| Framework | Electron 42 + electron-vite |
| UI | React 18 + TypeScript |
| Build | Vite 7 |
| Packaging | electron-builder (NSIS installer) |
| Persistence | electron-store |
| Auto-update | electron-updater (GitHub Releases) |
| Device probe | devicehelper.exe — C# .NET 10, single-file self-contained, WinRT + Win32 P/Invoke |
npm run dev # build:helper + hot-reload dev mode
npm run build # build:helper + compile TypeScript → out/
npm run build:helper # dotnet publish → src/main/scripts/devicehelper.exe (requires .NET 10 SDK)
npm run dist # build + package NSIS installer → dist/
npm run dist:dir # build + package unpacked directory (faster, no installer)To publish a new release to GitHub (requires GH_TOKEN with repo scope):
npm run dist -- --publish alwaysThis builds the NSIS installer, generates latest.yml (used by electron-updater to detect new versions), and uploads both to the GitHub Releases page for the configured tag.
TypeScript is the primary correctness check — there is no test suite. A type error will fail the build.
src/
main/ Node.js (main process)
index.ts Entry point, IPC handlers, app lifecycle
devicehelper.ts Manages devicehelper.exe child process; parses JSON push events
poller.ts Event-driven update loop, low-battery notifications
store.ts electron-store wrapper — DeviceRecord registry + AppSettings
estimate.ts Average discharge/charge rate (%/ms) over the current streak, plus a persisted long-term average used as a fallback
windows.ts Three BrowserWindows: panel, settings, about
tray.ts Tray icon + context menu
icons.ts Tray icon generation
devicehelper/ C# .NET 10 project (built to scripts/)
devicehelper.csproj
Program.cs BluetoothProvider + RazerProvider + DualSenseProvider + DeviceManager
scripts/
devicehelper.exe self-contained binary (gitignored, built by build:helper)
preload/
index.ts contextBridge — exposes window.api to the renderer
index.d.ts Type declaration for window.api
renderer/
src/
App.tsx Hash-based router (#/panel, #/settings, #/about)
pages/
Panel.tsx Floating battery panel
Settings.tsx Settings window (General + Devices tabs)
About.tsx About window
components/
BatteryIcon.tsx SVG battery icon with charging/warn states
CompactDeviceCircle.tsx Circular ring for compact mode
DeviceRow.tsx Row component for Devices tab
DeviceTypeIcon.tsx Device type SVG icons
hooks/
useDevices.ts Subscribes to devices:update IPC push events
useSettings.ts Subscribes to settings:update IPC push events
utils/
battery.ts levelColor()/isWarning() for color; estimateRemaining()/estimateLabel()/formatDuration() for the time-remaining countdown
shared/
types.ts Shared type contracts (ProbeResult, DeviceRecord, DeviceView, AppSettings, …)
ipc.ts IPC channel name constants (IPC object)
resources/
icon.ico / icon.png App icon (installer and window)
trayTemplate.png Legacy tray image (unused — icon is generated at runtime by icons.ts)
electron-builder.yml Packaging config
electron.vite.config.ts Vite config
The app has three Electron processes with a strict one-way data flow:
devicehelper.exe ──stdout JSON──► devicehelper.ts ──► poller.ts (warn) ──► store (DeviceRecord[]) ──► broadcast ──► renderer
BluetoothProvider (WinRT DeviceWatcher + CM P/Invoke)
RazerProvider (HID watcher + feature reports)
DualSenseProvider (HID watcher + input reports)
devicehelper.exe is a persistent C# helper process. It emits JSON lines on device connect/disconnect/battery events. devicehelper.ts manages its lifecycle (spawn, line-buffer, restart on crash). poller.ts calls runOnce on each push event — no interval timer.
Main process (src/main/) owns all system access — filesystem, IPC, notifications, windows, tray. It also registers the global hotkeys Alt+B (toggle the panel) and Alt+Shift+B (toggle compact/full panel mode).
Preload (src/preload/index.ts) bridges IPC to window.api via contextBridge. The exported Api type is the authoritative contract. IPC channel names are centralised in src/shared/ipc.ts as the IPC constants object — prefer it over raw string literals.
Renderer (src/renderer/) is a React SPA. All three windows share one bundle; the URL hash (#/panel, #/settings, #/about) selects the active view in App.tsx.
| Channel | Direction | Purpose |
|---|---|---|
devices:get |
invoke | Fetch current device list |
devices:setConfig |
invoke | Patch alias / visibility / warn settings for one device |
devices:refresh |
invoke | Force an immediate poll |
devices:reorder |
invoke | Persist drag-and-drop sort order |
devices:update |
push (main → renderer) | Broadcast after each poll |
settings:get |
invoke | Read AppSettings |
settings:set |
invoke | Write partial AppSettings |
settings:update |
push (main → renderer) | Broadcast after settings change |
app:info |
invoke | App name / version / author |
panel:resize |
send (renderer → main) | Renderer reports content size so main resizes the frameless panel |
window:action |
send (renderer → main) | closePanel, openSettings, openAbout, quit |
update:status |
push (main → renderer) | Broadcast UpdateStatus on each updater state change |
update:check |
invoke | Trigger an immediate update check |
update:install |
invoke | Quit and install a downloaded update |
| Type | Description |
|---|---|
ProbeResult |
Raw row from a probe (bluetooth or vendor HID) |
DeviceRecord |
Persisted device entry (registry + battery history + discharge/charge streak anchors + long-term avgDischargeRatePctPerMs/avgChargeRatePctPerMs) |
DeviceView |
DeviceRecord + displayName + estimatedDischargeRatePctPerMs/estimatedChargeRatePctPerMs/estimateFromHistory; what the renderer receives |
AppSettings |
All persisted settings (includes lowColorThreshold, warnColorThreshold, dynamicColorMode for indicator color) |
DeviceConfigPatch |
Partial patch applied by devices:setConfig |
ChargingState |
'charging' | 'discharging' | 'idle' | 'unknown' |
DeviceType |
'keyboard' | 'mouse' | 'headphones' | 'controller' | null |
PanelCorner |
'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' |
UpdateState |
'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'error' | 'up-to-date' |
UpdateStatus |
{ state: UpdateState; version?: string; progress?: number; error?: string } |
Each runOnce call (triggered by a push event from devicehelper.exe):
- Reads the latest
ProbeResult[]fromgetLastDevices()(cached in memory bydevicehelper.ts). - Derives charging state for standard BT devices by comparing the new level to the previous one (up → charging, down → discharging, same → idle). DualSense and Razer report charging state directly.
- Resets the discharge or charge streak anchor (
dischargeStart{Battery,Time}/chargeStart{Battery,Time}onDeviceRecord, persisted across restarts) when reconnecting after being offline for more than 10 minutes, or when there's no carried-forward anchor for the new direction yet; otherwise carries the existing anchor forward — so idle polls (a reading with no % change), brief BT dropouts, and app restarts (where a still-connected device may miss the first probe cycle whiledevicehelper.exere-enumerates) don't reset the average.estimate.ts'scomputeEstimateRates()turns an anchor into a %/ms rate once enough streak time/delta has accumulated, and each validated rate is blended (viaupdateAverageRate()) into the persistedavgDischargeRatePctPerMs/avgChargeRatePctPerMs.computeDisplayRates()prefers the live rate but falls back to that persisted average right after a reset, so the panel doesn't go blank while the new streak warms up; the renderer projects the resulting rate forward against its own clock (estimateRemaining()inutils/battery.ts) so the displayed countdown ticks down between actual readings. - Updates the
electron-storeregistry. - Fires a Windows notification for any device below its threshold (suppressed until the device recovers via a
warnedSet). - Broadcasts the updated
DeviceView[]to all open windows.
devicehelper.ts spawns devicehelper.exe once at startup. It line-buffers stdout, parses JSON ({"type":"snapshot"|"update","devices":[...]}) and calls onUpdate. On crash it auto-restarts (max 5 attempts, 2 s delay). Helper path: resources/scripts/devicehelper.exe (packaged) → src/main/scripts/devicehelper.exe (dev).
C# device providers (in devicehelper/Program.cs):
-
BluetoothProvider: Three WinRT
DeviceWatchers — AEP BT Classic and AEP BT LE (both requestSystem.Devices.Aep.IsConnected;Updatedfires on connect/disconnect); PnP BTH-node watcher (nullproperties, used only for battery reads). Battery read viaCM_Locate_DevNodeW+CM_Get_DevNode_PropertyW(DEVPKEY_Bluetooth_Battery = {104EA319-6EE2-4701-BD47-8DDBF425BBE5}pid=2). 60-second background poll. -
RazerProvider (VID
0x1532): Generic HID class GUID watcher; testsHidD_SetFeature/HidD_GetFeatureon each found interface; Razer protocol class=0x07, cmd0x80=battery (0–255 scale),0x84=charging. -
DualSenseProvider (VID
0x054C, PIDs0x0CE6/0x0DF2):HidDevice.GetDeviceSelector(0x0001, 0x0005)selector; per-device read thread. USB and Bluetooth appearances collapse to one record by reading the controller's MAC from feature report0x09(bytes 1–6 reversed).
Three BrowserWindow instances: floating panel (frameless, transparent, always-on-top), settings, about. broadcast() sends a given IPC event to all three simultaneously.
The panel is resizable: false to prevent user resizing, but programmatic resize is needed to fit content. To work around the Windows restriction that blocks setBounds on non-resizable windows, windows.ts temporarily toggles setResizable(true) around every setBounds call.
electron-store holds two keys: settings (an AppSettings object) and devices (a Record<string, DeviceRecord>). getSettings() always spreads defaultSettings before the stored value, so new settings fields with defaults are safe to read without a migration step.
Four files need to change:
src/shared/types.ts— add the field toAppSettingssrc/main/store.ts— add a default indefaultSettings; optionally clamp/validate insetSettingssrc/renderer/src/pages/Settings.tsx— add the UI control, callingpatchSettings({ field: value })- Consumer (e.g.
Panel.tsx) — read viagetSettings()/onSettingsUpdateand apply
- Windows-only.
devicehelper.exeuses WinRTDeviceWatcher,CM_Get_DevNode_Property(cfgmgr32), and Win32 HID P/Invoke. No cross-platform fallback. - devicehelper.exe ships in
extraResources. Packaged toresources/scripts/devicehelper.exeby electron-builder;devicehelper.tschecks both packaged and dev paths. - Single-instance lock. A second launch re-focuses the settings window instead of spawning a new process.
- No taskbar button.
app.setAppUserModelIdis called butapp.dock/ taskbar presence is suppressed; the app is tray-only.
npm run distOutputs an NSIS installer to dist/. The installer is per-user (no elevation required) and creates Start Menu and Desktop shortcuts. To produce an unpacked directory instead (faster, no installer wizard):
npm run dist:dir