thermalcanary 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. thermalcanary-1.1.0/.claude/settings.local.json +5 -0
  2. thermalcanary-1.1.0/.githooks/pre-commit +75 -0
  3. thermalcanary-1.1.0/.github/workflows/ci.yml +112 -0
  4. thermalcanary-1.1.0/.gitignore +18 -0
  5. thermalcanary-1.1.0/LICENSE +21 -0
  6. thermalcanary-1.1.0/PKG-INFO +15 -0
  7. thermalcanary-1.1.0/README.md +181 -0
  8. thermalcanary-1.1.0/assets/icon.png +0 -0
  9. thermalcanary-1.1.0/assets/logo.png +0 -0
  10. thermalcanary-1.1.0/bin/build_logo.py +62 -0
  11. thermalcanary-1.1.0/bin/fonts/Exo2-SemiBold.ttf +0 -0
  12. thermalcanary-1.1.0/config.example.yaml +23 -0
  13. thermalcanary-1.1.0/coverage-badge.json +1 -0
  14. thermalcanary-1.1.0/install.sh +280 -0
  15. thermalcanary-1.1.0/mutation-badge.json +1 -0
  16. thermalcanary-1.1.0/packaging/aur/.SRCINFO +26 -0
  17. thermalcanary-1.1.0/packaging/aur/PKGBUILD +75 -0
  18. thermalcanary-1.1.0/packaging/flatpak/io.github.ibasaw.ThermalCanary.desktop +12 -0
  19. thermalcanary-1.1.0/packaging/flatpak/io.github.ibasaw.ThermalCanary.metainfo.xml +89 -0
  20. thermalcanary-1.1.0/packaging/flatpak/io.github.ibasaw.ThermalCanary.yml +84 -0
  21. thermalcanary-1.1.0/pyproject.toml +53 -0
  22. thermalcanary-1.1.0/screenshot-settings.png +0 -0
  23. thermalcanary-1.1.0/screenshot.png +0 -0
  24. thermalcanary-1.1.0/tests/__init__.py +0 -0
  25. thermalcanary-1.1.0/tests/conftest.py +57 -0
  26. thermalcanary-1.1.0/tests/test_app.py +254 -0
  27. thermalcanary-1.1.0/tests/test_config.py +459 -0
  28. thermalcanary-1.1.0/tests/test_first_run.py +106 -0
  29. thermalcanary-1.1.0/tests/test_gauge.py +439 -0
  30. thermalcanary-1.1.0/tests/test_gpu_sysfs.py +146 -0
  31. thermalcanary-1.1.0/tests/test_integration.py +64 -0
  32. thermalcanary-1.1.0/tests/test_screens.py +106 -0
  33. thermalcanary-1.1.0/tests/test_sensor.py +356 -0
  34. thermalcanary-1.1.0/tests/test_sensor_amd.py +129 -0
  35. thermalcanary-1.1.0/tests/test_sensor_intel.py +48 -0
  36. thermalcanary-1.1.0/tests/test_sensor_nvidia.py +137 -0
  37. thermalcanary-1.1.0/tests/test_sensor_select.py +121 -0
  38. thermalcanary-1.1.0/tests/test_settings_dpms.py +193 -0
  39. thermalcanary-1.1.0/tests/test_tray.py +202 -0
  40. thermalcanary-1.1.0/thermalcanary/__init__.py +7 -0
  41. thermalcanary-1.1.0/thermalcanary/__main__.py +4 -0
  42. thermalcanary-1.1.0/thermalcanary/_gpu_sysfs.py +61 -0
  43. thermalcanary-1.1.0/thermalcanary/amd.py +24 -0
  44. thermalcanary-1.1.0/thermalcanary/app.py +662 -0
  45. thermalcanary-1.1.0/thermalcanary/config.py +141 -0
  46. thermalcanary-1.1.0/thermalcanary/first_run.py +139 -0
  47. thermalcanary-1.1.0/thermalcanary/gauge.py +244 -0
  48. thermalcanary-1.1.0/thermalcanary/intel.py +11 -0
  49. thermalcanary-1.1.0/thermalcanary/nvidia.py +26 -0
  50. thermalcanary-1.1.0/thermalcanary/screens.py +47 -0
  51. thermalcanary-1.1.0/thermalcanary/sensor.py +116 -0
  52. thermalcanary-1.1.0/thermalcanary/sensor_select.py +82 -0
  53. thermalcanary-1.1.0/thermalcanary/settings.py +527 -0
  54. thermalcanary-1.1.0/thermalcanary/tray.py +89 -0
  55. thermalcanary-1.1.0/todos.md +1 -0
  56. thermalcanary-1.1.0/tools/fake_gpu.py +91 -0
  57. thermalcanary-1.1.0/uninstall.sh +46 -0
  58. thermalcanary-1.1.0/uv.lock +846 -0
