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:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -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'
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
1. Download `LogitechBatteryWidget.exe` from a GitLab release or build artifact.
|
||||||
* [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:
|
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.
|
||||||
|
|
||||||
```
|
The executable is produced by PyInstaller. Some antivirus products heuristically flag unsigned
|
||||||
cd existing_repo
|
PyInstaller one-file programs. This can be a false positive; verify the checksum, inspect the CI
|
||||||
git remote add origin http://gitlab.nesterovic.cc/nessi/NexaLogi.git
|
job and source, or submit the file to VirusTotal. A detection should still be investigated rather
|
||||||
git branch -M main
|
than automatically ignored.
|
||||||
git push -uf origin main
|
|
||||||
|
## 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/)
|
```powershell
|
||||||
* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/)
|
python -m pip install -r requirements.txt
|
||||||
* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically)
|
pytest
|
||||||
* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/)
|
ruff check .
|
||||||
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
|
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/)
|
1. Check out the same commit on a clean Windows machine or isolated Windows runner.
|
||||||
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/)
|
2. Install Python 3.12 and the exact packages from `requirements.txt`.
|
||||||
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/)
|
3. Run the commands in **Build**.
|
||||||
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/)
|
4. Compare the SHA-256 result. Exact bytes also depend on the same Python patch version, Windows
|
||||||
* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
|
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
|
## Device compatibility
|
||||||
Choose a self-explaining name for your project.
|
|
||||||
|
|
||||||
## Description
|
Logitech exposes different protocols across receivers, Bluetooth devices, and product generations.
|
||||||
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.
|
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
|
||||||
## Badges
|
feature. Contributions with tested protocol captures are welcome; do not include serial numbers.
|
||||||
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.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
For open source projects, say how it is licensed.
|
|
||||||
|
|
||||||
## Project status
|
MIT. This project is independent and is not affiliated with or endorsed by Logitech. Logitech is a
|
||||||
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.
|
trademark of its respective owner.
|
||||||
|
|||||||
+32
@@ -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 |
@@ -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 |
@@ -0,0 +1,6 @@
|
|||||||
|
"""Absolute-import launcher used by PyInstaller."""
|
||||||
|
|
||||||
|
from logitech_battery_widget.main import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -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])])])
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Logitech Battery Widget."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user