Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ and the project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added
- **Preferences > Sound with the low battery alert** (off by default), for full-screen
games where the notification is not seen (#66). The low battery alert then also plays
Windows' own "Battery Low" sound ("Battery Critical" at 5% or less), and plays it again
every 5 minutes while the device stays low, awake and off the charger.

## [1.13.0] - 2026-09-29

A new Windows 11 style tray menu and a batch of tray features: turn device types off,
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ Right-click a device icon to open its menu. It looks like a Windows 11 menu (acr
- **Alert when fully charged** (a notification once per charge, on by default)
- **Estimated time left**: "about 5 h of use left" in the tooltip, from how fast the device has drained since its last charge. Only time the device is awake and on battery counts, and there is no estimate until it has been used for 30 minutes and dropped 3%. The history is kept in `%APPDATA%\HaloBattery\history.json`.
- **Quiet while gaming** (on by default): while a game or other app is full screen (the same signal Windows uses to hold its own notifications), alerts are held and shown once it closes - one per device, and a low battery alert is dropped if the device was put on the charger meanwhile. Devices are polled only every 5 minutes then, since every poll talks to them; plugging something in still updates at once.
- **Sound with the low battery alert** (off by default): also play the Windows "Battery Low" sound ("Battery Critical" at 5% or less), for full-screen games where the notification is not seen. It plays again every 5 minutes while the device stays low and off the charger, and **Quiet while gaming** does not hold it back
- **Windows Bluetooth devices**, **Device pictogram**, **Percentage in the icon** (the level as a number in the ring instead of the pictogram, amber or red when low), **Charging animation**
- **PlayStation full mode (Bluetooth)** (off by default): always read the battery of a PS4 / PS5 controller over Bluetooth. Some games stop seeing the controller in that mode until it is turned off and on
- **Status file for other apps** (off by default): writes `%APPDATA%\HaloBattery\status.json` after every poll, for Rainmeter, a Stream Deck plugin or a script. Each device has `name`, `level`, `charging`, `online`, `kind`, `seconds_left` and the tooltip `text`; `running` turns false when the app exits, and `updated_unix` says how fresh it is. Turning it off deletes the file.
Expand Down
64 changes: 64 additions & 0 deletions halo_battery.pyw
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ try:
import hid # noqa: E402
except ImportError:
hid = None
try:
import winsound # noqa: E402
except ImportError:
winsound = None
import pystray # noqa: E402
from pystray import Menu, MenuItem as Item # noqa: E402

Expand All @@ -89,6 +93,7 @@ DEFAULTS = {
"interval": 60, # seconds between polls
"low": 20, # low battery notification threshold, %
"full_alert": True, # notification when a charging device reaches 100 %
"low_sound": False, # also play a Windows sound with the low battery alert (#66)
"notify": True,
"bluetooth": True, # Windows Bluetooth devices
"badges": True, # device pictogram inside the ring
Expand Down Expand Up @@ -713,6 +718,43 @@ def device_state(st: DeviceStatus, left: str = "") -> str:
return state


# ------------------------------------------------------------- low battery sound
LOW_SOUND_REPEAT = 300 # seconds between low battery sounds while the device stays low
CRITICAL_LEVEL = 5 # at or below this %, the "critical" sound instead of the "low" one
# Windows' own sounds in %WINDIR%\Media, and the system sound used when the file is missing
LOW_SOUNDS = {"low": ("Windows Battery Low.wav", "SystemExclamation"),
"critical": ("Windows Battery Critical.wav", "SystemHand")}


def low_battery_sound(level: Optional[int], charging: bool, online: bool, low: int,
last: Optional[float], now: float) -> Optional[str]:
"""The sound to play now for a device, "low" or "critical", or None for no sound.

A device at or below the alert level `low`, awake and not charging, gets a sound
at once (`last` is None) and then again every LOW_SOUND_REPEAT seconds. `last` and
`now` are time.monotonic() values."""
if not low or level is None or not online or charging or level > low:
return None
if last is not None and now - last < LOW_SOUND_REPEAT:
return None
return "critical" if level <= CRITICAL_LEVEL else "low"


def play_low_sound(kind: str) -> None:
"""Play the sound in the background (SND_ASYNC), so the poll thread does not wait."""
if winsound is None:
return
name, alias = LOW_SOUNDS[kind]
path = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Media", name)
try:
if os.path.isfile(path):
winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC | winsound.SND_NODEFAULT)
else:
winsound.PlaySound(alias, winsound.SND_ALIAS | winsound.SND_ASYNC)
except Exception as e:
log.warning("low battery sound: %s", e)


# ------------------------------------------------------------- hide / rename
def ask_name(current: str) -> Optional[str]:
"""Show a Windows input box for a new device name.
Expand Down Expand Up @@ -868,6 +910,7 @@ class App:
self.stop_evt = threading.Event()
self.diag_requested = threading.Event()
self.alerted: Dict[str, bool] = {}
self.low_sound_at: Dict[str, float] = {} # key -> time.monotonic() of the last sound
self.full_state: Dict[str, str] = {} # key -> charging / full / idle
self.missing: Dict[str, int] = {}
self.bt_cache: List[DeviceStatus] = []
Expand Down Expand Up @@ -1014,6 +1057,9 @@ class App:
checked=lambda it: self.cfg.get("time_left", True)),
Item("Quiet while gaming", toggle("quiet_fullscreen"),
checked=lambda it: self.cfg.get("quiet_fullscreen", True)),
# for full-screen games, where the notification is not seen (#66)
Item("Sound with the low battery alert", toggle("low_sound"),
checked=lambda it: self.cfg.get("low_sound", False)),
Menu.SEPARATOR,
Item("Windows Bluetooth devices", toggle("bluetooth"),
checked=lambda it: self.cfg["bluetooth"]),
Expand Down Expand Up @@ -1083,6 +1129,7 @@ class App:
ic = self.icons.pop(st.key, None)
self.missing.pop(st.key, None)
self.alerted.pop(st.key, None)
self.low_sound_at.pop(st.key, None)
log.info("hidden: %s [%s]", self.display_name(st), st.key)
if ic is not None:
# stop the icon from another thread: this runs in the icon's own menu callback
Expand Down Expand Up @@ -1183,6 +1230,7 @@ class App:
lows[key] = value
save_config(self.cfg)
self.alerted.pop(key, None) # the new level may alert at once
self.low_sound_at.pop(key, None) # and sound at once
log.info("low battery alert of [%s]: %s", key, "default" if value is None else f"{value}%")
owner.update(owner.status) # the ring turns red at the new level
self.refresh_menus()
Expand Down Expand Up @@ -1214,6 +1262,7 @@ class App:
ic = self.icons.pop(key, None)
self.missing.pop(key, None)
self.alerted.pop(key, None)
self.low_sound_at.pop(key, None)
if ic is not None:
stopped.append(ic)
log.info("device type %s: %s", name, "off" if name in off else "on")
Expand Down Expand Up @@ -1530,6 +1579,7 @@ class App:
return
if st.charging or st.level > low + 5:
self.alerted[st.key] = False
self.low_sound_at.pop(st.key, None)
return
if st.level <= low and not self.alerted.get(st.key):
self.alerted[st.key] = True
Expand All @@ -1539,6 +1589,20 @@ class App:
"Low battery")
except Exception as e:
log.warning("notify: %s", e)
self.check_low_sound(st, low)

def check_low_sound(self, st: DeviceStatus, low: int) -> None:
""""Sound with the low battery alert": a Windows sound with the notification, and
again every 5 minutes while the device stays low, awake and off the charger."""
if not self.cfg.get("low_sound", False):
self.low_sound_at.pop(st.key, None)
return
now = time.monotonic()
kind = low_battery_sound(st.level, st.charging, st.online, low,
self.low_sound_at.get(st.key), now)
if kind:
self.low_sound_at[st.key] = now
play_low_sound(kind)

def check_full(self, ic: DeviceIcon, st: DeviceStatus):
"""A notification when a charging device reaches 100 %, once per charge.
Expand Down
2 changes: 2 additions & 0 deletions tests/test_hide_rename.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def make_app(cfg=None):
app.cfg = dict(hb.DEFAULTS, **(cfg or {}))
app.lock = threading.RLock()
app.icons, app.missing, app.alerted, app.full_state = {}, {}, {}, {}
app.low_sound_at = {}
app.placeholder = None
app.wake = threading.Event()
app.light_taskbar = False
Expand Down Expand Up @@ -274,6 +275,7 @@ def test_preferences_holds_the_settings(self):
texts = [i.text for i in prefs.items if i is not hb.Menu.SEPARATOR]
self.assertEqual(texts, ["Poll interval", "Low battery alert", "Alert when fully charged",
"Estimated time left", "Quiet while gaming",
"Sound with the low battery alert",
"Windows Bluetooth devices", "PlayStation full mode (Bluetooth)",
"Device types",
"Device pictogram", "Percentage in the icon", "Charging animation",
Expand Down
207 changes: 207 additions & 0 deletions tests/test_low_sound.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
"""Tests for the optional sound with the low battery alert (#66) in halo_battery.pyw.
No tray, no hardware, no sound: the app module is loaded with the fake icons of
test_hide_rename.py and winsound is replaced by a mock.

Run from the repository root:

python -m unittest discover -s tests
"""
import os
import sys
import unittest
from unittest import mock

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from test_hide_rename import HideRenameTestCase, hb, make_app # noqa: E402
from test_tray_features import KEY, mouse # noqa: E402

REPEAT = hb.LOW_SOUND_REPEAT


class LowSoundDecisionTests(unittest.TestCase):
"""low_battery_sound(): play now or not, and which sound. Threshold 20 %."""

def sound(self, level, charging=False, online=True, low=20, last=None, now=1000.0):
return hb.low_battery_sound(level, charging, online, low, last, now)

def test_repeat_is_five_minutes(self):
self.assertEqual(REPEAT, 300)

def test_first_alert_plays_the_low_sound(self):
self.assertEqual(self.sound(20), "low")
self.assertEqual(self.sound(12), "low")

def test_repeats_after_five_minutes(self):
self.assertEqual(self.sound(15, last=1000.0, now=1000.0 + REPEAT), "low")
self.assertEqual(self.sound(15, last=1000.0, now=1000.0 + REPEAT + 60), "low")

def test_not_before_five_minutes(self):
self.assertIsNone(self.sound(15, last=1000.0, now=1000.0))
self.assertIsNone(self.sound(15, last=1000.0, now=1000.0 + REPEAT - 1))

def test_critical_sound_at_5_percent_or_below(self):
self.assertEqual(self.sound(5), "critical")
self.assertEqual(self.sound(0), "critical")
self.assertEqual(self.sound(6), "low")
self.assertEqual(self.sound(3, last=1000.0, now=1000.0 + REPEAT), "critical")

def test_no_sound_while_charging(self):
self.assertIsNone(self.sound(10, charging=True))

def test_no_sound_above_the_threshold(self):
self.assertIsNone(self.sound(21))
self.assertIsNone(self.sound(80))

def test_no_sound_when_offline_or_level_unknown(self):
self.assertIsNone(self.sound(10, online=False))
self.assertIsNone(self.sound(None))

def test_no_sound_when_the_low_battery_alert_is_off(self):
self.assertIsNone(self.sound(3, low=0))
self.assertIsNone(self.sound(0, low=0))


class LowSoundWiringTests(HideRenameTestCase):
"""check_alert() plays the sound through winsound, only when the option is on."""

def setUp(self):
super().setUp()
self.ws = mock.Mock(SND_FILENAME=0x20000, SND_ASYNC=0x1, SND_NODEFAULT=0x2,
SND_ALIAS=0x10000)
self.now = 1000.0
self.wav = True # does the file in %WINDIR%\Media exist?
for p in [mock.patch.object(hb, "winsound", self.ws),
mock.patch.object(hb.time, "monotonic", lambda: self.now),
mock.patch.object(hb.os.path, "isfile", lambda path: self.wav)]:
p.start()
self.addCleanup(p.stop)

def app(self, **cfg):
return make_app(dict({"low": 20, "low_sound": True}, **cfg))

def played(self):
return [c.args for c in self.ws.PlaySound.call_args_list]

def test_off_by_default(self):
self.assertIs(hb.DEFAULTS["low_sound"], False)
app = make_app({"low": 20})
app.apply([mouse(15)])
self.assertEqual(len(app.notes), 1, "the notification is unchanged")
self.ws.PlaySound.assert_not_called()

def test_alert_plays_windows_battery_low_async(self):
app = self.app()
app.apply([mouse(15)])
self.assertEqual(len(app.notes), 1)
(path, flags), = self.played()
self.assertEqual(os.path.basename(path), "Windows Battery Low.wav")
self.assertEqual(os.path.basename(os.path.dirname(path)), "Media")
self.assertEqual(flags, self.ws.SND_FILENAME | self.ws.SND_ASYNC | self.ws.SND_NODEFAULT)

def test_critical_level_plays_windows_battery_critical(self):
app = self.app()
app.apply([mouse(4)])
(path, flags), = self.played()
self.assertEqual(os.path.basename(path), "Windows Battery Critical.wav")
self.assertTrue(flags & self.ws.SND_ASYNC)

def test_missing_file_falls_back_to_a_system_sound(self):
self.wav = False
app = self.app()
app.apply([mouse(15)])
app.apply([mouse(15, key="razer:00c8:2")])
self.now += REPEAT
app.apply([mouse(4)])
self.assertEqual(self.played(), [("SystemExclamation", self.ws.SND_ALIAS | self.ws.SND_ASYNC),
("SystemExclamation", self.ws.SND_ALIAS | self.ws.SND_ASYNC),
("SystemHand", self.ws.SND_ALIAS | self.ws.SND_ASYNC)])

def test_plays_while_quiet_while_gaming_holds_the_notification(self):
"""The sound is for full-screen games: "Quiet while gaming" holds the
notification, not the sound."""
app = self.app(quiet_fullscreen=True)
with mock.patch.object(hb, "fullscreen_app_running", lambda: True):
app.apply([mouse(15)])
self.assertEqual(len(app.held), 1, "the notification is held")
self.assertEqual(len(self.played()), 1, "the sound still plays")

def test_follows_the_alert_level_of_the_device(self):
app = self.app()
app.cfg["lows"] = {mouse(25).key: 30}
app.apply([mouse(25)])
self.assertEqual(len(self.played()), 1)

def test_repeats_every_five_minutes_while_low(self):
app = self.app()
app.apply([mouse(15)])
self.now += 60
app.apply([mouse(15)])
self.assertEqual(len(self.played()), 1, "not again after one minute")
self.now += REPEAT - 60
app.apply([mouse(14)])
self.assertEqual(len(self.played()), 2)
self.assertEqual(len(app.notes), 1, "the notification still comes once")

def test_stops_when_charging_and_starts_again_after(self):
app = self.app()
app.apply([mouse(15)])
self.now += 60
app.apply([mouse(15, True)])
self.now += REPEAT
app.apply([mouse(15, True)])
self.assertEqual(len(self.played()), 1, "no sound while charging")
app.apply([mouse(15)]) # off the charger, still low: a new alert
self.now += 60
app.apply([mouse(15, True)])
self.now += 1
app.apply([mouse(15)]) # a minute on the charger: the next alert has its sound too
self.assertEqual(len(self.played()), 3)
self.assertEqual(len(app.notes), 3)

def test_stops_above_the_threshold(self):
app = self.app()
app.apply([mouse(20)])
self.now += REPEAT
app.apply([mouse(22)])
self.assertEqual(len(self.played()), 1)

def test_stops_while_offline(self):
app = self.app()
app.apply([mouse(15)])
self.now += REPEAT
app.apply([mouse(15, online=False)])
self.assertEqual(len(self.played()), 1)

def test_stops_when_the_option_is_turned_off(self):
app = self.app()
app.apply([mouse(15)])
app.cfg["low_sound"] = False
self.now += REPEAT
app.apply([mouse(15)])
self.assertEqual(len(self.played()), 1)

def test_a_sound_error_does_not_break_the_poll(self):
self.ws.PlaySound.side_effect = RuntimeError("Failed to play sound")
app = self.app()
app.apply([mouse(15)])
self.assertIn(KEY, app.icons)
self.assertEqual(len(app.notes), 1)

def test_setting_is_in_preferences_and_toggles(self):
app = make_app()
prefs = next(i for i in app.build_menu(None).items if i.text == "Preferences").submenu
item = next(i for i in prefs.items if i.text == "Sound with the low battery alert")
self.assertFalse(item.checked)
item(None)
self.assertTrue(app.cfg["low_sound"])
self.assertTrue(self.saved[-1]["low_sound"])
self.assertTrue(item.checked)

def test_setting_is_read_from_the_settings_file(self):
self.assertTrue(hb._valid_setting("low_sound", True))
self.assertFalse(hb._valid_setting("low_sound", "yes"))


if __name__ == "__main__":
unittest.main()