@@ -0,0 +1,5 @@
1
+ {
2
+ "permissions": {
3
+ "allow": []
4
+ }
5
+ }
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # ──────────────────────────────────────────────────────────────────────────────
3
+ # Pre-commit hook — fast checks on staged files only.
4
+ # Install: git config core.hooksPath .githooks
5
+ # ──────────────────────────────────────────────────────────────────────────────
6
+
7
+ set -e
8
+
9
+ ROOT="$(git rev-parse --show-toplevel)"
10
+
11
+ STAGED_PY=$(git diff --cached --name-only --diff-filter=AM | grep '\.py$' | tr '\n' ' ' || true)
12
+ STAGED_ALL=$(git diff --cached --name-only --diff-filter=AM | tr '\n' ' ' || true)
13
+
14
+ ERRORS=0
15
+
16
+ # ── Python: syntax check on staged files ─────────────────────────────────────
17
+ if [ -n "$STAGED_PY" ]; then
18
+ echo "── Python syntax check (staged files) ──"
19
+ for f in $STAGED_PY; do
20
+ if ! uv run --project "$ROOT" python -m py_compile "$f" 2>&1; then
21
+ echo " Syntax error in: $f"
22
+ ERRORS=1
23
+ fi
24
+ done
25
+ [ $ERRORS -eq 0 ] && echo " OK"
26
+ fi
27
+
28
+ # ── Bandit: security lint on staged Python files ──────────────────────────────
29
+ # Test files are excluded: pytest legitimately requires `assert` (B101), and the
30
+ # whole point of test fixtures is to use hardcoded paths (B108). Lint app code.
31
+ if [ -n "$STAGED_PY" ]; then
32
+ echo "── Bandit security lint (staged files) ──"
33
+ BANDIT_TARGETS=""
34
+ for f in $STAGED_PY; do
35
+ case "$f" in
36
+ tests/*|*/tests/*) ;;
37
+ *) BANDIT_TARGETS="$BANDIT_TARGETS $f" ;;
38
+ esac
39
+ done
40
+ if [ -n "$BANDIT_TARGETS" ]; then
41
+ BANDIT_EXIT=0
42
+ uv run --project "$ROOT" bandit -q $BANDIT_TARGETS || BANDIT_EXIT=$?
43
+ if [ $BANDIT_EXIT -ne 0 ]; then
44
+ ERRORS=1
45
+ fi
46
+ else
47
+ echo " (only test files staged — skipped)"
48
+ fi
49
+ fi
50
+
51
+ # ── Secrets scan: block commits containing secrets/keys ──────────────────────
52
+ if [ -n "$STAGED_ALL" ]; then
53
+ echo "── Secrets scan (staged files) ──"
54
+ SECRET_PATTERNS='(PRIVATE KEY-----)'
55
+ SECRET_PATTERNS="$SECRET_PATTERNS|(sk_live_[a-zA-Z0-9]{20,})"
56
+ SECRET_PATTERNS="$SECRET_PATTERNS|(ls_live_[a-zA-Z0-9]{20,})"
57
+ SECRET_PATTERNS="$SECRET_PATTERNS|(password\s*[:=]\s*['\"][^'\"]{8,}['\"])"
58
+ SECRET_PATTERNS="$SECRET_PATTERNS|(_HMAC_SECRET\s*=\s*['\"][^'\"]{8,}['\"])"
59
+
60
+ if git diff --cached --diff-filter=AM -U0 -- ':!.githooks/pre-commit' | grep -iEq "$SECRET_PATTERNS"; then
61
+ echo " Potential secret detected in staged changes!"
62
+ echo " Review the diff and remove secrets before committing."
63
+ ERRORS=1
64
+ fi
65
+ fi
66
+
67
+ # ── Result ────────────────────────────────────────────────────────────────────
68
+ if [ $ERRORS -ne 0 ]; then
69
+ echo ""
70
+ echo "Pre-commit checks failed. Fix the errors above, then try again."
71
+ echo "To bypass (emergency only): git commit --no-verify"
72
+ exit 1
73
+ fi
74
+
75
+ echo "Pre-commit checks passed."
@@ -0,0 +1,112 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-24.04
11
+
12
+ steps:
13
+ - uses: actions/checkout@v6
14
+
15
+ - name: Install Qt system dependencies
16
+ run: |
17
+ sudo apt-get update -qq
18
+ sudo apt-get install -y --no-install-recommends \
19
+ libxcb-cursor0 libxcb-xinerama0 libegl1 libgl1 \
20
+ libglib2.0-0 libdbus-1-3
21
+
22
+ - uses: astral-sh/setup-uv@v6
23
+
24
+ - name: Install Python dependencies
25
+ run: uv sync --extra dev
26
+
27
+ - name: Run tests with coverage
28
+ run: uv run pytest tests/ --cov=thermalcanary --cov-report=term-missing --cov-report=json
29
+ env:
30
+ QT_QPA_PLATFORM: offscreen
31
+
32
+ - name: Run mutation tests (changed files only)
33
+ id: mutation
34
+ run: |
35
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
36
+ BASE="${{ github.event.pull_request.base.sha }}"
37
+ else
38
+ BASE="$(git rev-parse HEAD~1 2>/dev/null || echo '')"
39
+ fi
40
+ if [ -n "$BASE" ]; then
41
+ PATHS=$(git diff --name-only "$BASE" HEAD -- 'thermalcanary/*.py' | tr '\n' ' ')
42
+ else
43
+ PATHS=""
44
+ fi
45
+ if [ -z "$PATHS" ]; then
46
+ echo "No thermalcanary/ files changed — skipping mutation tests."
47
+ echo "score=-1" >> "$GITHUB_OUTPUT"
48
+ exit 0
49
+ fi
50
+ echo "Mutating: $PATHS"
51
+ # mutmut 3.x accepts module-name patterns as positional args, NOT
52
+ # --paths-to-mutate (which was a 2.x flag). Convert path → module.
53
+ MODS=""
54
+ for f in $PATHS; do
55
+ MODS="$MODS $(echo "$f" | sed 's|/|.|g; s|\.py$||').*"
56
+ done
57
+ # Fresh cache each CI run so `mutmut results` only lists what we ran.
58
+ rm -rf mutants/.mutmut-cache mutants/_*.json .mutmut-cache 2>/dev/null || true
59
+ uv run mutmut run $MODS || true
60
+ # mutmut 3.x results: one line per non-killed mutant ending in
61
+ # ": survived" / ": segfault" / ": timeout" / ": not checked".
62
+ # Killed mutants are hidden — derive from totals.
63
+ RESULTS=$(uv run mutmut results 2>&1)
64
+ TOTAL_LINES=$(echo "$RESULTS" | grep -cE "^\s+thermalcanary\." || true)
65
+ SURVIVED=$(echo "$RESULTS" | grep -cE ": survived$" || true)
66
+ SEGFAULT=$(echo "$RESULTS" | grep -cE ": segfault$" || true)
67
+ TIMEOUT=$(echo "$RESULTS" | grep -cE ": timeout$" || true)
68
+ NOTCHECK=$(echo "$RESULTS" | grep -cE ": not checked$" || true)
69
+ KILLED=$((TOTAL_LINES - SURVIVED - SEGFAULT - TIMEOUT - NOTCHECK))
70
+ [ "$KILLED" -lt 0 ] && KILLED=0
71
+ TOTAL=$((KILLED + SURVIVED))
72
+ SCORE=$(python3 -c "print(f'{$KILLED/$TOTAL*100:.0f}' if $TOTAL else '0')")
73
+ echo "Mutation score: ${SCORE}% (${KILLED}/${TOTAL} killed)"
74
+ echo "score=$SCORE" >> "$GITHUB_OUTPUT"
75
+ echo "killed=$KILLED" >> "$GITHUB_OUTPUT"
76
+ echo "total=$TOTAL" >> "$GITHUB_OUTPUT"
77
+ python3 -c "import sys; sys.exit(0 if $TOTAL == 0 or $KILLED/$TOTAL >= 0.50 else 1)" \
78
+ || { echo "FAIL: mutation kill rate below 50%"; exit 1; }
79
+ env:
80
+ QT_QPA_PLATFORM: offscreen
81
+
82
+ - name: Update badges
83
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.mutation.outputs.score != '-1'
84
+ run: |
85
+ # coverage badge
86
+ COV=$(python3 -c "import json; d=json.load(open('coverage.json')); print(int(d['totals']['percent_covered_display']))")
87
+ if [ "$COV" -ge 90 ]; then COV_COLOR=brightgreen
88
+ elif [ "$COV" -ge 80 ]; then COV_COLOR=green
89
+ elif [ "$COV" -ge 70 ]; then COV_COLOR=yellowgreen
90
+ elif [ "$COV" -ge 60 ]; then COV_COLOR=yellow
91
+ else COV_COLOR=orange
92
+ fi
93
+ printf '{"schemaVersion":1,"label":"coverage","message":"%s%%","color":"%s"}\n' "$COV" "$COV_COLOR" \
94
+ > coverage-badge.json
95
+ # mutation badge
96
+ S=${{ steps.mutation.outputs.score }}
97
+ if [ "$S" -ge 80 ]; then COLOR=brightgreen
98
+ elif [ "$S" -ge 70 ]; then COLOR=green
99
+ elif [ "$S" -ge 60 ]; then COLOR=yellowgreen
100
+ elif [ "$S" -ge 50 ]; then COLOR=yellow
101
+ else COLOR=orange
102
+ fi
103
+ printf '{"schemaVersion":1,"label":"mutation","message":"%s%% (%s/%s)","color":"%s"}\n' \
104
+ "$S" "${{ steps.mutation.outputs.killed }}" "${{ steps.mutation.outputs.total }}" "$COLOR" \
105
+ > mutation-badge.json
106
+ git config user.name "github-actions[bot]"
107
+ git config user.email "github-actions[bot]@users.noreply.github.com"
108
+ git add coverage-badge.json mutation-badge.json
109
+ git diff --cached --quiet || git commit -m "chore: update badges [skip ci]"
110
+ git push
111
+ env:
112
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,18 @@
1
+ venv/
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ *.pyo
6
+ .idea/
7
+ *.swp
8
+ CLAUDE.md
9
+ scripts/
10
+ .serena/
11
+ .coverage
12
+ htmlcov/
13
+ dist/
14
+ build/
15
+ *.egg-info/
16
+ mutants/
17
+ pyproject.toml.mutmut_tmp
18
+ .mutmut-cache
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ibasaw
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: thermalcanary
3
+ Version: 1.1.0
4
+ License-File: LICENSE
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: nvidia-ml-py>=12.0.0
7
+ Requires-Dist: psutil>=5.9.0
8
+ Requires-Dist: pyqt6>=6.5.0
9
+ Requires-Dist: pyyaml>=6.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: bandit>=1.7; extra == 'dev'
12
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
13
+ Requires-Dist: pytest-mock>=3.14; extra == 'dev'
14
+ Requires-Dist: pytest-qt>=4.4; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -0,0 +1,181 @@
1
+ <p align="center">
2
+ <img src="assets/logo.png" alt="Thermal Canary" width="600">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://github.com/ibasaw/thermalcanary/actions/workflows/ci.yml"><img src="https://github.com/ibasaw/thermalcanary/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
7
+ <img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/ibasaw/thermalcanary/main/coverage-badge.json&cacheSeconds=300" alt="Coverage">
8
+ <img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/ibasaw/thermalcanary/main/mutation-badge.json&cacheSeconds=300" alt="Mutation score">
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
10
+ <img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10+">
11
+ <img src="https://img.shields.io/badge/platform-Linux-lightgrey.svg" alt="Platform: Linux">
12
+ </p>
13
+
14
+ The Linux software for dedicated hardware monitoring screens — like AIDA64's sensor panel, but native to your desktop.
15
+
16
+ 6 circular arc gauges (CPU temp, usage, RAM · GPU temp, fan, VRAM) built with PyQt6 and pynvml.
17
+ Smooth 60fps animation, rolling average stabilisation, dynamic heat colors. Auto-starts on login.
18
+
19
+ **Built for dedicated monitoring screens** — works out of the box on stretched panels (1920×480), small IPS monitors, or any secondary display you use as a permanent hardware panel.
20
+
21
+ > ⚠ **Important** — this is NOT an overlay. Thermal Canary is a fullscreen app intended for a **dedicated monitor**. Don't try to use it as an in-game overlay (use MangoHud for that).
22
+
23
+ **Fully responsive** — gauges scale to any resolution and orientation: ultrawide, portrait, rotated, compact.
24
+
25
+ **One-command install** — a single `bash install.sh` sets up everything automatically.
26
+
27
+ ### Dedicated monitoring screen setup
28
+
29
+ Plug in a secondary screen (a stretched panel, a small IPS monitor, anything), select it in the Settings sidebar under **Monitor**, and click **Set as default**. Thermal Canary will always open on that screen at login — no configuration files to edit.
30
+
31
+ ![Thermal Canary preview](screenshot.png)
32
+
33
+ ![Thermal Canary with settings sidebar open](screenshot-settings.png)
34
+
35
+ ## Gauges
36
+
37
+ | Row | Gauge | Source |
38
+ |-----|-------|--------|
39
+ | CPU | CPU Temperature | `psutil.sensors_temperatures()` — coretemp/k10temp (configurable) |
40
+ | CPU | CPU Usage % | psutil |
41
+ | CPU | RAM Usage % | psutil |
42
+ | GPU | GPU Temperature | NVIDIA: pynvml · AMD: sysfs hwmon · Intel: xe/i915 sysfs |
43
+ | GPU | GPU Fan Speed % | NVIDIA: pynvml · AMD: sysfs hwmon (0% = fans stopped or AMD integrated GPU with no fan) |
44
+ | GPU | GPU VRAM Usage % | NVIDIA: pynvml · AMD: sysfs hwmon · Intel: always 0 (not exposed by driver) |
45
+
46
+ ## Requirements
47
+
48
+ > **Tested and supported on Ubuntu 24.04 LTS.** Other distros (Fedora, Arch, openSUSE) are supported by the installer but have not been formally tested yet. Ubuntu versions below 24.04 may work but are not guaranteed.
49
+
50
+ The installer checks and installs everything automatically. Here is the full dependency list:
51
+
52
+ **System packages** (installed automatically via your distro's package manager):
53
+
54
+ | Dependency | Purpose |
55
+ |------------|---------|
56
+ | Python 3.10+ | Runtime |
57
+ | `python3-venv` | Isolated Python environment |
58
+ | `lm-sensors` | Populates `/sys/class/hwmon` for CPU temperature |
59
+ | XCB cursor libs (`libxcb-cursor0` / `xcb-util-cursor`) | Required by PyQt6 on X11 |
60
+
61
+ **NVIDIA driver** — checked separately. The installer prints distro-specific install instructions if the driver is missing. GPU gauges (temperature, fan, VRAM) require the driver for NVIDIA; AMD and Intel GPUs are read via sysfs without any driver installation.
62
+
63
+ > **GPU backends**: NVIDIA (`pynvml`, direct NVML — no `nvidia-smi` subprocess), AMD (`amdgpu` sysfs hwmon — kernel driver, no extra install), Intel (`xe`/`i915` sysfs hwmon). The backend is auto-detected at startup or can be forced in the Settings sidebar. If no GPU is detected, all three GPU gauges show `—`.
64
+
65
+ **Python libraries** (installed automatically into a venv):
66
+
67
+ | Library | Purpose |
68
+ |---------|---------|
69
+ | `PyQt6` | GUI framework |
70
+ | `psutil` | CPU temperature, CPU usage, RAM usage |
71
+ | `nvidia-ml-py` (`pynvml`) | GPU metrics via direct NVML calls — no `nvidia-smi` subprocess |
72
+ | `PyYAML` | Config file read/write |
73
+
74
+ Supported package managers: **apt** (Debian/Ubuntu), **dnf** (Fedora/RHEL), **pacman** (Arch), **zypper** (openSUSE).
75
+
76
+ ## Install
77
+
78
+ ```bash
79
+ git clone https://github.com/ibasaw/thermalcanary.git
80
+ cd thermalcanary
81
+ bash install.sh
82
+ ```
83
+
84
+ **Thermal Canary runs entirely as your user — no root needed at runtime.**
85
+
86
+ The installer separates system-package installation from everything else:
87
+
88
+ - `bash install.sh` — checks system packages and prints any missing ones with the exact `sudo` command to install them, then proceeds with the rootless steps (venv, pip, desktop files, launch)
89
+ - `bash install.sh --install-deps` — same, but also invokes `sudo` automatically to install missing packages
90
+
91
+ The installer will:
92
+ 1. Check for NVIDIA driver — prints distro-specific install instructions if missing (GPU gauges need it; CPU/RAM gauges work without)
93
+ 2. Check system packages — prints missing ones or installs them if `--install-deps` was passed
94
+ 3. Copy the app package and icon to `~/.local/share/thermalcanary/`
95
+ 4. Create a Python venv and install all Python dependencies (PyQt6, psutil, nvidia-ml-py, PyYAML)
96
+ 5. Verify all dependencies are working
97
+ 6. Copy the default config to `~/.config/thermalcanary/config.yaml` (only on first install — never overwrites existing config)
98
+ 7. Register the app in `~/.local/share/applications/` for taskbar icon support
99
+ 8. Register an autostart entry so the app launches 8 seconds after login
100
+ 9. Kill any running instance and launch the app immediately
101
+
102
+ The clone folder is only needed to run the installer. You can delete it afterwards.
103
+
104
+ ## Installed file layout
105
+
106
+ | Path | What |
107
+ |------|------|
108
+ | `~/.local/share/thermalcanary/thermalcanary/` | App package |
109
+ | `~/.local/share/thermalcanary/assets/` | App icon |
110
+ | `~/.local/share/thermalcanary/venv/` | Python virtual environment |
111
+ | `~/.local/share/icons/hicolor/*/apps/thermalcanary.png` | System icon (taskbar) |
112
+ | `~/.local/share/applications/thermalcanary.desktop` | App entry (taskbar icon) |
113
+ | `~/.config/thermalcanary/config.yaml` | User configuration (auto-saved by the app) |
114
+ | `~/.config/autostart/thermalcanary.desktop` | Autostart on login |
115
+
116
+ ## Launch manually
117
+
118
+ ```bash
119
+ cd ~/.local/share/thermalcanary && venv/bin/python3 -m thermalcanary
120
+ ```
121
+
122
+ ## Settings sidebar
123
+
124
+ Two buttons sit in the top-right corner: **⚙** opens the settings sidebar, **✕** closes the app (with confirmation). Press **Ctrl+,** to toggle the sidebar from the keyboard. All changes apply live without restarting.
125
+
126
+ | Section | Setting | Description |
127
+ |---------|---------|-------------|
128
+ | Display | Monitor | Which monitor to display on — works with any number of monitors and any orientation |
129
+ | Display | Set as default | Save the current monitor as the startup monitor — the app always opens here on launch |
130
+ | Sampling | Poll rate | Sensor poll interval (100ms – 10s) |
131
+ | Sampling | Smoothing | Rolling average window size (1–60 samples) |
132
+ | Colors | Background | Window background color |
133
+ | Colors | Inner circle | Gauge center fill color |
134
+ | Colors | Arc track | Unfilled arc track color |
135
+ | Colors | Tick marks | Tick mark color |
136
+
137
+ The **Reset to defaults** button restores all colors and sampling values to factory settings while keeping your saved default monitor.
138
+
139
+ ## Configuration file
140
+
141
+ `~/.config/thermalcanary/config.yaml` is written automatically by the settings sidebar. You can also edit it by hand; changes take effect on next launch.
142
+
143
+ ```yaml
144
+ poll_ms: 1000 # sensor poll interval in milliseconds
145
+ smooth_n: 5 # rolling average window (number of samples)
146
+
147
+ bg_color: "#252040" # window background
148
+ inner_color: "#1e1a35" # gauge inner circle
149
+ track_color: "#332e55" # arc track (unfilled)
150
+ tick_color: "#3d3860" # tick marks
151
+
152
+ # screen_index and default_screen_index are set via the Settings sidebar
153
+ ```
154
+
155
+ ## Autostart caveat
156
+
157
+ The autostart entry inherits `$DISPLAY` from the login session, falling back to `:0`. If the app doesn't start on login, check your display number with `echo $DISPLAY` and edit `~/.config/autostart/thermalcanary.desktop` accordingly.
158
+
159
+ ## Uninstall
160
+
161
+ ```bash
162
+ bash uninstall.sh
163
+ ```
164
+
165
+ Removes the app, venv, icon, config, and all desktop entries after confirmation.
166
+
167
+ ## Architecture
168
+
169
+ - **SensorWorker** runs in a `QThread` — all sensor reads are off the main thread
170
+ - GPU metrics use **pynvml** (direct NVML calls, <1ms) — never `subprocess nvidia-smi` which causes observer-effect CPU temperature spikes of 10–15°C
171
+ - CPU temp and usage are stabilised with a `deque`-based rolling average
172
+ - **Config** is reactive: `Config(QObject)` emits a signal on every change, all settings apply live
173
+ - Window runs **fullscreen** (`showFullScreen`) — no title bar, fills the entire monitor. This bypasses Mutter's `WM_NORMAL_HINTS` enforcement which otherwise clamps window geometry to `minimumSizeHint`, making maximize unreliable on short or rotated monitors
174
+ - Monitor switching uses an event-driven state machine: `showNormal()` → wait for `WindowStateChange` (Mutter ack) → `windowHandle().setScreen()` + `setGeometry()` → 50ms → `showFullScreen()`. `wmctrl` is called once after first show to set `_NET_WM_STATE_SKIP_TASKBAR/SKIP_PAGER` (hides the window from GNOME Dash without using `Qt.WindowType.Tool`, which would break cross-monitor placement on Mutter)
175
+ - Monitor indices are validated against actual connected screens at startup — safe on any number of monitors
176
+ - Single-instance lock via `fcntl.flock` on `$XDG_RUNTIME_DIR/thermalcanary.lock`
177
+ - Runs entirely as the logged-in user — no root required at runtime
178
+
179
+ ## License
180
+
181
+ MIT — see [LICENSE](LICENSE)
Binary file
Binary file
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+ """Generate assets/logo.png — horizontal lockup: canary icon + "Thermal Canary" wordmark."""
3
+ from pathlib import Path
4
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter
5
+
6
+ ROOT = Path(__file__).resolve().parent.parent
7
+ ICON = ROOT / "assets" / "icon.png"
8
+ FONT_PATH = ROOT / "bin" / "fonts" / "Exo2-SemiBold.ttf"
9
+ OUT = ROOT / "assets" / "logo.png"
10
+
11
+ CANVAS = (1400, 400)
12
+ BIRD_HEIGHT = 320
13
+ BIRD_X = 80
14
+ BIRD_Y = (CANVAS[1] - BIRD_HEIGHT) // 2 # 40px top/bottom margin
15
+ GAP = 60
16
+ FONT_SIZE = 140
17
+ COLOR_THERMAL = (242, 242, 242, 255) # warm off-white
18
+ COLOR_CANARY = (245, 197, 24, 255) # matches bird yellow
19
+ SHADOW = (0, 0, 0, 80)
20
+ OPTICAL_NUDGE = -20 # raise text to align with bird body centroid
21
+
22
+ canvas = Image.new("RGBA", CANVAS, (0, 0, 0, 0))
23
+
24
+ # Bird
25
+ bird = Image.open(ICON).convert("RGBA")
26
+ bw = int(bird.width * BIRD_HEIGHT / bird.height)
27
+ bird = bird.resize((bw, BIRD_HEIGHT), Image.LANCZOS)
28
+ bird_layer = Image.new("RGBA", CANVAS, (0, 0, 0, 0))
29
+ bird_layer.paste(bird, (BIRD_X, BIRD_Y), bird)
30
+ canvas = Image.alpha_composite(canvas, bird_layer)
31
+
32
+ # Font with fallback chain
33
+ for font_path in [FONT_PATH, "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"]:
34
+ try:
35
+ font = ImageFont.truetype(str(font_path), FONT_SIZE)
36
+ break
37
+ except (OSError, IOError):
38
+ continue
39
+ else:
40
+ font = ImageFont.load_default()
41
+
42
+ text_x = BIRD_X + bw + GAP
43
+ text_y = (CANVAS[1] - FONT_SIZE) // 2 + OPTICAL_NUDGE
44
+ w_thermal = font.getbbox("Thermal ")[2]
45
+
46
+ # Shadow layer
47
+ shadow_layer = Image.new("RGBA", CANVAS, (0, 0, 0, 0))
48
+ sd = ImageDraw.Draw(shadow_layer)
49
+ sd.text((text_x, text_y + 2), "Thermal ", font=font, fill=SHADOW)
50
+ sd.text((text_x + w_thermal, text_y + 2), "Canary", font=font, fill=SHADOW)
51
+ shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=2))
52
+ canvas = Image.alpha_composite(canvas, shadow_layer)
53
+
54
+ # Text layer
55
+ text_layer = Image.new("RGBA", CANVAS, (0, 0, 0, 0))
56
+ td = ImageDraw.Draw(text_layer)
57
+ td.text((text_x, text_y), "Thermal ", font=font, fill=COLOR_THERMAL)
58
+ td.text((text_x + w_thermal, text_y), "Canary", font=font, fill=COLOR_CANARY)
59
+ canvas = Image.alpha_composite(canvas, text_layer)
60
+
61
+ canvas.save(OUT, "PNG", optimize=True)
62
+ print(f"Saved {OUT} ({OUT.stat().st_size // 1024} KB)")
@@ -0,0 +1,23 @@
1
+ # Thermal Canary — configuration
2
+ # Installed to: ~/.config/thermalcanary/config.yaml
3
+ # Edited live by the Settings sidebar — you can also edit this file by hand.
4
+
5
+ # Monitor to display on (0 = primary, 1 = second, 2 = third...).
6
+ # Defaults to your primary monitor. Set via the Settings sidebar.
7
+ # screen_index: 0
8
+
9
+ # Monitor to return to when "Reset to defaults" is pressed.
10
+ # Set via the "Set as default" button in the Settings sidebar.
11
+ # default_screen_index: 0
12
+
13
+ # Sensor poll interval in milliseconds (lower = more responsive, higher = less CPU)
14
+ poll_ms: 1000
15
+
16
+ # Rolling average window: number of samples averaged to smooth noisy sensors
17
+ smooth_n: 5
18
+
19
+ # Colors — hex strings
20
+ bg_color: "#252040" # window background
21
+ inner_color: "#1e1a35" # gauge inner circle
22
+ track_color: "#332e55" # arc track (unfilled portion)
23
+ tick_color: "#3d3860" # tick marks
@@ -0,0 +1 @@
1
+ {"schemaVersion":1,"label":"coverage","message":"72%","color":"yellowgreen"}