Add project infrastructure and core application files

Add .gitignore, GitLab CI pipeline with test/lint/build/release stages, project documentation (README, LICENSE, CHANGELOG, CONTRIBUTING, SECURITY), PyInstaller packaging configuration with icon and version info, Python project configuration with dependencies, and complete application source including battery provider, device discovery, settings management, UI components, startup control, and comprehensive test suite
This commit is contained in:
2026-06-21 11:07:05 +02:00
parent 7ef92caef8
commit 454cad95c5
33 changed files with 1111 additions and 67 deletions
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
*.egg-info/
.venv/
.pytest_cache/
.ruff_cache/
build/
dist/
*.log
.idea/
.vscode/
+48
View File
@@ -0,0 +1,48 @@
stages:
- test
- lint
- build
- release
default:
tags:
- windows
before_script:
- py -3.12 -m pip install --disable-pip-version-check -r requirements.txt
test:
stage: test
script:
- py -3.12 -m pytest
lint:
stage: lint
script:
- py -3.12 -m ruff check .
build:
stage: build
needs: ["test", "lint"]
script:
- py -3.12 -m PyInstaller --clean --noconfirm packaging/pyinstaller.spec
- powershell -NoProfile -Command "$hash=(Get-FileHash dist/LogitechBatteryWidget.exe -Algorithm SHA256).Hash.ToLower(); \"$hash LogitechBatteryWidget.exe\" | Set-Content -Encoding ascii dist/LogitechBatteryWidget.exe.sha256"
artifacts:
name: "LogitechBatteryWidget-$CI_COMMIT_SHORT_SHA"
paths:
- dist/LogitechBatteryWidget.exe
- dist/LogitechBatteryWidget.exe.sha256
expire_in: 30 days
release:
stage: release
needs:
- job: build
artifacts: true
script:
- powershell -NoProfile -Command "Get-Content dist/LogitechBatteryWidget.exe.sha256"
artifacts:
paths:
- dist/LogitechBatteryWidget.exe
- dist/LogitechBatteryWidget.exe.sha256
rules:
- if: '$CI_COMMIT_TAG'
+14
View File
@@ -0,0 +1,14 @@
# Changelog
All notable changes follow Keep a Changelog. Versions use Semantic Versioning.
## [Unreleased]
## [0.1.0] - 2026-06-21
### Added
- Local Logitech HID/HID++ device and battery discovery.
- Compact multi-device widget, tray menu, themes, settings, and optional always-on-top mode.
- Explicit per-user Windows startup control, JSON settings, and rotating logs.
- Unit tests, PyInstaller packaging, and GitLab test/lint/build/release pipeline.
+12
View File
@@ -0,0 +1,12 @@
# Contributing
Contributions are welcome through GitLab merge requests.
1. Create a focused branch and explain the user-visible behavior.
2. Use Python 3.12+, type hints, `pathlib`, and existing provider/UI boundaries.
3. Add tests for behavior changes. Never require real registry writes or specific hardware in tests.
4. Run `pytest` and `ruff check .` before submitting.
5. Do not add network access, telemetry, runtime downloads, elevation, or opaque binary dependencies.
For new device protocols, document the tested product/connection type without including device
serial numbers or personal HID paths.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Logitech Battery Widget contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+75 -67
View File
@@ -1,93 +1,101 @@
# NexaLogi
# Logitech Battery Widget
An open-source Windows tray widget that displays battery information for connected Logitech mice,
keyboards, and headsets. It discovers devices locally through HID and attempts a read-only Logitech
HID++ battery query. Multiple devices are shown together. If Windows or a device does not expose a
battery value, the widget says so instead of guessing.
> **Screenshot placeholder:** A project screenshot will be added under `docs/screenshot.png` after
> the first signed release build is visually verified on Windows 11.
## Getting started
## Trust and privacy
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
- No network access, telemetry, analytics, updater, advertising, or runtime downloads.
- No administrator rights or UAC prompt.
- No hidden startup entry. "Start with Windows" changes only the current user's registry after an
explicit click, and can be disabled from the same menu.
- The complete source and reproducible GitLab CI recipe are public so the behavior can be audited.
- Settings and logs stay in the documented per-user Windows folders.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
See [SECURITY.md](SECURITY.md) for the full security model.
## Add your files
## End-user installation
* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
1. Download `LogitechBatteryWidget.exe` from a GitLab release or build artifact.
2. Optionally verify its SHA-256 value against `LogitechBatteryWidget.exe.sha256`.
3. Run the EXE. It is portable and does not install a service or driver.
4. Use the tray menu to refresh, hide the widget, change settings, or explicitly enable startup.
```
cd existing_repo
git remote add origin http://gitlab.nesterovic.cc/nessi/NexaLogi.git
git branch -M main
git push -uf origin main
The executable is produced by PyInstaller. Some antivirus products heuristically flag unsigned
PyInstaller one-file programs. This can be a false positive; verify the checksum, inspect the CI
job and source, or submit the file to VirusTotal. A detection should still be investigated rather
than automatically ignored.
## Local development
Requires Python 3.12+.
```powershell
py -3.12 -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install -r requirements.txt
python -m logitech_battery_widget.main
pytest
ruff check .
```
## Integrate with your tools
On Linux and macOS the UI starts with clearly named demo devices. This makes UI development
possible without pretending that real Logitech hardware was detected.
* [Set up project integrations](http://gitlab.nesterovic.cc/nessi/NexaLogi/-/settings/integrations)
## Build
## Collaborate with your team
On Windows:
* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/)
* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/)
* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically)
* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/)
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
```powershell
python -m pip install -r requirements.txt
pytest
ruff check .
pyinstaller --clean --noconfirm packaging/pyinstaller.spec
Get-FileHash dist/LogitechBatteryWidget.exe -Algorithm SHA256
```
## Test and Deploy
The result is `dist/LogitechBatteryWidget.exe`, a console-free, UPX-disabled one-file executable.
GitLab CI executes the same dependency installation, tests, lint, and PyInstaller command on a
Windows runner and publishes the EXE plus checksum. Builds fail when tests fail.
Use the built-in continuous integration in GitLab.
## Reproducible builds
* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/)
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/)
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/)
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/)
* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
1. Check out the same commit on a clean Windows machine or isolated Windows runner.
2. Install Python 3.12 and the exact packages from `requirements.txt`.
3. Run the commands in **Build**.
4. Compare the SHA-256 result. Exact bytes also depend on the same Python patch version, Windows
SDK/runtime, and PyInstaller version; the GitLab job log records that environment.
***
No file is downloaded by the application itself. Dependency retrieval happens only while preparing
the build environment.
# Editing this README
## Configuration
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
`%APPDATA%\LogitechBatteryWidget\config.json` contains:
## Suggestions for a good README
| Key | Type | Meaning |
| --- | --- | --- |
| `theme` | string | `system`, `light`, or `dark` |
| `always_on_top` | boolean | Keep the widget above normal windows |
| `window_x`, `window_y` | integer/null | Last widget position |
| `refresh_interval_seconds` | integer | Refresh period, clamped to 15-3600 |
| `log_level` | string | `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` |
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
Logs rotate at `%LOCALAPPDATA%\LogitechBatteryWidget\logs\app.log` (1 MB, three backups).
## Name
Choose a self-explaining name for your project.
## Device compatibility
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
Logitech exposes different protocols across receivers, Bluetooth devices, and product generations.
The widget supports HID++ battery features `0x1000` and `0x1004`. A detected device can therefore
appear with `N/A` when its interface, firmware, Bluetooth stack, or receiver does not expose either
feature. Contributions with tested protocol captures are welcome; do not include serial numbers.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
MIT. This project is independent and is not affiliated with or endorsed by Logitech. Logitech is a
trademark of its respective owner.
+32
View File
@@ -0,0 +1,32 @@
# Security
## Design guarantees
Logitech Battery Widget performs local, read-only HID device enumeration and HID++ feature queries.
It has no networking code, telemetry, analytics, updater, runtime downloads, obfuscation, anti-VM
logic, browser access, credential access, service installation, elevation request, or UAC manifest.
It does not require administrator privileges.
The application writes only:
- `%APPDATA%\LogitechBatteryWidget\config.json`
- `%LOCALAPPDATA%\LogitechBatteryWidget\logs\app.log` and three rotated backups
- `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\LogitechBatteryWidget`, but only after the
user checks **Start with Windows**. Unchecking it deletes that value.
Logs contain timestamps, severity, module names, and diagnostic messages. Device serial numbers,
paths, user names, configuration content, credentials, and HID report bytes are not logged.
PyInstaller may unpack its one-file bundle into its standard temporary directory while running.
The project creates no other temporary payloads. UPX is disabled.
## Reporting a vulnerability
Open a confidential GitLab issue with affected versions, reproduction steps, and impact. Do not
publish credentials, serial numbers, or personal paths. Maintainers should acknowledge reports
within seven days and coordinate a fix before public disclosure.
## Release verification
Release jobs publish a SHA-256 checksum beside the executable. The CI configuration and PyInstaller
spec are versioned, allowing anyone to rebuild from source and inspect every included dependency.
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<rect width="256" height="256" rx="52" fill="#17191f"/>
<rect x="42" y="69" width="156" height="118" rx="24" fill="#f5f7fa"/>
<rect x="198" y="105" width="20" height="46" rx="8" fill="#f5f7fa"/>
<rect x="58" y="85" width="105" height="86" rx="14" fill="#2cc970"/>
</svg>

After

Width:  |  Height:  |  Size: 367 B

+6
View File
@@ -0,0 +1,6 @@
"""Absolute-import launcher used by PyInstaller."""
from logitech_battery_widget.main import main
if __name__ == "__main__":
raise SystemExit(main())
+39
View File
@@ -0,0 +1,39 @@
# -*- mode: python ; coding: utf-8 -*-
from pathlib import Path
root = Path(SPECPATH).parent
a = Analysis(
[str(root / "packaging" / "launcher.py")],
pathex=[str(root / "src")],
binaries=[],
datas=[],
hiddenimports=[
"PyQt6.QtCore",
"PyQt6.QtGui",
"PyQt6.QtWidgets",
"hid",
"logitech_battery_widget.battery.windows_hid",
],
hookspath=[],
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=1,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="LogitechBatteryWidget",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
icon=str(root / "packaging" / "icon.ico"),
version=str(root / "packaging" / "version_info.txt"),
)
+12
View File
@@ -0,0 +1,12 @@
VSVersionInfo(
ffi=FixedFileInfo(filevers=(0, 1, 0, 0), prodvers=(0, 1, 0, 0),
mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0, date=(0, 0)),
kids=[StringFileInfo([StringTable('040904B0', [
StringStruct('CompanyName', 'Logitech Battery Widget contributors'),
StringStruct('FileDescription', 'Offline Logitech battery status widget'),
StringStruct('FileVersion', '0.1.0'),
StringStruct('InternalName', 'LogitechBatteryWidget'),
StringStruct('LegalCopyright', 'Copyright (c) 2026 contributors'),
StringStruct('OriginalFilename', 'LogitechBatteryWidget.exe'),
StringStruct('ProductName', 'Logitech Battery Widget'),
StringStruct('ProductVersion', '0.1.0')])]), VarFileInfo([VarStruct('Translation', [1033, 1200])])])
+32
View File
@@ -0,0 +1,32 @@
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[project]
name = "logitech-battery-widget"
version = "0.1.0"
description = "Transparent, offline Logitech battery widget for Windows"
readme = "README.md"
requires-python = ">=3.12"
license = {file = "LICENSE"}
dependencies = ["PyQt6==6.9.1", "hidapi==0.14.0.post4"]
[project.gui-scripts]
logitech-battery-widget = "logitech_battery_widget.main:main"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM"]
+5
View File
@@ -0,0 +1,5 @@
PyQt6==6.9.1
hidapi==0.14.0.post4
pyinstaller==6.14.1
pytest==8.4.1
ruff==0.12.0
+3
View File
@@ -0,0 +1,3 @@
"""Logitech Battery Widget."""
__version__ = "0.1.0"
+125
View File
@@ -0,0 +1,125 @@
"""Application controller."""
from __future__ import annotations
import logging
import sys
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
from . import __version__
from .battery.mock_provider import MockBatteryProvider
from .battery.provider import BatteryProvider
from .battery.windows_hid import WindowsHidBatteryProvider
from .config import ConfigStore
from .logging_config import configure_logging
from .ui.main_window import MainWindow, SettingsDialog
from .ui.theme import apply_theme
from .ui.tray import TrayIcon
from .windows import autostart
logger = logging.getLogger(__name__)
class WidgetApplication:
def __init__(self, qt_app: QApplication, provider: BatteryProvider | None = None) -> None:
self.qt_app = qt_app
self.qt_app.setQuitOnLastWindowClosed(False)
self.store = ConfigStore()
self.config = self.store.load()
configure_logging(self.config.log_level)
default_provider = (
WindowsHidBatteryProvider() if sys.platform == "win32" else MockBatteryProvider()
)
self.provider = provider or default_provider
self.window = MainWindow(self._window_hidden)
self._apply_preferences()
self.tray = TrayIcon(
self.toggle_window,
self.refresh,
self.set_autostart,
self.show_settings,
self.show_about,
self.qt_app.quit,
)
if sys.platform == "win32":
self.tray.autostart_action.blockSignals(True)
self.tray.autostart_action.setChecked(autostart.is_enabled())
self.tray.autostart_action.blockSignals(False)
else:
self.tray.autostart_action.setEnabled(False)
self.timer = QTimer()
self.timer.timeout.connect(self.refresh)
self.timer.start(self.config.refresh_interval_seconds * 1000)
self.qt_app.aboutToQuit.connect(self._save_position)
def run(self) -> int:
if QSystemTrayIcon.isSystemTrayAvailable():
self.tray.show()
self.window.show()
self.refresh()
return self.qt_app.exec()
def refresh(self) -> None:
try:
self.window.set_devices(self.provider.get_devices())
except Exception:
logger.exception("Battery refresh failed")
self.window.set_devices([])
QMessageBox.warning(
self.window,
"Refresh failed",
"Devices could not be refreshed. See the log for details.",
)
def toggle_window(self) -> None:
self.window.setVisible(not self.window.isVisible())
self.tray.set_widget_visible(self.window.isVisible())
def _window_hidden(self) -> None:
self._save_position()
self.tray.set_widget_visible(False)
def show_settings(self) -> None:
dialog = SettingsDialog(self.config, self.window)
if dialog.exec():
self.config.theme = dialog.theme.currentText()
self.config.always_on_top = dialog.always_on_top.isChecked()
self._apply_preferences()
self.store.save(self.config)
def _apply_preferences(self) -> None:
apply_theme(self.qt_app, self.config.theme)
self.window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, self.config.always_on_top)
if self.config.window_x is not None and self.config.window_y is not None:
self.window.move(self.config.window_x, self.config.window_y)
if self.window.isVisible():
self.window.show()
def _save_position(self) -> None:
self.config.window_x = self.window.x()
self.config.window_y = self.window.y()
try:
self.store.save(self.config)
except OSError:
logger.exception("Could not save settings")
def set_autostart(self, enabled: bool) -> None:
try:
autostart.set_enabled(enabled)
except OSError as error:
logger.exception("Could not update startup registration")
QMessageBox.warning(self.window, "Startup setting failed", str(error))
self.tray.autostart_action.blockSignals(True)
self.tray.autostart_action.setChecked(not enabled)
self.tray.autostart_action.blockSignals(False)
def show_about(self) -> None:
QMessageBox.about(
self.window,
"About Logitech Battery Widget",
f"Logitech Battery Widget {__version__}\n\n"
"Open-source, offline battery status display.\n"
"No telemetry, analytics, or network access.",
)
@@ -0,0 +1,5 @@
"""Battery provider implementations."""
from .provider import BatteryDevice, BatteryProvider, ChargeStatus
__all__ = ["BatteryDevice", "BatteryProvider", "ChargeStatus"]
@@ -0,0 +1,25 @@
"""Predictable provider for development and non-Windows platforms."""
from __future__ import annotations
from datetime import datetime
from .provider import BatteryDevice, ChargeStatus
class MockBatteryProvider:
def __init__(self, devices: list[BatteryDevice] | None = None) -> None:
self._devices = devices
def get_devices(self) -> list[BatteryDevice]:
if self._devices is not None:
return list(self._devices)
now = datetime.now().astimezone()
return [
BatteryDevice(
"mock-mouse", "Logitech MX Master (Demo)", 78, ChargeStatus.DISCHARGING, now
),
BatteryDevice(
"mock-keyboard", "Logitech MX Keys (Demo)", 54, ChargeStatus.UNKNOWN, now
),
]
@@ -0,0 +1,33 @@
"""Shared battery provider types."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Protocol
class ChargeStatus(str, Enum):
CHARGING = "Charging"
DISCHARGING = "Discharging"
UNKNOWN = "Unknown"
@dataclass(frozen=True, slots=True)
class BatteryDevice:
identifier: str
name: str
percentage: int | None
status: ChargeStatus = ChargeStatus.UNKNOWN
updated_at: datetime | None = None
message: str | None = None
def __post_init__(self) -> None:
if self.percentage is not None and not 0 <= self.percentage <= 100:
raise ValueError("percentage must be between 0 and 100")
class BatteryProvider(Protocol):
def get_devices(self) -> list[BatteryDevice]:
"""Return all currently detected supported devices."""
@@ -0,0 +1,111 @@
"""Local Logitech HID/HID++ discovery for Windows.
Only HID feature reports are exchanged with already connected devices. No
drivers, services, network requests, or vendor applications are required.
"""
from __future__ import annotations
import logging
import sys
from contextlib import suppress
from datetime import datetime
from typing import Any
from .provider import BatteryDevice, ChargeStatus
LOGITECH_VENDOR_ID = 0x046D
_BATTERY_FEATURES = (0x1004, 0x1000)
logger = logging.getLogger(__name__)
class WindowsHidBatteryProvider:
def get_devices(self) -> list[BatteryDevice]:
if sys.platform != "win32":
return []
try:
import hid
except ImportError:
logger.error("hidapi is not installed")
return []
devices: dict[str, BatteryDevice] = {}
for info in hid.enumerate(LOGITECH_VENDOR_ID, 0):
identifier = self._identifier(info)
if identifier in devices:
continue
name = self._name(info)
percentage, status = self._read_hidpp_battery(hid, info)
message = (
None if percentage is not None else "Battery information is not available via HID."
)
devices[identifier] = BatteryDevice(
identifier, name, percentage, status, datetime.now().astimezone(), message
)
return sorted(devices.values(), key=lambda device: device.name.lower())
@staticmethod
def _identifier(info: dict[str, Any]) -> str:
serial = info.get("serial_number")
if serial:
return str(serial)
return f"{info.get('product_id', 0):04x}:{info.get('interface_number', -1)}"
@staticmethod
def _name(info: dict[str, Any]) -> str:
product = str(info.get("product_string") or "Logitech HID Device")
return product if product.lower().startswith("logitech") else f"Logitech {product}"
def _read_hidpp_battery(
self, hid: Any, info: dict[str, Any]
) -> tuple[int | None, ChargeStatus]:
# HID++ long reports normally live on Logitech vendor usage page 0xFF00.
if info.get("usage_page") not in (0xFF00, 0x0001, None):
return None, ChargeStatus.UNKNOWN
device = hid.device()
try:
device.open_path(info["path"])
device.set_nonblocking(0)
for device_index in (0xFF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06):
for feature_id in _BATTERY_FEATURES:
feature_index = self._get_feature_index(device, device_index, feature_id)
if feature_index:
result = self._request(device, device_index, feature_index, 0x00)
if result and len(result) > 4 and 0 <= result[4] <= 100:
status = self._status(result[6] if len(result) > 6 else -1)
return int(result[4]), status
except (OSError, ValueError):
logger.debug("A Logitech HID interface did not expose battery data", exc_info=True)
finally:
with suppress(OSError):
device.close()
return None, ChargeStatus.UNKNOWN
def _get_feature_index(self, device: Any, device_index: int, feature_id: int) -> int | None:
response = self._request(
device, device_index, 0x00, 0x00, [(feature_id >> 8) & 0xFF, feature_id & 0xFF, 0x00]
)
return int(response[4]) if response and len(response) > 4 and response[4] else None
@staticmethod
def _request(
device: Any,
device_index: int,
feature_index: int,
function: int,
params: list[int] | None = None,
) -> list[int] | None:
request = [0x10, device_index, feature_index, function | 0x0B, *(params or [0, 0, 0])]
device.write(request[:7])
response = device.read(20, timeout_ms=120)
if not response or response[0] == 0x8F:
return None
return list(response)
@staticmethod
def _status(code: int) -> ChargeStatus:
if code == 0:
return ChargeStatus.DISCHARGING
if code in {1, 2, 3, 4}:
return ChargeStatus.CHARGING
return ChargeStatus.UNKNOWN
+61
View File
@@ -0,0 +1,61 @@
"""JSON-backed application settings."""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass, fields
from pathlib import Path
from typing import Any
APP_NAME = "LogitechBatteryWidget"
def config_path() -> Path:
base = Path(os.environ.get("APPDATA", Path.home() / ".config"))
return base / APP_NAME / "config.json"
@dataclass(slots=True)
class AppConfig:
theme: str = "system"
always_on_top: bool = False
window_x: int | None = None
window_y: int | None = None
refresh_interval_seconds: int = 60
log_level: str = "INFO"
def validate(self) -> None:
if self.theme not in {"system", "light", "dark"}:
self.theme = "system"
self.refresh_interval_seconds = max(15, min(self.refresh_interval_seconds, 3600))
if self.log_level.upper() not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
self.log_level = "INFO"
else:
self.log_level = self.log_level.upper()
class ConfigStore:
def __init__(self, path: Path | None = None) -> None:
self.path = path or config_path()
def load(self) -> AppConfig:
if not self.path.exists():
return AppConfig()
try:
raw: Any = json.loads(self.path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
return AppConfig()
allowed = {item.name for item in fields(AppConfig)}
config = AppConfig(**{key: value for key, value in raw.items() if key in allowed})
config.validate()
return config
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return AppConfig()
def save(self, config: AppConfig) -> None:
config.validate()
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix(".tmp")
temporary.write_text(json.dumps(asdict(config), indent=2) + "\n", encoding="utf-8")
temporary.replace(self.path)
@@ -0,0 +1,27 @@
"""Application logging without personal or device identifiers."""
from __future__ import annotations
import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
from .config import APP_NAME
def log_path() -> Path:
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / ".local" / "state"))
return base / APP_NAME / "logs" / "app.log"
def configure_logging(level: str = "INFO", path: Path | None = None) -> Path:
destination = path or log_path()
destination.parent.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(destination, maxBytes=1_000_000, backupCount=3, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(getattr(logging, level.upper(), logging.INFO))
return destination
+29
View File
@@ -0,0 +1,29 @@
"""Executable entry point."""
from __future__ import annotations
import sys
from PyQt6.QtWidgets import QApplication, QMessageBox
from .app import WidgetApplication
def main() -> int:
try:
application = QApplication(sys.argv)
application.setApplicationName("Logitech Battery Widget")
application.setOrganizationName("LogitechBatteryWidget")
return WidgetApplication(application).run()
except Exception as error:
if QApplication.instance() is not None:
QMessageBox.critical(
None,
"Logitech Battery Widget",
f"The application could not start:\n{error}",
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1 @@
"""PyQt6 user interface."""
@@ -0,0 +1,110 @@
"""Compact battery widget and settings dialog."""
from __future__ import annotations
from collections.abc import Callable
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QCloseEvent
from PyQt6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFrame,
QHBoxLayout,
QLabel,
QScrollArea,
QVBoxLayout,
QWidget,
)
from ..battery.provider import BatteryDevice
from ..config import AppConfig
class MainWindow(QWidget):
def __init__(self, on_hide: Callable[[], None]) -> None:
super().__init__()
self._on_hide = on_hide
self.setWindowTitle("Logitech Battery Widget")
self.setMinimumWidth(330)
self.resize(360, 260)
root = QVBoxLayout(self)
root.setContentsMargins(14, 14, 14, 14)
title = QLabel("LOGITECH BATTERIES")
title.setObjectName("muted")
root.addWidget(title)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
self._contents = QWidget()
self._devices = QVBoxLayout(self._contents)
self._devices.setAlignment(Qt.AlignmentFlag.AlignTop)
scroll.setWidget(self._contents)
root.addWidget(scroll)
def set_devices(self, devices: list[BatteryDevice]) -> None:
while self._devices.count():
item = self._devices.takeAt(0)
if item.widget():
item.widget().deleteLater()
if not devices:
label = QLabel(
"No Logitech HID devices were found.\nConnect a device and choose Refresh."
)
label.setObjectName("muted")
label.setWordWrap(True)
self._devices.addWidget(label)
return
for device in devices:
self._devices.addWidget(DeviceCard(device))
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
event.ignore()
self.hide()
self._on_hide()
class DeviceCard(QFrame):
def __init__(self, device: BatteryDevice) -> None:
super().__init__()
self.setObjectName("deviceCard")
layout = QVBoxLayout(self)
header = QHBoxLayout()
name = QLabel(device.name)
name.setObjectName("deviceName")
percentage = QLabel(f"{device.percentage}%" if device.percentage is not None else "N/A")
percentage.setObjectName("percentage")
header.addWidget(name, 1)
header.addWidget(percentage)
layout.addLayout(header)
status = QLabel(f"Status: {device.status.value}")
status.setObjectName("muted")
layout.addWidget(status)
updated = device.updated_at.strftime("%Y-%m-%d %H:%M:%S") if device.updated_at else "Never"
details = QLabel(device.message or f"Last updated: {updated}")
details.setObjectName("muted")
details.setWordWrap(True)
layout.addWidget(details)
class SettingsDialog(QDialog):
def __init__(self, config: AppConfig, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("Settings")
layout = QVBoxLayout(self)
layout.addWidget(QLabel("Theme"))
self.theme = QComboBox()
self.theme.addItems(["system", "light", "dark"])
self.theme.setCurrentText(config.theme)
layout.addWidget(self.theme)
self.always_on_top = QCheckBox("Keep widget always on top")
self.always_on_top.setChecked(config.always_on_top)
layout.addWidget(self.always_on_top)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
+38
View File
@@ -0,0 +1,38 @@
"""Application color themes."""
from __future__ import annotations
from PyQt6.QtGui import QPalette
from PyQt6.QtWidgets import QApplication
def system_is_dark(app: QApplication) -> bool:
return app.palette().color(QPalette.ColorRole.Window).lightness() < 128
def apply_theme(app: QApplication, theme: str) -> bool:
dark = system_is_dark(app) if theme == "system" else theme == "dark"
colors = {
"bg": "#17191f" if dark else "#f3f5f7",
"card": "#23262e" if dark else "#ffffff",
"text": "#f5f7fa" if dark else "#18202a",
"muted": "#aab2c0" if dark else "#657080",
"accent": "#2cc970" if dark else "#129650",
"border": "#343944" if dark else "#dce1e7",
}
app.setStyleSheet(
f"""
QWidget {{ background: {colors["bg"]}; color: {colors["text"]}; font-family: 'Segoe UI'; }}
QFrame#deviceCard {{ background: {colors["card"]}; border: 1px solid {colors["border"]};
border-radius: 10px; }}
QLabel#deviceName {{ font-size: 14px; font-weight: 600; }}
QLabel#percentage {{ color: {colors["accent"]}; font-size: 24px; font-weight: 700; }}
QLabel#muted {{ color: {colors["muted"]}; font-size: 11px; }}
QPushButton, QComboBox {{ background: {colors["card"]};
border: 1px solid {colors["border"]}; border-radius: 6px;
padding: 6px 10px; }}
QMenu {{ background: {colors["card"]}; border: 1px solid {colors["border"]}; }}
QMenu::item:selected {{ background: {colors["accent"]}; color: white; }}
"""
)
return dark
+57
View File
@@ -0,0 +1,57 @@
"""System tray icon and menu."""
from __future__ import annotations
from collections.abc import Callable
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QAction, QColor, QIcon, QPainter, QPixmap
from PyQt6.QtWidgets import QMenu, QSystemTrayIcon
def battery_icon(dark: bool = True) -> QIcon:
pixmap = QPixmap(64, 64)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#f5f7fa" if dark else "#18202a"))
painter.drawRoundedRect(10, 17, 40, 30, 5, 5)
painter.drawRoundedRect(50, 26, 5, 12, 2, 2)
painter.setBrush(QColor("#2cc970"))
painter.drawRoundedRect(15, 22, 27, 20, 3, 3)
painter.end()
return QIcon(pixmap)
class TrayIcon(QSystemTrayIcon):
def __init__(
self,
toggle: Callable[[], None],
refresh: Callable[[], None],
autostart: Callable[[bool], None],
settings: Callable[[], None],
about: Callable[[], None],
exit_app: Callable[[], None],
) -> None:
super().__init__(battery_icon())
self.setToolTip("Logitech Battery Widget")
menu = QMenu()
self.toggle_action = menu.addAction("Hide Widget")
self.toggle_action.triggered.connect(toggle)
menu.addAction("Refresh", refresh)
menu.addSeparator()
self.autostart_action = QAction("Start with Windows", menu, checkable=True)
self.autostart_action.toggled.connect(autostart)
menu.addAction(self.autostart_action)
menu.addAction("Settings", settings)
menu.addAction("About", about)
menu.addSeparator()
menu.addAction("Exit", exit_app)
self.setContextMenu(menu)
self.activated.connect(
lambda reason: toggle() if reason == self.ActivationReason.DoubleClick else None
)
def set_widget_visible(self, visible: bool) -> None:
self.toggle_action.setText("Hide Widget" if visible else "Show Widget")
@@ -0,0 +1 @@
"""Windows-specific integrations."""
@@ -0,0 +1,52 @@
"""Explicit per-user Windows startup registration.
The module only changes HKCU when set_enabled() is called from the user's tray
menu action. Merely starting the application never creates a registry value.
"""
from __future__ import annotations
import sys
from pathlib import Path
from types import ModuleType
RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
VALUE_NAME = "LogitechBatteryWidget"
def _winreg() -> ModuleType:
if sys.platform != "win32":
raise OSError("Start with Windows is available on Windows only.")
import winreg
return winreg
def launch_command() -> str:
if getattr(sys, "frozen", False):
executable = Path(sys.executable)
return f'"{executable}"'
return f'"{Path(sys.executable)}" -m logitech_battery_widget.main'
def is_enabled(registry: ModuleType | None = None) -> bool:
reg = registry or _winreg()
try:
with reg.OpenKey(reg.HKEY_CURRENT_USER, RUN_KEY, 0, reg.KEY_READ) as key:
value, _ = reg.QueryValueEx(key, VALUE_NAME)
return value == launch_command()
except OSError:
return False
def set_enabled(enabled: bool, registry: ModuleType | None = None) -> None:
reg = registry or _winreg()
if enabled:
with reg.CreateKeyEx(reg.HKEY_CURRENT_USER, RUN_KEY, 0, reg.KEY_SET_VALUE) as key:
reg.SetValueEx(key, VALUE_NAME, 0, reg.REG_SZ, launch_command())
return
try:
with reg.OpenKey(reg.HKEY_CURRENT_USER, RUN_KEY, 0, reg.KEY_SET_VALUE) as key:
reg.DeleteValue(key, VALUE_NAME)
except FileNotFoundError:
pass
+46
View File
@@ -0,0 +1,46 @@
from contextlib import nullcontext
from unittest.mock import MagicMock
from logitech_battery_widget.windows import autostart
def registry_mock():
registry = MagicMock()
registry.HKEY_CURRENT_USER = object()
registry.KEY_READ = 1
registry.KEY_SET_VALUE = 2
registry.REG_SZ = 1
key = MagicMock()
registry.OpenKey.return_value = nullcontext(key)
registry.CreateKeyEx.return_value = nullcontext(key)
return registry, key
def test_enable_writes_current_user_run_value():
registry, key = registry_mock()
autostart.set_enabled(True, registry)
registry.SetValueEx.assert_called_once_with(
key, autostart.VALUE_NAME, 0, registry.REG_SZ, autostart.launch_command()
)
def test_disable_removes_value_without_real_registry():
registry, key = registry_mock()
autostart.set_enabled(False, registry)
registry.DeleteValue.assert_called_once_with(key, autostart.VALUE_NAME)
def test_is_enabled_compares_exact_command():
registry, _ = registry_mock()
registry.QueryValueEx.return_value = (autostart.launch_command(), registry.REG_SZ)
assert autostart.is_enabled(registry)
def test_missing_value_is_disabled():
registry, _ = registry_mock()
registry.QueryValueEx.side_effect = FileNotFoundError
assert not autostart.is_enabled(registry)
+30
View File
@@ -0,0 +1,30 @@
import json
from logitech_battery_widget.config import AppConfig, ConfigStore
def test_config_round_trip(tmp_path):
path = tmp_path / "config.json"
store = ConfigStore(path)
expected = AppConfig(theme="dark", always_on_top=True, window_x=12, window_y=34)
store.save(expected)
assert store.load() == expected
assert json.loads(path.read_text(encoding="utf-8"))["theme"] == "dark"
def test_invalid_config_uses_safe_values(tmp_path):
path = tmp_path / "config.json"
path.write_text('{"theme": "purple", "refresh_interval_seconds": 1}', encoding="utf-8")
loaded = ConfigStore(path).load()
assert loaded.theme == "system"
assert loaded.refresh_interval_seconds == 15
def test_broken_json_returns_defaults(tmp_path):
path = tmp_path / "config.json"
path.write_text("not json", encoding="utf-8")
assert ConfigStore(path).load() == AppConfig()
+25
View File
@@ -0,0 +1,25 @@
from datetime import datetime
import pytest
from logitech_battery_widget.battery.mock_provider import MockBatteryProvider
from logitech_battery_widget.battery.provider import BatteryDevice, ChargeStatus
def test_mock_provider_returns_multiple_independent_devices():
first = MockBatteryProvider().get_devices()
second = MockBatteryProvider().get_devices()
assert len(first) == 2
assert all(device.updated_at is not None for device in first)
assert first is not second
def test_custom_mock_devices_are_preserved():
device = BatteryDevice("id", "Test", 42, ChargeStatus.CHARGING, datetime.now())
assert MockBatteryProvider([device]).get_devices() == [device]
def test_invalid_percentage_is_rejected():
with pytest.raises(ValueError):
BatteryDevice("id", "Test", 101)
+19
View File
@@ -0,0 +1,19 @@
from logitech_battery_widget.battery.provider import ChargeStatus
from logitech_battery_widget.battery.windows_hid import WindowsHidBatteryProvider
def test_hidpp_charge_status_mapping():
provider = WindowsHidBatteryProvider()
assert provider._status(0) == ChargeStatus.DISCHARGING
assert provider._status(1) == ChargeStatus.CHARGING
assert provider._status(4) == ChargeStatus.CHARGING
assert provider._status(99) == ChargeStatus.UNKNOWN
def test_device_name_is_clear_and_vendor_prefixed():
provider = WindowsHidBatteryProvider()
assert provider._name({"product_string": "MX Keys"}) == "Logitech MX Keys"
assert provider._name({"product_string": "Logitech G Pro"}) == "Logitech G Pro"
assert provider._name({}) == "Logitech HID Device"