diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a0ef5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# build artefacts +dist/ +build/ +*.AppImage +__pycache__/ +*.pyc + +# machine-specific analysis artefacts (regenerate with REA) +evidence/ +snapshots/ +*.rea.backup + +# local bits +*.log +config.json diff --git a/DISCLAIMER.md b/DISCLAIMER.md new file mode 100644 index 0000000..0a08f16 --- /dev/null +++ b/DISCLAIMER.md @@ -0,0 +1,39 @@ +# Disclaimer + +**Bahasa Indonesia** + +Aplikasi ini dibuat oleh **oxpoe** semata-mata untuk **kompatibilitas Linux dengan +perangkat keras** (khususnya layar LCD pada AIO cooler). Tujuannya agar pengguna +Linux bisa memakai perangkat yang secara resmi hanya didukung aplikasi Windows. + +1. **Tidak berafiliasi** dengan GAMEMAX, GameMax, atau vendor mana pun. Proyek ini + tidak disponsori, tidak didukung, dan tidak berhubungan dengan pembuat perangkat. +2. **Dibuat untuk interoperabilitas.** Protokolnya dipelajari secara independen dari + perilaku perangkat dan dari aplikasi vendor yang sudah terpasang di komputer + penulis (rekayasa balik untuk kompatibilitas). Tidak ada kode, berkas, atau aset + milik vendor yang disertakan dalam repositori ini. +3. **Merek dagang** seperti GAMEMAX, GameMax, IceBurg, serta nama produk lain + adalah milik pemegangnya masing-masing. Ikon aplikasi di repositori ini adalah + karya orisinal, bukan logo vendor. +4. **Risiko ditanggung pengguna.** Perangkat lunak ini menulis data ke perangkat USB + HID. Penulis telah mengujinya pada perangkat miliknya sendiri, tetapi tidak ada + jaminan apa pun — termasuk jaminan kelayakan untuk tujuan tertentu. Gunakan dengan + risiko Anda sendiri. +5. **Tanpa jaminan.** Perangkat lunak disediakan "sebagaimana adanya" di bawah + lisensi MIT; lihat `LICENSE`. +6. **Perkakas pihak ketiga** yang dipakai saat analisis (mis. REA, Ghidra, PySide6) + tetap tunduk pada lisensinya masing-masing dan tidak didistribusikan ulang di sini. + +**English** + +This project is made by **oxpoe** purely for **Linux hardware compatibility** — +so that Linux users can drive the LCD panel of a device that is officially supported +only by a Windows application. + +- Not affiliated with, sponsored by, or endorsed by GAMEMAX/GameMax or any vendor. +- Independently reverse engineered for interoperability. No vendor code, binaries, + or assets are redistributed; the application icon is original artwork. +- Trademarks belong to their respective owners. +- The software writes to a USB HID device. It was tested on the author's own hardware + only. Use at your own risk, provided "as is", without warranty of any kind (MIT). +- Third-party analysis tools keep their own licenses. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a3c0d1 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# GAMEMAX LCD — Linux bridge & tray app + +Drive the LCD panel of a GAMEMAX **IceBurg Digital** AIO cooler from Linux: a small +sender plus a tray application (GUI) with live monitoring, settings and autostart. + +Reverse engineered independently from the Windows vendor software +(`PcInfoMonitorPro.exe` / "IceBurg Digital Software"); the protocol notes are in +[`docs/reverse-engineering.md`](docs/reverse-engineering.md) (Indonesian). + +Author: **oxpoe** · License: **MIT** · Please read [DISCLAIMER.md](DISCLAIMER.md). + +![Tray app preview](assets/preview.png) + +## Features + +- Sends CPU temp/load/clock/fan/power, GPU temp/load/clock/fan/power, RAM and disk + usage to the panel as a 65-byte HID report (the layout the firmware understands) +- Reads sensors from standard Linux interfaces: `hwmon`, `/proc/stat`, `cpufreq`, + `/proc/meminfo`, `statvfs` — no extra dependencies (pure Python, stdlib only) +- **Tray app** (`--tray-only`) plus a window with an animated gauge, bars and details +- **Settings**: fan channel (pick the header your fans are on), CPU temperature source, + send/sample interval — saved to `~/.config/gamemax-report/config.json` +- **Clear mode indicators**: green "showing sensor data" / orange "blank (zeros)" / + grey "sending stopped", dimmed tray icon and a desktop notification per change +- **Autostart** via a systemd user unit (equivalent of the vendor's Task Scheduler trick) +- **Plug and play**: the service waits for the device, streams while present and keeps + waiting through unplug/replug; a udev rule grants access to the logged-in user +- **Single instance**: launching the app again just raises the running window +- **AppImage build** script (`packaging/build_appimage.sh`, PySide6 bundled) + +The GUI labels are in Indonesian. + +## Requirements + +- Linux with a working `hidraw` stack (tested on CachyOS / Arch, KDE Wayland) +- Python 3.10+ for the sender (stdlib only) +- For the tray app: PySide6 (`uv run --with pyside6 python src/gamemax_gui.py`) + or use the prebuilt AppImage + +## Install + +```bash +# 1. device access for the logged-in user (once, needs root) +sudo cp packaging/70-gamemax.rules /etc/udev/rules.d/ +sudo udevadm control --reload && sudo udevadm trigger --subsystem-match=hidraw + +# 2. try it without installing anything +cd src +python3 gamemax_report.py --dry-run # build and print a report, no device writes +python3 gamemax_report.py --listen 5 # read panel -> host reports (none on this panel) + +# 3. send for real +python3 gamemax_report.py --count 3 # a few reports +python3 gamemax_report.py # keep streaming (Ctrl-C to stop) +``` + +## Run as a background service (autostart + plug and play) + +```bash +mkdir -p ~/.local/share/gamemax-report ~/.config/systemd/user +cp src/gamemax_report.py ~/.local/share/gamemax-report/ +cp packaging/gamemax-report.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now gamemax-report.service +journalctl --user -u gamemax-report -f # shows "waiting for 0x5131:0x2007" / "opened ..." +``` + +The tray app can do all of this for you (buttons *Pasang / perbarui layanan*, +*Mulai otomatis saat login*). + +## Tray app / AppImage + +```bash +uv run --with pyside6 python src/gamemax_gui.py # window + tray +uv run --with pyside6 python src/gamemax_gui.py --tray-only + +bash packaging/build_appimage.sh # -> dist/GAMEMAX-LCD-x86_64.AppImage +``` + +## Protocol (verified on hardware) + +VID `0x5131` / PID `0x2007`, vendor-defined HID page `0xFF00`, 64-byte IN + 64-byte OUT, +no report IDs. The host sends 64 data bytes: + +| Offset | Field | Notes | +|---|---|---| +| 0 | report ID | always 0 | +| 1 | 64 | payload length | +| 2 | CPU temp | 0..99 °C, two digits only; values ≥ 128 are ignored by firmware | +| 3 | CPU load % | | +| 4..5 | RPM slot | big-endian u16 — the panel shows *this* field next to the fan icon | +| 6..7 | CPU clock MHz | big-endian u16 | +| 8..9 | CPU power W | big-endian u16 | +| 10 | GPU temp | 0..99 °C | +| 11 | GPU load % | | +| 12..13 | GPU clock MHz | big-endian u16 | +| 14..15 | GPU fan RPM | big-endian u16 | +| 16..17 | GPU power W | big-endian u16 | +| 18 | RAM usage % | | +| 19..20 | total RAM MiB | big-endian u16 | +| 21 | disk free % | | +| 22..23 | total disk GB | big-endian u16 | +| 24..64 | unused | zero | + +Firmware behaviours worth knowing: the panel latches values it accepts, blinks the +value area when the data stops changing, cannot display three digits, has a fixed `°C` +label, and offers no command to power the display off. + +## Repository layout + +``` +src/ gamemax_report.py (sender) gamemax_gui.py (tray app) icon.png +packaging/ build_appimage.sh 70-gamemax.rules gamemax-report.service +tools/ ildump.py, string_refs.py — helpers used to read the vendor MSIL +docs/ reverse-engineering.md — full analysis (Indonesian) +assets/ previews and icon +``` + +## Credits + +Reverse engineered and written by **oxpoe**, with the help of +[REA](https://github.com/morluto/rea) (managed-code analysis) and Ghidra. +Not affiliated with GAMEMAX/GameMax — see [DISCLAIMER.md](DISCLAIMER.md). diff --git a/assets/icon-128.png b/assets/icon-128.png new file mode 100644 index 0000000..2ea35b4 Binary files /dev/null and b/assets/icon-128.png differ diff --git a/assets/preview-blank-mode.png b/assets/preview-blank-mode.png new file mode 100644 index 0000000..36942f0 Binary files /dev/null and b/assets/preview-blank-mode.png differ diff --git a/assets/preview.png b/assets/preview.png new file mode 100644 index 0000000..9df59dc Binary files /dev/null and b/assets/preview.png differ diff --git a/docs/reverse-engineering.md b/docs/reverse-engineering.md new file mode 100644 index 0000000..66598c5 --- /dev/null +++ b/docs/reverse-engineering.md @@ -0,0 +1,204 @@ +# GAMEMAX PcInfoMonitorPro — Recon & HID Protocol + +Target: `GAMEMAX_V1.0.0/app/` (installer `GAMEMAX_V1.0.0.04(1).exe`) +Analisis: 2026-10-13, REA 6.4.0 + Ghidra 12.1.2 (managed static provider), IL dumper lokal. + +## 1. Isi target + +| File | Peran | Teknologi | +|---|---|---| +| `PcInfoMonitorPro.exe` | Aplikasi utama (GUI) | PE32 mixed-mode C++/CLI + Qt5, **logika = MSIL** (457 method body) | +| `allComputerInfoGetPro.exe` | Helper console (dipanggil terpisah) | mixed-mode C++/CLI, sebagian besar CRT | +| `WitmodHardwareInfoLibrary.dll` | Model data sensor (CPU/GPU/mobo) | .NET Framework, CIL murni | +| `LibreHardwareMonitorLib.dll` | Sumber data sensor | OSS pihak ketiga (bukan kode vendor) | +| `HidSharp.dll` | HID library .NET | OSS pihak ketiga (tidak dipakai jalur utama) | +| `hidapi.dll` | I/O HID | OSS, **dipakai** untuk buka/tulis device | +| `Qt5*.dll`, `opengl32sw.dll`, `D3Dcompiler_47.dll` | Framework UI | Qt 5 native | +| `configDir/appConfig.ini` | Konfigurasi app | `[AppInfo] AppName=GAMEMAX, AppVersion=V_1.0.0.04, AppID=ABCDEF6543A1`, warna tema, font Microsoft YaHei | + +Catatan: vendor internal tampil sebagai `MyCompanyWitmod` / `WITMOD`; GAMEMAX adalah merek OEM-nya. + +## 2. Arsitektur logika + +Semua logika aplikasi dikompilasi ke **MSIL** (mixed-mode `/clr`), sehingga +decompile native (Ghidra) pada metode aplikasi menghasilkan sampah. Metode +dideklarasikan sebagai fungsi global bernama bertitik, mis. +`MainHidInterFace.hidApi_Write`, `DMainWidget.onGetPcInfoOk`, di bawah tipe ``. +Native hanya: Qt DLL, hidapi, CRT. + +Alur HID: +``` +DMainWidget.onGetPcInfoOk (bangun report 65 byte dari sensor) + -> MainHidInterFace.hidApi_Write (mutex + copy 65 B + QList enqueue) + -> MainHidInterFace.run (thread: QList.takeFirst -> hid_write, poll 500 us) +``` + +## 3. Device target & pembukaan + +`MainHidInterFace.getuUsbHidDeviceList`: +1. `hid_enumerate(0, 0)` — semua HID +2. filter `vendor_id == 20785 (0x5131)` **dan** `product_id == 8199 (0x2007)` +3. ambil `path` (offset 0, string ASCII) → `hid_open_path` +4. daftar device aktif dimuat via `RegisterDeviceNotificationW` (plug/unplug, sinyal + `sigHidDeviceConnectChanging_Into/Remove`, `sigOpenHidSuccessful`) + +Tulis **satu arah**: tidak ada `hid_read*` yang diimpor — app hanya mengirim. + +## 4. Protokol tulis (terverifikasi dari IL) + +- Transfer `hid_write(handle, buf, 65)`: 65 byte = 1 byte report ID + 64 byte payload +- `buf[0] = 0` (report ID 0) +- `buf[1] = 64` (0x40, panjang payload) +- Nilai 16-bit **big-endian** (high byte dulu) +- Suhu: jika checkbox unit tercentang → nilai dipakai apa adanya; jika tidak → + `round((v - 32) / 1.8) | 0x80`. **Bit 0x80 menandakan nilai Fahrenheit.** +- Pengiriman berulang tiap 500 us selama device terbuka + +### Layout report (dari `DMainWidget.onGetPcInfoOk`) + +| Offset | Isi | Sumber | +|---|---|---| +| 0 | 0 | report ID | +| 1 | 64 | panjang payload | +| 2 | CPU temp | `GetCpu_Temperature` (+flag 0x80 bila °F) | +| 3 | CPU load | `GetCpu_Load` | +| 4..5 | CPU clock (MHz, BE16) | `GetCpu_Clock` | +| 6..7 | CPU fan (RPM, BE16) | `GetCpu_Fan` | +| 8..9 | BE16 dari struct lokal | belum dipetakan | +| 10 | GPU temp | `GetGpu_Temperature` (+flag 0x80) | +| 11 | GPU load | `GetGpu_CoreLoad` | +| 12..13 | GPU clock (BE16) | `GetGpu_CoreClock` | +| 14..15 | GPU fan (BE16) | `GetGpu_Fan` | +| 16..17 | GPU power (BE16) | `GetGpu_Power` | +| 18 | u8 dari struct lokal | belum dipetakan | +| 19..20 | BE16 dari struct lokal | belum dipetakan | +| 21 | u8 dari struct lokal | belum dipetakan | +| 22..23 | BE16 dari struct lokal | belum dipetakan | +| 24..64 | 0 | tidak ditulis jalur ini | + +Nilai sensor berasal dari `WitmodHardwareInfoLibrary.Monitor` (wrapper tipis di atas +LibreHardwareMonitor): CPU/GPU Temperature, Load, CoreLoad, Clock, Clock_Max, Fan, +Fan_Max, Power, Power_Max. + +## 5. Bukti & artefak + +- `analysis/evidence/hid-write-protocol.json` — Evidence bundle REA (12 records, 5.5 MB) +- `analysis/snapshots/pcinfomonitorpro.json` — snapshot Ghidra (bisa dibuka ulang tanpa import) +- `analysis/tools/ildump.py` — disassembler IL (dnfile+dncil) untuk membaca MSIL dengan + operand terselesaikan; venv: `uv venv ilenv && uv pip install --python ilenv/bin/python dnfile dncil` + +## 6. Hasil lanjutan + +### 6.1 Hanya ada satu jenis perintah HID +Seluruh IL (457 body) dipindai: setiap fungsi hidapi dipanggil tepat sekali +(`hid_init`, `hid_enumerate`, `hid_free_enumeration`, `hid_open_path`, +`hid_set_nonblocking`, `hid_write`, `hid_close`, `hid_exit`) dan `hidApi_Write` +hanya punya **satu pemanggil** (`DMainWidget.onGetPcInfoOk`). Tidak ada +`hid_read*`/`hid_get_feature_report` — tidak ada perintah lain (brightness, +mode, dll.) di jalur ini. + +### 6.2 Auto-start + self-elevation (Task Scheduler) +`DMainWidget.initThemeData` (fungsi startup besar, offset IL ~4380-4520): +1. Menulis penanda ke QSettings: `StartupTaskOKver---` +2. `ShellExecuteW(verb="runas", file="schtasks", params=`
+ `/create /tn "%1" /tr "\"%2\" %3" /sc onlogon /rl highest` + - `%2` = `QCoreApplication.applicationFilePath()` (path exe sendiri) + - `%3` = `---StartupByTaskMinWindow` (penanda argumen) + - verb `runas` → UAC sekali di awal, task berjalan dengan token tertinggi tiap logon +3. `main` memindai `argv` untuk `---StartupByTaskMinWindow`, lalu mencetak + "Program was started by Task Scheduler." / "Program was not started by Task Scheduler." + dan memulai aplikasi terminimalkan +4. Manifest exe: `requestedExecutionLevel level='requireAdministrator'` + +Efek: aplikasi otomatis berjalan sebagai admin tanpa prompt UAC di setiap logon. + +### 6.3 `allComputerInfoGetPro.exe` = stub test +`main`: `new Monitor(); GetComputerInfo(); Sleep(500); printf("Over ... ..."); getchar();` +— harness uji developer untuk `WitmodHardwareInfoLibrary`, bukan bagian alur runtime. +Nama file ini **tidak dirujuk** oleh PcInfoMonitorPro.exe (ASCII maupun UTF-16) dan +tidak ada di installer. + +### 6.4 Layout report — LENGKAP (terverifikasi di device) + +| Offset | Isi | Sumber di aplikasi | Terverifikasi | +|---|---|---|---| +| 0 | 0 | report ID | ✅ | +| 1 | 64 | panjang payload | ✅ | +| 2 | CPU temp (0..127) | `GetCpu_Temperature` | ✅ tampil di layar | +| 3 | CPU load % | `GetCpu_Load` | ✅ | +| 4..5 | CPU clock MHz (BE16) | `GetCpu_Clock` | ✅ — **device merender byte ini di slot "RPM"** | +| 6..7 | CPU fan RPM (BE16) | `GetCpu_Fan` | ✅ (device tidak menampilkannya di halaman ini) | +| 8..9 | **CPU power W** (BE16) | `GetCpu_Power` | ✅ | +| 10 | GPU temp (0..127) | `GetGpu_Temperature` | ✅ | +| 11 | GPU load % | `GetGpu_CoreLoad` | ✅ | +| 12..13 | GPU clock MHz (BE16) | `GetGpu_CoreClock` | ✅ | +| 14..15 | GPU fan RPM (BE16) | `GetGpu_Fan` | ✅ | +| 16..17 | GPU power W (BE16) | `GetGpu_Power` | ✅ | +| 18 | RAM usage % | `GlobalMemoryStatusEx` → `dwMemoryLoad` | ✅ | +| 19..20 | total RAM MiB (BE16) | `ullTotalPhys` × 2⁻²⁰ | ✅ | +| 21 | disk free % | `GetDiskFreeSpaceExW` (free/total × 100) | ✅ | +| 22..23 | total disk GB (BE16) | total bytes >> 30 | ✅ | +| 24..64 | nol | tidak dipakai | ✅ | + +Catatan pembalikan dari dugaan awal: byte 8..9 **bukan** counter waktu melainkan +CPU power; 19..20 dan 22..23 adalah RAM total dan ukuran disk, bukan waktu. + +### 6.5 Perilaku firmware yang ditemukan saat pengujian + +1. **Nilai ≥ 128 ditolak.** Aplikasi asli menulis suhu Celsius dengan bit `0x80` + aktif. Firmware IceBurg **mengabaikan field itu** dan mempertahankan nilai terakhir + yang valid (pengujian: 99 °C mentok sampai nilai tanpa flag dikirim). Jadi kirim + suhu 0..127 tanpa flag. +2. **Kedip = indikator "nilai tidak berubah".** Saat semua field statis, angka tengah + berkedip ~1×/detik; saat nilai berubah (suhu naik 40→90) kedip hilang. Bukan efek + laju kirim — kedip tetap ada walau kita berhenti mengirim. +3. **Plafon laju ~8 report/detik.** Endpoint interrupt OUT `bInterval 160 ms`; setiap + `write()` hidraw menunggu URB selesai (~125 ms). Aplikasi asli menumpuk antrean dari + loop 500 µs (`QTimer.setInterval(10)` + `QThread.usleep(500)`), efektifnya sama. +4. **Selalu "streaming"**, tidak ada handshake/init: buka lalu tulis 64 byte. +5. **Slot RPM salah byte.** Diuji silang di hardware: kirim clock=4000 dan fan=1500 → + layar menampilkan 4000 di slot RPM. Jadi firmware memakai byte 4..5 untuk slot itu, + padahal aplikasi asli menaruh clock CPU di sana (bug pasangan app/firmware). + Implementasi pengganti menukar clock/fan agar layar menampilkan RPM fan sungguhan. +6. **Akurasi dibandingkan system monitor**: suhu cocok (selisih hanya pembulatan ke + integer), load cocok; keduanya tampil dengan delay beberapa detik akibat jendela + sampling dan laju device. + +## 7. Implementasi pengganti (jalan di host ini) + +`analysis/tools/gamemax_report.py` — Python murni, tanpa dependensi: +- auto-detect node hidraw dari VID:PID (`0x5131:0x2007`) +- baca sensor Linux: hwmon (CPU = maksimum Tctl/Tccd*, GPU, fan, power), `/proc/stat`, + cpufreq, `/proc/meminfo`, `statvfs("/")` +- bangun report 65 byte sesuai tabel di 6.4, tulis 64 byte payload ke hidraw +- `--dry-run` mencetak report + nilai; `--listen N` membaca pesan device→host + (device ini tidak pernah mengirim apa pun); `--count`, `--interval`, `--unit c|f`, + `--flag-celsius` (fidelitas ke aplikasi asli), override tiap field via `--cpu-temp` dll. +- `self_check()` mengunci header, urutan big-endian, dan semantik satuan + +`analysis/tools/70-gamemax.rules` — udev rule `TAG+="uaccess"` untuk node hidraw. +`analysis/tools/gamemax-report.service` — unit systemd **user** (padanan +`schtasks /sc onlogon` aplikasi asli), sudah aktif di mesin ini: + +```bash +systemctl --user status gamemax-report.service +journalctl --user -u gamemax-report -f +systemctl --user disable --now gamemax-report.service # matikan +``` + +Uji yang sudah lolos: dry-run + assert, kirim nyata, device menampilkan load/RPM/suhu +sesuai kiriman, dan tampilan mengikuti perubahan nilai. + +## 8. Device: GameMax IceBurg 240 Digital BK + +Ditemukan di mesin ini sebagai `Bus 001 Device 005: ID 5131:2007` (usb.ids salah +menamainya "MSR-101U card reader" — descriptor-nya vendor-defined 0xFF00, 64 byte IN ++ 64 byte OUT, tanpa report ID). Produknya: **GameMax IceBurg 240 Digital BK**, AIO +240 mm dengan blok LCD; "IceBurg 240 Digital Software" dari halaman produk +(gamemaxpc.com/cpu-liquid-cooler/1550.html) adalah `PcInfoMonitorPro.exe` yang kita +bongkar. Layar menampilkan halaman CPU (ikon CPU + load %, suhu besar, RPM, 6 titik +indikator halaman) dan menganimasikan busur kiri/kanan. + +Verifikasi: setelah `gamemax-report.service` aktif, device menampilkan suhu/load/RPM +dari sensor Linux, mengikuti perubahan nilai, dan berkedip hanya saat nilai statis +(perilaku firmware, disepakati dibiarkan apa adanya). diff --git a/packaging/70-gamemax.rules b/packaging/70-gamemax.rules new file mode 100644 index 0000000..31aed9f --- /dev/null +++ b/packaging/70-gamemax.rules @@ -0,0 +1,5 @@ +# Vendor-defined HID device used by GAMEMAX PcInfoMonitorPro (VID 0x5131, PID 0x2007). +# Grants the logged-in desktop user access to its hidraw node. +# Install: sudo cp 70-gamemax.rules /etc/udev/rules.d/ +# sudo udevadm control --reload && sudo udevadm trigger --subsystem-match=hidraw +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="5131", ATTRS{idProduct}=="2007", TAG+="uaccess" diff --git a/packaging/build_appimage.sh b/packaging/build_appimage.sh new file mode 100755 index 0000000..a06c52b --- /dev/null +++ b/packaging/build_appimage.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Build the GAMEMAX LCD tray app as an AppImage. +# +# bash packaging/build_appimage.sh [output_dir] +# +# Needs: uv (for the throwaway build venv) and network on first run. +# Produces dist/GAMEMAX-LCD-x86_64.AppImage with PySide6, the sender script and the icon. +set -euo pipefail + +PACKAGING_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$PACKAGING_DIR/.." && pwd)" +OUT_DIR="${1:-$ROOT_DIR/dist}" +BUILD_DIR="${BUILD_DIR:-/tmp/gamemax-appimage}" +VENV="${VENV:-/tmp/gamemax-appimage-venv}" +APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" + +mkdir -p "$OUT_DIR" "$BUILD_DIR" +cd "$BUILD_DIR" +cp "$ROOT_DIR/src/gamemax_gui.py" "$ROOT_DIR/src/gamemax_report.py" "$ROOT_DIR/src/icon.png" . + +# 1. throwaway venv with PySide6 + PyInstaller +if [ ! -x "$VENV/bin/pyinstaller" ]; then + uv venv "$VENV" + uv pip install --python "$VENV/bin/python" pyside6 pyinstaller +fi + +# 2. bundle the app (PySide6 + the sender script + the icon) +"$VENV/bin/pyinstaller" --noconfirm --windowed --name gamemax-lcd \ + --add-data "gamemax_report.py:." --add-data "icon.png:." gamemax_gui.py + +# 3. assemble the AppDir +APPDIR="$BUILD_DIR/AppDir" +rm -rf "$APPDIR" +mkdir -p "$APPDIR/usr/bin" +cp -r "$BUILD_DIR/dist/gamemax-lcd/." "$APPDIR/usr/bin/" +install -m 755 /dev/stdin "$APPDIR/AppRun" <<'EOF' +#!/bin/sh +HERE="$(dirname "$(readlink -f "$0")")" +exec "$HERE/usr/bin/gamemax-lcd" "$@" +EOF +cp "$ROOT_DIR/src/icon.png" "$APPDIR/gamemax-lcd.png" +cat > "$APPDIR/gamemax-lcd.desktop" <<'EOF' +[Desktop Entry] +Type=Application +Name=GAMEMAX IceBurg LCD +GenericName=AI LCD controller +Comment=Status, on/off, monitor, and settings for the GAMEMAX IceBurg AIO LCD +Exec=gamemax-lcd +Icon=gamemax-lcd +Terminal=false +Categories=System;Utility; +EOF + +# 4. package +if [ ! -x "$BUILD_DIR/appimagetool" ]; then + curl -fsSL "$APPIMAGETOOL_URL" -o "$BUILD_DIR/appimagetool" + chmod +x "$BUILD_DIR/appimagetool" +fi +OUT="$OUT_DIR/GAMEMAX-LCD-x86_64.AppImage" +ARCH=x86_64 "$BUILD_DIR/appimagetool" "$APPDIR" "$OUT" +chmod +x "$OUT" +echo "built: $OUT" +echo "verify: $OUT --selftest (or APPIMAGE_EXTRACT_AND_RUN=1 $OUT --selftest)" diff --git a/packaging/gamemax-report.service b/packaging/gamemax-report.service new file mode 100644 index 0000000..60914cf --- /dev/null +++ b/packaging/gamemax-report.service @@ -0,0 +1,13 @@ +[Unit] +Description=GAMEMAX HID sensor display bridge (PcInfoMonitorPro replacement) +Documentation=file:///mnt/SSD/Work/REVERSE-EGENERING/analysis/01-recon.md +After=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /mnt/SSD/Work/REVERSE-EGENERING/analysis/tools/gamemax_report.py --unit c --interval 0.5 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/src/gamemax_gui.py b/src/gamemax_gui.py new file mode 100755 index 0000000..368d232 --- /dev/null +++ b/src/gamemax_gui.py @@ -0,0 +1,804 @@ +#!/usr/bin/env python3 +"""GAMEMAX IceBurg LCD — GUI tray: status, nyala/mati, pemantauan beranimasi, pengaturan. + +Streaming tetap dijalankan oleh layanan systemd (gamemax-report.service); GUI ini +mengendalikannya dan memakai gamemax_report.py sebagai pengirim. + + gamemax_gui.py # jendela + tray (default) + gamemax_gui.py --tray-only # tanpa jendela + gamemax_gui.py --selftest # uji muat tanpa tampilan (untuk build AppImage) +""" +import json +import math +import os +import shutil +import subprocess +import sys +import threading +import time +import traceback + +from PySide6.QtCore import QPointF, QRectF, Qt, QThread, QTimer, Signal +from PySide6.QtGui import (QAction, QColor, QConicalGradient, QFont, QIcon, + QPainter, QPen, QPixmap) +from PySide6.QtWidgets import ( + QApplication, QCheckBox, QComboBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel, + QMenu, QPlainTextEdit, QProgressBar, QPushButton, QSystemTrayIcon, QVBoxLayout, QWidget, +) +from PySide6.QtNetwork import QLocalServer, QLocalSocket + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import gamemax_report as gr # noqa: E402 + +SERVICE = "gamemax-report.service" +CONFIG_PATH = gr.CONFIG_PATH +INSTALL_DIR = os.path.expanduser("~/.local/share/gamemax-report") +UNIT_PATH = os.path.expanduser(f"~/.config/systemd/user/{SERVICE}") +RULE_PATH = "/etc/udev/rules.d/70-gamemax.rules" + +UNIT_TEMPLATE = """[Unit] +Description=GAMEMAX HID sensor display bridge (PcInfoMonitorPro replacement) +After=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 {script} --wait --interval {interval} --sample {sample} +Restart=always +RestartSec=3 + +[Install] +WantedBy=default.target +""" + +RULE_TEMPLATE = ('SUBSYSTEM=="hidraw", ATTRS{idVendor}=="5131", ATTRS{idProduct}=="2007", ' + 'TAG+="uaccess"\n') + +CRASH_LOG = os.path.join(os.path.dirname(CONFIG_PATH), "gui-crash.log") +ACCENT = QColor(222, 247, 80) # warna aksen GAMEMAX dari appConfig.ini +DIM = QColor(120, 128, 120) + + +def crash_log(text): + """Catat galat ke berkas supaya bisa diperiksa setelah aplikasi berhenti.""" + try: + os.makedirs(os.path.dirname(CRASH_LOG), exist_ok=True) + with open(CRASH_LOG, "a") as fh: + fh.write(time.strftime("[%Y-%m-%d %H:%M:%S] ") + text.rstrip() + "\n") + except OSError: + pass + + +def systemctl(*args): + return subprocess.run(["systemctl", "--user", *args], capture_output=True, text=True) + + +def service_active(): + return systemctl("is-active", SERVICE).stdout.strip() == "active" + + +def service_enabled(): + return systemctl("is-enabled", SERVICE).stdout.strip() == "enabled" + + +def dimmed_icon(path, opacity=0.35): + """Ikon tray versi redup untuk menandai layar tidak menampilkan sensor.""" + icon = QIcon(path) + pixmap = icon.pixmap(64, 64) + if pixmap.isNull(): + return icon + dim = QPixmap(pixmap.size()) + dim.fill(Qt.transparent) + painter = QPainter(dim) + painter.setOpacity(opacity) + painter.drawPixmap(0, 0, pixmap) + painter.end() + return QIcon(dim) + + +def icon_path(): + for name in ("icon.png", "icon.ico", "gamemax.png"): + path = os.path.join(HERE, name) + if os.path.exists(path): + return path + return "" + + +class Task(QThread): + """Menjalankan aksi lambat (systemctl/pkexec/salin berkas) di luar thread UI. + + PENTING: method yang dijalankan di sini tidak boleh menyentuh widget Qt + (membaca combo/checkbox atau memanggil refresh()); ambil nilainya di thread UI + dan kirim sebagai argumen. + """ + + done = Signal(str, bool, str) + + def __init__(self, label, fn): + super().__init__() + self.label, self.fn = label, fn + + def run(self): + try: + self.done.emit(self.label, True, self.fn() or "") + except Exception as exc: # noqa: BLE001 + self.done.emit(self.label, False, str(exc)) + + +class Animated: + """Nilai yang bergerak halus menuju target (digerakkan QTimer 33 ms).""" + + def __init__(self, value=0.0): + self.shown = value + self.target = value + + def tick(self, ease=0.18): + if abs(self.target - self.shown) < 0.5: + self.shown = self.target + else: + self.shown += (self.target - self.shown) * ease + return self.shown + + +class Gauge(QWidget): + """Busur 240 derajat untuk suhu (0..99 °C), dengan jarum dan angka besar.""" + + def __init__(self, title, vmax=99, unit="°C"): + super().__init__() + self.title, self.vmax, self.unit = title, vmax, unit + self.value = Animated() + self.setMinimumSize(220, 220) + + def set(self, value): + self.value.target = 0.0 if value is None else min(self.vmax, value) + + def tick(self): + self.value.tick(0.25) + self.update() + + def paintEvent(self, _): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + w, h = self.width(), self.height() + side = min(w, h) - 12 + rect = QRectF((w - side) / 2, (h - side) / 2 + 6, side, side) + start, span = 210, -240 # derajat: kiri-bawah -> kanan-bawah + frac = max(0.0, min(1.0, self.value.shown / self.vmax)) + + p.setPen(QPen(QColor(58, 62, 58), 14, Qt.SolidLine, Qt.RoundCap)) + p.drawArc(rect, start * 16, span * 16) + if frac > 0: + grad = QConicalGradient(rect.center(), -60) + grad.setColorAt(0.0, ACCENT) + grad.setColorAt(0.5, QColor(255, 170, 60)) + grad.setColorAt(1.0, QColor(255, 90, 60)) + p.setPen(QPen(grad, 14, Qt.SolidLine, Qt.RoundCap)) + p.drawArc(rect, start * 16, int(span * frac * 16)) + + angle = math.radians(start + span * frac) + r = side / 2 - 8 + cx, cy = rect.center().x(), rect.center().y() + p.setPen(QPen(ACCENT, 5, Qt.SolidLine, Qt.RoundCap)) + p.drawLine(QPointF(cx, cy), QPointF(cx + r * math.cos(angle), cy - r * math.sin(angle))) + + p.setPen(ACCENT) + f = QFont(); f.setPointSize(max(22, side // 5)); f.setBold(True) + p.setFont(f) + p.drawText(QRectF(rect.x(), cy - side / 6, side, side / 3), Qt.AlignCenter, + f"{int(round(self.value.shown))}") + p.setPen(QColor(210, 214, 210)) + f.setPointSize(max(9, side // 14)); f.setBold(False) + p.setFont(f) + p.drawText(QRectF(rect.x(), cy + side / 8, side, side / 5), Qt.AlignCenter, + f"{self.unit} · {self.title}") + + +class Bar(QWidget): + """Bar horizontal beranimasi dengan label dan nilai.""" + + def __init__(self, title, vmax, unit=""): + super().__init__() + self.title, self.vmax, self.unit = title, vmax, unit + self.value = Animated() + self.setMinimumHeight(46) + + def set(self, value): + self.value.target = 0.0 if value is None else max(0.0, min(self.vmax, value)) + + def tick(self): + self.value.tick() + self.update() + + def paintEvent(self, _): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + w, h = self.width(), self.height() + f = QFont(); f.setPointSize(10) + p.setFont(f) + p.setPen(QColor(210, 214, 210)) + frac = self.value.shown / self.vmax + shown = int(round(self.value.shown)) + p.drawText(QRectF(0, 0, w, 20), Qt.AlignLeft, self.title) + p.setPen(ACCENT) + p.drawText(QRectF(0, 0, w, 20), Qt.AlignRight, f"{shown}{self.unit}") + track = QRectF(0, h - 18, w, 12) + p.setPen(Qt.NoPen) + p.setBrush(QColor(58, 62, 58)) + p.drawRoundedRect(track, 6, 6) + if frac > 0.001: + fill = QRectF(0, h - 18, max(6.0, w * frac), 12) + p.setBrush(ACCENT if frac < 0.8 else QColor(255, 120, 70)) + p.drawRoundedRect(fill, 6, 6) + + +class Window(QWidget): + def __init__(self, tray): + super().__init__() + self.tray = tray + self.prev_stat = gr.stat_snapshot() + self.setWindowTitle("GAMEMAX IceBurg LCD") + self.setWindowIcon(QIcon(icon_path())) + self.setMinimumWidth(640) + root = QVBoxLayout(self) + + hint = QLabel("Kontrol layar LCD AIO GAMEMAX. Pilih slot kipas/sumber suhu di " + "Pengaturan lalu klik Simpan & terapkan.") + hint.setWordWrap(True) + hint.setStyleSheet("color:#c8ccc8;") + root.addWidget(hint) + + # ---- banner mode layar (jelas: normal / kosong / berhenti) + self.mode_banner = QLabel() + self.mode_banner.setAlignment(Qt.AlignCenter) + self.mode_banner.setMinimumHeight(34) + root.addWidget(self.mode_banner) + + # ---- header status + self.status = QLabel() + self.status.setStyleSheet("font-size: 13px;") + root.addWidget(self.status) + + # ---- kontrol utama + controls = QHBoxLayout() + self.toggle = QPushButton("Aktifkan layar") + self.toggle.setMinimumHeight(38) + self.toggle.setStyleSheet("font-weight: bold;") + self.toggle.clicked.connect(lambda: self.start_task("mengubah status layanan", self.on_toggle)) + self.toggle.setToolTip("Mulai/hentikan pengiriman data sensor ke layar LCD") + controls.addWidget(self.toggle, 2) + self.blank = QCheckBox("Layar kosong") + self.blank.toggled.connect(self.on_blank_clicked) + self.blank.setToolTip("Kirim nilai nol ke layar (panel tetap beranimasi)") + controls.addWidget(self.blank) + test = QPushButton("Kirim uji") + test.setToolTip("Kirim satu report contoh: 55 °C, beban 50 %, kipas 1500 RPM") + test.clicked.connect(self.send_test) + controls.addWidget(test) + root.addLayout(controls) + + # indikator proses berjalan (aksi dijalankan di thread terpisah) + self.busy = QProgressBar() + self.busy.setRange(0, 0) # mode tak tentu -> animasi berjalan + self.busy.setTextVisible(False) + self.busy.setFixedHeight(10) + self.busy.hide() + root.addWidget(self.busy) + self.busy_label = QLabel() + self.busy_label.setStyleSheet("color:#deF750;") + self.busy_label.hide() + root.addWidget(self.busy_label) + self.buttons = [self.toggle, test] + + # ---- papan monitor beranimasi + board = QGroupBox("Pemantauan langsung") + self.board = board + bl = QHBoxLayout(board) + self.gauge = Gauge("CPU", 99, "°C") + bl.addWidget(self.gauge, 1) + bars = QVBoxLayout() + self.bar_load = Bar("Beban CPU", 100, " %") + self.bar_fan = Bar("Kipas CPU (slot RPM)", 2000, " RPM") + self.bar_clock = Bar("Frekuensi CPU", 6000, " MHz") + for b in (self.bar_load, self.bar_fan, self.bar_clock): + bars.addWidget(b) + self.info = QLabel() + self.info.setStyleSheet("color:#c8ccc8;") + bars.addWidget(self.info) + bars.addStretch(1) + bl.addLayout(bars, 2) + root.addWidget(board) + + # ---- pengaturan + settings = QGroupBox("Pengaturan") + form = QFormLayout(settings) + self.fan = QComboBox() + self.fan.setToolTip("Kanal kipas yang dikirim ke layar; pilih kanal yang berputar " + "kalau kipas dipindah ke header lain") + self.temp = QComboBox() + form.addRow("Slot kipas (RPM)", self.fan) + form.addRow("Sumber suhu CPU", self.temp) + self.autostart = QCheckBox("Mulai otomatis saat login") + self.autostart.toggled.connect( + lambda checked: self.start_task("mulai otomatis saat login", + lambda: self.on_autostart(checked))) + form.addRow("", self.autostart) + row = QHBoxLayout() + save = QPushButton("Simpan && terapkan") + save.clicked.connect(self.on_save_clicked) + row.addWidget(save) + install = QPushButton("Pasang / perbarui layanan") + install.clicked.connect(lambda: self.start_task("memasang layanan", self.install_service)) + row.addWidget(install) + rule = QPushButton("Pasang aturan udev") + rule.clicked.connect(lambda: self.start_task("memasang aturan udev", self.install_rule)) + row.addWidget(rule) + self.buttons += [save, install, rule] + form.addRow("", row) + root.addWidget(settings) + + # ---- detail + log + self.detail = QPlainTextEdit() + self.detail.setReadOnly(True) + self.detail.setFixedHeight(110) + root.addWidget(self.detail) + self.logview = QPlainTextEdit() + self.logview.setReadOnly(True) + self.logview.setFixedHeight(64) + root.addWidget(self.logview) + + self.reload_choices() + self.reload_config() + + # 33 ms -> animasi; 1000 ms -> baca sensor + self.anim = QTimer(self, timeout=self.tick, interval=33) + self.anim.start() + self.poll = QTimer(self, timeout=self.refresh, interval=1000) + self.poll.start() + self.refresh() + if gr.load_config(CONFIG_PATH).get("blank"): + self.log("PERHATIAN: mode layar kosong sedang aktif dari konfigurasi " + "(layar menampilkan nilai nol). Hilangkan centang 'Layar kosong' " + "untuk kembali ke nilai sensor.") + + # -------------------------------------------------------------- pengaturan + def reload_choices(self): + self.fan.clear() + self.fan.addItem("Otomatis (kipas tercepat yang berputar)", "") + for c in gr.hwmon_channels("fan*_input"): + rpm = "" if c["value"] is None else f" — {int(c['value'])} RPM" + self.fan.addItem(f"{c['chip']} {c['file']}{rpm}", f"{c['chip']}/{c['file']}") + self.temp.clear() + self.temp.addItem("Otomatis (sensor CPU tertinggi)", "") + channels = [c for c in gr.hwmon_channels("temp*_input") if c["value"] is not None] + channels.sort(key=lambda c: (c["chip"] not in gr.CPU_CHIPS, c["chip"], c["file"])) + for c in channels: + name = c["label"] or c["file"] + tag = "" if c["chip"] in gr.CPU_CHIPS else " (bukan CPU) " + self.temp.addItem(f"{c['chip']}{tag} {name} — {c['value'] / 1000:.1f} °C", + f"{c['chip']}/{c['file']}") + + def reload_config(self): + cfg = gr.load_config(CONFIG_PATH) + for combo, key in ((self.fan, "fan_channel"), (self.temp, "temp_channel")): + idx = combo.findData(cfg.get(key) or "") + combo.setCurrentIndex(idx if idx >= 0 else 0) + self.autostart.blockSignals(True) + self.autostart.setChecked(service_enabled()) + self.autostart.blockSignals(False) + + def config(self): + return { + "fan_channel": self.fan.currentData() or None, + "temp_channel": self.temp.currentData() or None, + "unit": "c", # panel hanya punya label °C + "interval": 0.5, + "sample": 0.5, + "vendor_layout": False, + "blank": bool(self.blank.isChecked()), + } + + # -------------------------------------------------------------- tugas latar + def start_task(self, label, fn): + if getattr(self, "_task", None) is not None and self._task.isRunning(): + self.log("masih ada proses berjalan — tunggu sampai indikator selesai") + return + self.busy_label.setText(f"Sedang bekerja: {label}…") + self.busy.show() + self.busy_label.show() + for button in self.buttons: + button.setEnabled(False) + task = Task(label, fn) + task.done.connect(self.on_task_done) + self._task = task + task.start() + + def on_task_done(self, label, ok, message): + self.busy.hide() + self.busy_label.hide() + for button in self.buttons: + button.setEnabled(True) + self.log(("selesai: " if ok else "GAGAL: ") + label + (f" — {message}" if message else "")) + self.refresh() + + # -------------------------------------------------------------- aksi + def on_toggle(self): + try: + self.ensure_unit() + want_active = not service_active() + for attempt in (1, 2): + systemctl("start" if want_active else "stop", SERVICE) + if service_active() == want_active: + return (f"layanan {'AKTIF' if want_active else 'nonaktif'}" + + ("" if attempt == 1 else f" (percobaan ke-{attempt})")) + else: + return "status layanan tidak berubah seperti yang diminta" + except Exception as exc: # noqa: BLE001 + return f"galat: {exc}" + + def on_autostart(self, checked): + try: + self.ensure_unit() + systemctl("daemon-reload") + res = systemctl("enable" if checked else "disable", SERVICE) + state = service_enabled() + return (f"mulai otomatis saat login: {'AKTIF' if state else 'nonaktif'}" + + ("" if res.returncode == 0 else f" ({res.stderr.strip()})")) + except Exception as exc: # noqa: BLE001 + return f"galat autostart: {exc}" + + def on_blank_clicked(self, checked): + """Dijalankan di thread UI: baca widget di sini, kerjakan sisanya di latar.""" + cfg = self.config() + cfg["blank"] = bool(checked) + self.start_task("mode layar kosong", lambda: self.on_blank(checked, cfg)) + + def on_blank(self, checked, cfg): + """Mode layar kosong ditangani pengirim (config "blank").""" + try: + self.write_config(cfg) + self.ensure_unit(cfg) + if checked and not service_active(): + systemctl("start", SERVICE) + elif service_active(): + systemctl("restart", SERVICE) + return ("layar kosong: AKTIF (report bernilai nol)" if checked + else "layar kosong: nonaktif (kembali ke nilai sensor)") + except Exception as exc: # noqa: BLE001 + return f"galat mode layar kosong: {exc}" + + def write_config(self, cfg): + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + with open(CONFIG_PATH, "w") as fh: + json.dump(cfg, fh, indent=2) + + def ensure_unit(self, cfg=None): + if not os.path.exists(UNIT_PATH): + self.install_service(cfg) + + def send_test(self): + node = gr.find_device() + if not node: + self.log("perangkat tidak terdeteksi") + return + v = gr.read_sensors(self.prev_stat, {"fan_channel": self.fan.currentData(), + "temp_channel": self.temp.currentData()}) + v.update({"cpu_temp": 55, "gpu_temp": 60, "cpu_load": 50, "cpu_fan": 1500, + "cpu_clock": 4000, "cpu_power": 66}) + try: + with open(node, "wb", buffering=0) as fh: + fh.write(gr.build_report(v, "c")[1:]) + self.log(f"report uji terkirim ke {node} (55 °C / 50 % / 1500 RPM)") + except OSError as exc: + self.log(f"gagal mengirim uji: {exc}") + + def install_service(self, cfg=None): + try: + os.makedirs(INSTALL_DIR, exist_ok=True) + script = os.path.join(INSTALL_DIR, "gamemax_report.py") + shutil.copyfile(os.path.join(HERE, "gamemax_report.py"), script) + os.makedirs(os.path.dirname(UNIT_PATH), exist_ok=True) + cfg = cfg or {} + with open(UNIT_PATH, "w") as fh: + fh.write(UNIT_TEMPLATE.format(script=script, + interval=cfg.get("interval", 0.5), + sample=cfg.get("sample", 0.5))) + systemctl("daemon-reload") + return f"layanan dipasang: {UNIT_PATH}" + except Exception as exc: # noqa: BLE001 + return f"gagal memasang layanan: {exc}" + + def install_rule(self): + script = "/tmp/gamemax-udev-install.sh" + try: + with open(script, "w") as fh: + fh.write("#!/bin/sh\n" + f"cat > {RULE_PATH} <<'EOF'\n{RULE_TEMPLATE}EOF\n" + "udevadm control --reload\n" + "udevadm trigger --subsystem-match=hidraw\n") + os.chmod(script, 0o755) + res = subprocess.run(["pkexec", "sh", script], capture_output=True, text=True) + return ("aturan udev dipasang" if res.returncode == 0 + else f"gagal memasang udev: {res.stderr.strip() or res.returncode}") + except FileNotFoundError: + return f"pkexec tidak tersedia; jalankan manual: sudo sh {script}" + except Exception as exc: # noqa: BLE001 + return f"galat udev: {exc}" + + def fix_fan_choice(self): + """Slot kipas terpilih yang diam sementara ada kipas lain berputar akan dialihkan.""" + if not self.fan.currentData(): + return "" + spinning = [(c["chip"], c["file"], int(c["value"])) for c in gr.hwmon_channels("fan*_input") + if c["value"] and c["chip"] not in gr.GPU_CHIPS] + if gr.channel_value(self.fan.currentData()) or not spinning: + return "" + chip, fname, rpm = max(spinning, key=lambda t: t[2]) + idx = self.fan.findData(f"{chip}/{fname}") + if idx < 0: + return "" + old = self.fan.currentText() + self.fan.setCurrentIndex(idx) + return f"slot lama membaca 0 RPM ({old.split(' —')[0]}) -> dialihkan ke {chip} {fname} ({rpm} RPM)" + + def on_save_clicked(self): + """Thread UI: koreksi slot kipas lalu baca pengaturan sebelum dikerjakan.""" + note = self.fix_fan_choice() + self.start_task("menyimpan pengaturan", + lambda: self.save_settings(self.config(), note)) + + def save_settings(self, cfg, note=""): + try: + self.write_config(cfg) + self.ensure_unit(cfg) + if service_active(): + systemctl("restart", SERVICE) + tail = " (layanan dijalankan ulang)" + else: + systemctl("start", SERVICE) + tail = " (layanan dinyalakan)" + return "pengaturan disimpan & diterapkan" + tail + (f" | {note}" if note else "") + except Exception as exc: # noqa: BLE001 + return f"gagal menyimpan: {exc}" + + def log(self, text): + self.logview.appendPlainText(text) + + def warn_once(self, text): + if getattr(self, "_warned", None) != text: + self._warned = text + self.log(text) + + # -------------------------------------------------------------- refresh + def refresh(self): + try: + active = service_active() + node = gr.find_device() + enabled = service_enabled() + blank_on = bool(gr.load_config(CONFIG_PATH).get("blank")) + mode = "normal" if active and not blank_on else ("kosong" if blank_on else "berhenti") + banner = { + "normal": ("Layar menampilkan data sensor", "#1d3b1d", "#b8f5b8"), + "kosong": ("LAYAR KOSONG — panel menampilkan nilai nol (animasi firmware tetap jalan)", + "#4a3a10", "#ffd479"), + "berhenti": ("PENGIRIMAN DIHENTIKAN — panel menampilkan data terakhir yang diterima", + "#3a3a3a", "#d0d0d0"), + }[mode] + self.mode_banner.setText(banner[0]) + self.board.setTitle({ + "normal": "Pemantauan langsung — nilai ini yang dikirim ke layar", + "kosong": "Pemantauan langsung — nilai sensor (yang DIKIRIM ke layar: nol)", + "berhenti": "Pemantauan langsung — nilai sensor (TIDAK dikirim, layanan mati)", + }[mode]) + self.mode_banner.setStyleSheet( + f"background:{banner[1]}; color:{banner[2]}; border-radius:4px; " + "font-weight:bold; padding:4px;") + if getattr(self, "_mode", None) != mode: + self._mode = mode + labels = {"normal": "Layar kembali menampilkan data sensor", + "kosong": "Layar dikosongkan (nilai nol)", + "berhenti": "Pengiriman data dihentikan"} + if mode != "normal" or getattr(self, "_mode_seen", False): + subprocess.run(["notify-send", "-a", "GAMEMAX LCD", "Status layar", + labels[mode]], capture_output=True) + self._mode_seen = True + dot = "🟢" if active else "⚪" + self.status.setText( + f"{dot} Perangkat: {node or 'tidak terdeteksi'}  ·  " + f"Layanan: {'AKTIF' if active else 'nonaktif'}  ·  " + f"Mulai otomatis: {'ya' if enabled else 'tidak'}" + + ("  ·  Layar: KOSONG (nilai nol)" + if blank_on else "")) + self.toggle.setText("Matikan layar" if active else "Aktifkan layar") + self.autostart.blockSignals(True) + self.autostart.setChecked(enabled) + self.autostart.blockSignals(False) + self.blank.blockSignals(True) + self.blank.setChecked(bool(gr.load_config(CONFIG_PATH).get("blank"))) + self.blank.blockSignals(False) + self.tray.sync(active) + + cfg = {"fan_channel": self.fan.currentData(), + "temp_channel": self.temp.currentData()} + v = gr.read_sensors(self.prev_stat, cfg) + self.prev_stat = gr.stat_snapshot() + self.gauge.set(v["cpu_temp"]) + self.bar_load.set(v["cpu_load"]) + self.bar_fan.set(v["cpu_fan"]) + spinning = [(c["chip"], c["file"], int(c["value"])) for c in gr.hwmon_channels("fan*_input") + if c["value"] and c["chip"] not in gr.GPU_CHIPS] + if self.fan.currentData() and not (v["cpu_fan"] or 0) and spinning: + best = max(spinning, key=lambda t: t[2]) + self.warn_once(f"slot kipas terpilih membaca 0 RPM; kipas berputar ada di " + f"{best[0]} {best[1]} ({best[2]} RPM) — pilih itu lalu Simpan") + self.bar_clock.set(v["cpu_clock"]) + self.detail.setPlainText( + (f"Daya CPU : {v['cpu_power']:.0f} W\n" if v["cpu_power"] else "Daya CPU : —\n") + + (f"GPU : {v['gpu_temp']:.0f} °C · beban {v['gpu_load'] or '—'} % · " + f"{int(v['gpu_clock'] or 0)} MHz\n" if v["gpu_temp"] else "GPU : —\n") + + (f"Memori : {v['ram_load']} % dari {int(v['ram_mb'])} MiB\n" + if v["ram_load"] and v["ram_mb"] else "Memori : —\n") + + (f"Penyimpanan : {v['disk_free']} % kosong dari {v['disk_gb']:.0f} GB\n" + if v["disk_free"] and v["disk_gb"] else "Penyimpanan : —\n") + + "Isi report : " + gr.build_report(v, "c").hex(" ")) + except Exception as exc: # noqa: BLE001 + self.log(f"galat pembaruan: {exc}") + if os.environ.get("GAMEMAX_DEBUG"): + self.log(traceback.format_exc()) + + def tick(self): + for widget in (self.gauge, self.bar_load, self.bar_fan, self.bar_clock): + widget.tick() + + +class Tray: + def __init__(self, app): + self.app = app + self.icon = QSystemTrayIcon(QIcon(icon_path()), app) + self.blank_action = QAction("Layar kosong (nilai nol)", app) + self.blank_action.setCheckable(True) + menu = QMenu() + show = QAction("Buka jendela", menu) + show.triggered.connect(self.show_window) + toggle = QAction("Aktifkan / matikan", menu) + toggle.triggered.connect(lambda: self.window.on_toggle()) + self.blank_action.triggered.connect(lambda checked: self.window.blank.setChecked(checked)) + quit_action = QAction("Keluar", menu) + quit_action.triggered.connect(app.quit) + menu.addAction(show) + menu.addAction(toggle) + menu.addAction(self.blank_action) + menu.addSeparator() + menu.addAction(quit_action) + self.icon.setContextMenu(menu) + self.icon.activated.connect( + lambda reason: self.show_window() if reason == QSystemTrayIcon.Trigger else None) + self.window = Window(self) + self.icon.show() + + def sync(self, active): + blank = bool(gr.load_config(CONFIG_PATH).get("blank")) + state = ("layar kosong (nilai nol)" if blank and active + else ("pengiriman dihentikan" if not active else "normal")) + self.icon.setToolTip(f"GAMEMAX LCD: {state}") + base = icon_path() + self.icon.setIcon(dimmed_icon(base) if state != "normal" else QIcon(base)) + window = getattr(self, "window", None) + if window is not None: + self.blank_action.setChecked(window.blank.isChecked()) + + def show_window(self): + self.window.showNormal() # keluar dari minimize + self.window.setWindowState((self.window.windowState() & ~Qt.WindowMinimized) + | Qt.WindowActive) + self.window.raise_() + self.window.activateWindow() + if QSystemTrayIcon.isSystemTrayAvailable(): + self.icon.showMessage("GAMEMAX LCD", "Jendela dibuka dari permintaan kedua.", + QSystemTrayIcon.Information, 3000) + + +SOCKET_NAME = "gamemax-lcd-" + os.environ.get("USER", "user") + + +def main(): + try: + return _main() + except Exception: # noqa: BLE001 + crash_log("FATAL\n" + traceback.format_exc()) + raise + + +def _main(): + app = QApplication(sys.argv) + app.setApplicationName("GAMEMAX LCD") + app.setQuitOnLastWindowClosed(False) + + # hanya satu instance: yang kedua memunculkan jendela yang sudah ada lalu keluar + probe = QLocalSocket() + probe.connectToServer(SOCKET_NAME) + if probe.waitForConnected(400): + probe.write(b"raise") + probe.waitForBytesWritten(400) + print("GAMEMAX LCD sudah berjalan — instance pertama diminta membuka jendelanya.") + # Beri tahu lewat notifikasi desktop juga, karena keluarnya proses kedua bisa + # terlihat seperti crash saat diluncurkan dari menu. + subprocess.run(["notify-send", "-a", "GAMEMAX LCD", "Sudah berjalan", + "Aplikasi sudah aktif — jendelanya dibuka, cek taskbar/tray."], + capture_output=True) + return 0 + QLocalServer.removeServer(SOCKET_NAME) + server = QLocalServer() + server.listen(SOCKET_NAME) + + tray = Tray(app) + server.newConnection.connect(lambda: tray.show_window()) + app.aboutToQuit.connect(lambda: (server.close(), QLocalServer.removeServer(SOCKET_NAME))) + # Jendela selalu dibuka saat diluncurkan; tray tetap ada kalau didukung desktop. + tray.show_window() + if "--tray-only" in sys.argv: + tray.window.hide() + if not QSystemTrayIcon.isSystemTrayAvailable(): + tray.window.log("Tray tidak tersedia di desktop ini — pakai jendela ini untuk kontrol.") + def report_exception(*tb): + text = "".join(traceback.format_exception(*tb)).strip() + crash_log(text) + try: + tray.window.log("GALAT: " + text.splitlines()[-1]) + except Exception: # noqa: BLE001 + pass + + sys.excepthook = report_exception + threading.excepthook = lambda args: report_exception(args.exc_type, args.exc_value, args.exc_traceback) + if "--selftest-actions" in sys.argv: # uji aksi GUI pada bundel beku + w = tray.window + steps = (("simpan & terapkan", lambda: w.save_settings(w.config())), + ("layar kosong ON", lambda: w.on_blank(True, {**w.config(), "blank": True})), + ("layar kosong OFF", lambda: w.on_blank(False, {**w.config(), "blank": False})), + ("matikan layar", w.on_toggle), + ("nyalakan layar", w.on_toggle), + ("autostart ON", lambda: w.on_autostart(True)), + ("kirim uji", w.send_test), + ("pasang layanan", lambda: w.install_service(w.config()))) + for name, fn in steps: + try: + fn() + print(f"[ OK ] {name}") + except Exception as exc: # noqa: BLE001 + print(f"[GAGAL] {name}: {exc}") + # jalur asinkron yang dipakai tombol: centang layar kosong + simpan + def wait_task(seconds=10): + t0 = time.time() + while time.time() - t0 < seconds: + task = getattr(w, "_task", None) + if task is not None and not task.isRunning(): + break + app.processEvents() + time.sleep(0.05) + for _ in range(10): + app.processEvents() + time.sleep(0.02) + + for name, trigger in (("centang layar kosong", lambda: w.blank.setChecked(True)), + ("lepas centang", lambda: w.blank.setChecked(False)), + ("simpan lewat tombol", w.on_save_clicked)): + try: + trigger() + wait_task() + print(f"[ OK ] {name} (asinkron)") + except Exception as exc: # noqa: BLE001 + print(f"[GAGAL] {name} (asinkron): {exc}") + w.refresh() + print("log:", " | ".join(w.logview.toPlainText().splitlines()[-6:])) + return 0 + if "--selftest" in sys.argv: + QTimer.singleShot(1200, app.quit) + rc = app.exec() + if "--selftest" in sys.argv: + print(f"selftest OK (kipas={tray.window.fan.count()} suhu={tray.window.temp.count()})") + return rc + + +if __name__ == "__main__": + if "--selftest" in sys.argv: + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + sys.exit(main()) diff --git a/src/gamemax_report.py b/src/gamemax_report.py new file mode 100755 index 0000000..4d2fc8f --- /dev/null +++ b/src/gamemax_report.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +"""Push hardware sensor data to a GAMEMAX AIO LCD (HID VID 0x5131, PID 0x2007). + +Reverse-engineered from PcInfoMonitorPro.exe (GAMEMAX V1.0.0.04) — see ../01-recon.md. +Report layout, derived from DMainWidget.onGetPcInfoOk: + + byte 0 report ID, always 0 + byte 1 64 (0x40), payload length + byte 2 CPU temp (0..127). The original app ORs 0x80 for Celsius, but this + firmware ignores every value >= 128, so we send plain numbers + byte 3 CPU load % + byte 4..5 RPM slot (big-endian u16) -- panel renders this field next to the fan icon + byte 6..7 CPU clock MHz (big-endian u16) -- original app's byte order + byte 8..9 CPU power W (big-endian u16) + byte 10 GPU temp (0..127), same rule as CPU temp + byte 11 GPU load % + byte 12..13 GPU clock MHz (big-endian u16) + byte 14..15 GPU fan RPM (big-endian u16) + byte 16..17 GPU power W (big-endian u16) + byte 18 RAM usage % + byte 19..20 total RAM MiB (big-endian u16) + byte 21 disk free % + byte 22..23 total disk GB (big-endian u16) + byte 24..64 zero + +The panel reads byte 4..5 for the "RPM" field while the original app puts the CPU +clock there, so by default the two values are swapped so the panel shows a real fan +speed; --vendor-layout restores the original placement. + +Observed ceiling: the interrupt OUT endpoint (bInterval 160 ms) accepts about +8 reports/s and each hidraw write blocks until its URB completes. + +Plug and play: with --wait the process stays alive, waits for the device to appear +and keeps streaming through unplug/replug, which is what the systemd unit uses. + + python gamemax_report.py --dry-run --cpu-temp 55 + python gamemax_report.py --wait # service mode + python gamemax_report.py --list-sensors # JSON for the GUI +""" +import argparse +import glob +import json +import os +import select +import signal +import sys +import threading +import time + +VID, PID = 0x5131, 0x2007 +REPORT_SIZE = 65 +CONFIG_PATH = os.path.expanduser("~/.config/gamemax-report/config.json") +VALUE_KEYS = ("cpu_temp", "gpu_temp", "cpu_load", "cpu_clock", "cpu_fan", "cpu_power", + "gpu_load", "gpu_clock", "gpu_fan", "gpu_power", + "ram_load", "ram_mb", "disk_free", "disk_gb") +CONFIG_KEYS = ("fan_channel", "temp_channel", "unit", "interval", "sample", + "flag_celsius", "vendor_layout", "disk_path", "blank") + + +def load_config(path=CONFIG_PATH): + try: + with open(path) as fh: + data = json.load(fh) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def u16(value): + v = max(0, min(0xFFFF, int(value or 0))) + return bytes((v >> 8, v & 0xFF)) + + +def temp_byte(value, unit, flag_celsius=False): + """Wire values are Celsius 0..127. + + The original app ORs 0x80 when the UI unit is Celsius; the IceBurg panel rejects + values >= 128 (it keeps showing the last accepted one), so we only do that when + --flag-celsius is requested. --unit f converts the supplied Fahrenheit values. + """ + if value is None: + return 0 + # The panel shows at most two digits and its unit label is fixed to Celsius, so + # values above 99 are clamped (verified on hardware); --unit f sends the number + # as-is for fidelity with the original app, but the panel still labels it °C. + v = max(0, min(99, int(round(value)))) + return v | 0x80 if flag_celsius else v + + +def build_report(v, unit, flag_celsius=False, vendor_layout=False): + r = bytearray(REPORT_SIZE) + clock, fan = v.get("cpu_clock"), v.get("cpu_fan") + if not vendor_layout: # this panel renders byte 4..5 in the slot labelled RPM + clock, fan = fan, clock + r[0] = 0 + r[1] = 64 + r[2] = temp_byte(v.get("cpu_temp"), unit, flag_celsius) + r[3] = int(v.get("cpu_load") or 0) & 0xFF + r[4:6] = u16(clock) + r[6:8] = u16(fan) + r[8:10] = u16(v.get("cpu_power")) + r[10] = temp_byte(v.get("gpu_temp"), unit, flag_celsius) + r[11] = int(v.get("gpu_load") or 0) & 0xFF + r[12:14] = u16(v.get("gpu_clock")) + r[14:16] = u16(v.get("gpu_fan")) + r[16:18] = u16(v.get("gpu_power")) + r[18] = int(v.get("ram_load") or 0) & 0xFF + r[19:21] = u16(v.get("ram_mb")) + r[21] = int(v.get("disk_free") or 0) & 0xFF + r[22:24] = u16(v.get("disk_gb")) + return bytes(r) + + +def self_check(): + """Lock the protocol shape the original enforces.""" + r = build_report({"cpu_temp": 60, "gpu_temp": 70, "cpu_clock": 5000, "cpu_fan": 1200, + "cpu_power": 88, "gpu_power": 250, "ram_load": 42, "ram_mb": 32768, + "disk_free": 30, "disk_gb": 1951}, "c") + assert len(r) == REPORT_SIZE, len(r) + assert r[0] == 0 and r[1] == 64, r[:2].hex() + assert r[2] == 60 and r[10] == 70, (r[2], r[10]) # plain Celsius, no flag + flagged = build_report({"cpu_temp": 60}, "c", flag_celsius=True) + assert flagged[2] == 0xBC, hex(flagged[2]) # original app's byte + assert r[4:6] == b"\x04\xb0" and r[6:8] == b"\x13\x88" # fan in RPM slot, BE + vendor = build_report({"cpu_clock": 5000, "cpu_fan": 1200}, "c", vendor_layout=True) + assert vendor[4:6] == b"\x13\x88" and vendor[6:8] == b"\x04\xb0" # original placement + assert r[8:10] == b"\x00\x58" and r[16:18] == b"\x00\xfa" + assert r[18] == 42 and r[19:21] == b"\x80\x00" # 32768 MiB + assert r[21] == 30 and r[22:24] == b"\x07\x9f" # 1951 GB + assert r[24:] == bytes(41) + assert build_report({"cpu_temp": 100}, "f")[2] == 99 # clamped to two digits + assert build_report({"cpu_temp": 77}, "f")[2] == 77 # Fahrenheit sent as-is + return r + + +# ---------------------------------------------------------------- hwmon access + +def hwmon_read(path, scale=1.0): + try: + with open(path) as fh: + return float(fh.read().strip()) / scale + except (OSError, ValueError): + return None + + +def hwmon_channels(pattern="fan*_input"): + """List every hwmon channel as {"chip", "file", "label", "value"}.""" + out = [] + for path in sorted(glob.glob(f"/sys/class/hwmon/hwmon*/{pattern}")): + base, fname = os.path.dirname(path), os.path.basename(path) + try: + chip = open(os.path.join(base, "name")).read().strip() + except OSError: + continue + label = None + try: + label = open(os.path.join(base, fname.replace("_input", "_label"))).read().strip() + except OSError: + pass + out.append({"chip": chip, "file": fname, "label": label, + "value": hwmon_read(path)}) + return out + + +def channel_value(spec, scale=1.0): + """Read an explicit channel like "nct6798/fan2_input".""" + if not spec or "/" not in spec: + return None + chip, fname = spec.split("/", 1) + for path in glob.glob(f"/sys/class/hwmon/hwmon*/{fname}"): + try: + if open(os.path.join(os.path.dirname(path), "name")).read().strip() != chip: + continue + except OSError: + continue + return hwmon_read(path, scale) + return None + + +def hwmon_max(chips, pattern="temp*_input", scale=1.0): + """Highest matching reading; a CPU exposes Tctl plus per-CCD Tccd sensors.""" + best = None + for ch in hwmon_channels(pattern): + if not any(c == "*" or c in ch["chip"] for c in chips): + continue + if ch["value"] is None: + continue + value = ch["value"] / scale + if best is None or value > best: + best = value + return best + + +CPU_CHIPS = ["k10temp", "coretemp", "zenpower", "cpu_thermal"] +GPU_CHIPS = ["amdgpu", "nouveau", "i915"] + + +def cpu_temp(cfg): + scale = 1000.0 + explicit = channel_value(cfg.get("temp_channel"), scale) + if explicit is not None: + return explicit + return (hwmon_max(CPU_CHIPS, "temp*_input", scale) + or hwmon_max(CPU_CHIPS, "temp1_input", scale)) + + +def cpu_fan(cfg): + explicit = channel_value(cfg.get("fan_channel")) + if explicit is not None: + return explicit + fans = [c["value"] for c in hwmon_channels("fan*_input") + if c["value"] and c["chip"] not in GPU_CHIPS] + return max(fans) if fans else 0 + + +def meminfo(key): + try: + with open("/proc/meminfo") as fh: + for line in fh: + if line.startswith(key): + return float(line.split()[1]) # kB + except OSError: + pass + return None + + +def stat_snapshot(): + with open("/proc/stat") as fh: + p = [int(x) for x in fh.readline().split()[1:]] + return p[3] + p[4], sum(p) + + +def load_pct(prev, now): + idle, total = prev + idle2, total2 = now + return None if total2 == total else round(100 * (1 - (idle2 - idle) / (total2 - total))) + + +def read_sensors(prev=None, cfg=None): + cfg = cfg or {} + total_kb, avail_kb = meminfo("MemTotal"), meminfo("MemAvailable") + try: + st = os.statvfs(cfg.get("disk_path") or "/") + disk_free = round(100 * st.f_bavail / st.f_blocks) if st.f_blocks else None + disk_gb = st.f_blocks * st.f_frsize / 2**30 + except OSError: + disk_free = disk_gb = None + return { + "cpu_temp": cpu_temp(cfg), # Celsius; temp_byte handles the unit + "gpu_temp": hwmon_max(GPU_CHIPS, "temp*_input", 1000.0), + "cpu_load": None if prev is None else load_pct(prev, stat_snapshot()), + "cpu_clock": hwmon_read("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq", 1000.0), + "cpu_fan": cpu_fan(cfg), + "cpu_power": hwmon_max(CPU_CHIPS, "power*_input", 1e6), + "gpu_clock": (hwmon_max(GPU_CHIPS, "freq*_input", 1e6) + or hwmon_read("/sys/class/drm/card0/device/pp_dpm_sclk")), + "gpu_fan": hwmon_max(GPU_CHIPS, "fan*_input") or 0, + "gpu_load": hwmon_max(GPU_CHIPS, "gpu_busy_percent"), + "gpu_power": hwmon_max(GPU_CHIPS, "power*_input", 1e6), + "ram_load": None if not (total_kb and avail_kb) else round(100 * (1 - avail_kb / total_kb)), + "ram_mb": None if not total_kb else total_kb / 1024, + "disk_free": disk_free, + "disk_gb": disk_gb, + } + + +class Sampler(threading.Thread): + """Refresh sensor values in the background so the send cadence stays independent.""" + + def __init__(self, interval, cfg=None): + super().__init__(daemon=True) + self.interval = interval + self.cfg = cfg or {} + self.values = {} + self.lock = threading.Lock() + self.stop = threading.Event() + self.prev = stat_snapshot() + + def run(self): + while not self.stop.is_set(): + now = stat_snapshot() + v = read_sensors(self.prev, self.cfg) + self.prev = now + with self.lock: + self.values = v + self.stop.wait(self.interval) + + def snapshot(self): + with self.lock: + return dict(self.values) + + +def find_device(): + """Return the hidraw node whose HID_ID reports VID:PID 5131:2007.""" + want = f"{VID:08X}:{PID:08X}" + for uevent in sorted(glob.glob("/sys/class/hidraw/hidraw*/device/uevent")): + try: + text = open(uevent).read() + except OSError: + continue + if want in text.upper(): + return "/dev/" + uevent.split("/")[4] + return None + + +def stream(a, overrides): + """Open the device and stream reports. Returns (sent, seconds).""" + node = a.device or find_device() + if not node: + return None + fd = os.open(node, os.O_RDWR) + print(f"terbuka {node} ({VID:#06x}:{PID:#06x})", flush=True) + sampler = Sampler(a.sample, {"fan_channel": a.fan_channel, "temp_channel": a.temp_channel}) + sampler.start() + time.sleep(min(a.sample, 0.5)) # wait for the first sample + sent, t0 = 0, time.time() + try: + while a.count == 0 or sent < a.count: + v = {} if a.blank else sampler.snapshot() + v.update(overrides) + os.write(fd, build_report(v, a.unit, a.flag_celsius, a.vendor_layout)[1:]) + sent += 1 + time.sleep(a.interval) + finally: + sampler.stop.set() + os.close(fd) + return sent, time.time() - t0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--dry-run", action="store_true", help="print the report instead of sending it") + ap.add_argument("--listen", type=float, metavar="SECONDS", + help="passively print device->host reports for this long") + ap.add_argument("--device", help="hidraw node; default: auto-detect by VID:PID") + ap.add_argument("--unit", choices=["c", "f"], help="unit of the values you supply") + ap.add_argument("--interval", type=float, help="seconds between reports (device caps at ~8/s)") + ap.add_argument("--sample", type=float, help="sensor refresh interval") + ap.add_argument("--count", type=int, default=0, help="stop after N reports (0 = run until Ctrl-C)") + ap.add_argument("--flag-celsius", action="store_true", default=None, + help="emit the original app's 0x80 Celsius flag (this firmware ignores it)") + ap.add_argument("--vendor-layout", action="store_true", default=None, + help="original app's byte order (clock lands in the panel's RPM slot)") + ap.add_argument("--fan-channel", help='explicit fan channel, e.g. "nct6798/fan2_input"') + ap.add_argument("--temp-channel", help='explicit temp channel, e.g. "k10temp/temp3_input"') + ap.add_argument("--blank", action="store_true", default=None, + help="kirim report bernilai nol (mode 'layar kosong')") + ap.add_argument("--wait", action="store_true", + help="stay alive and wait for the device (plug and play, service mode)") + ap.add_argument("--list-sensors", action="store_true", + help="print available fan/temp channels as JSON and exit") + ap.add_argument("--config", default=CONFIG_PATH, help="JSON config path") + for key in VALUE_KEYS: + ap.add_argument(f"--{key.replace('_', '-')}", type=float, default=None) + a = ap.parse_args() + + if a.list_sensors: + print(json.dumps({ + "fans": hwmon_channels("fan*_input"), + "temps": hwmon_channels("temp*_input"), + "cpu_chips": CPU_CHIPS, + "gpu_chips": GPU_CHIPS, + "device": find_device(), + "config": load_config(a.config), + }, indent=2)) + return 0 + + print("pemeriksaan format:", self_check().hex(" "), flush=True) + + # config provides defaults; explicit flags win + cfg = load_config(a.config) + for key in CONFIG_KEYS: + if getattr(a, key, None) is None and cfg.get(key) is not None: + setattr(a, key, cfg[key]) + a.unit = a.unit or "c" + a.interval = 0.5 if a.interval is None else a.interval + a.sample = 0.5 if a.sample is None else a.sample + a.flag_celsius = bool(a.flag_celsius) + a.vendor_layout = bool(a.vendor_layout) + a.blank = bool(a.blank) + + overrides = {k: getattr(a, k) for k in VALUE_KEYS if getattr(a, k) is not None} + + if a.dry_run: + v = {} if a.blank else read_sensors(stat_snapshot(), {"fan_channel": a.fan_channel, + "temp_channel": a.temp_channel}) + v.update(overrides) + print("nilai:", {k: (round(x, 1) if isinstance(x, float) else x) + for k, x in sorted(v.items())}) + print("isi report:", build_report(v, a.unit, a.flag_celsius, a.vendor_layout).hex(" ")) + return 0 + + if a.listen: + node = a.device or find_device() + if not node: + print(f"node hidraw untuk {VID:#06x}:{PID:#06x} tidak ditemukan", file=sys.stderr) + return 1 + fd = os.open(node, os.O_RDONLY | os.O_NONBLOCK) + print(f"mendengarkan di {node} selama {a.listen} detik") + deadline, seen = time.time() + a.listen, 0 + try: + while time.time() < deadline: + if select.select([fd], [], [], 0.3)[0]: + seen += 1 + print(f" report {seen}: {os.read(fd, 64).hex(' ')}") + finally: + os.close(fd) + print(f"report diterima: {seen}") + return 0 + + if not a.wait: + result = stream(a, overrides) + if result is None: + print(f"node hidraw untuk {VID:#06x}:{PID:#06x} tidak ditemukan", file=sys.stderr) + return 1 + sent, dt = result + print(f"terkirim {sent} report ({sent / dt:.1f}/detik)") + return 0 + + # plug and play: wait for the device, stream, and go back to waiting on unplug + stop = threading.Event() + signal.signal(signal.SIGTERM, lambda *_: stop.set()) + signal.signal(signal.SIGINT, lambda *_: stop.set()) + print(f"menunggu {VID:#06x}:{PID:#06x} (Ctrl-C atau SIGTERM untuk berhenti)", flush=True) + while not stop.is_set(): + if not find_device(): + stop.wait(2) + continue + try: + result = stream(a, overrides) + if result: + sent, dt = result + print(f"perangkat terlepas setelah {sent} report ({sent / dt:.1f}/detik), menunggu lagi", flush=True) + except OSError as exc: + print(f"galat perangkat ({exc}), menunggu lagi", flush=True) + stop.wait(2) + print("berhenti") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/icon.png b/src/icon.png new file mode 100644 index 0000000..d1cd797 Binary files /dev/null and b/src/icon.png differ diff --git a/tools/ildump.py b/tools/ildump.py new file mode 100755 index 0000000..a5c61be --- /dev/null +++ b/tools/ildump.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Dump MSIL of methods in a .NET/mixed-mode assembly with resolved operands. + +Usage: + ildump.py list + ildump.py dump + ildump.py callers +""" +import re +import sys + +import dnfile +from dncil.cil.body.reader import read_method_body_from_bytes + +TBL = {0x01: "TypeRef", 0x02: "TypeDef", 0x04: "Field", 0x06: "MethodDef", + 0x0A: "MemberRef", 0x1B: "TypeSpec", 0x2B: "MethodSpec", 0x70: "String"} +CALLISH = {"call", "callvirt", "ldftn", "newobj", "ldvirtftn", "jmp"} + + +def type_name(pe, row): + if row is None: + return "?" + for attr in ("TypeName", "Name"): + if hasattr(row, attr) and getattr(row, attr): + ns = getattr(row, "TypeNamespace", "") or "" + return str(f"{ns}.{getattr(row, attr)}").lstrip(".") + return "?" + + +def build_maps(pe): + md = pe.net.mdtables + names = {} + for i, row in enumerate(md.MethodDef.rows): + names[0x06000000 | (i + 1)] = str(row.Name) + for i, row in enumerate(md.MemberRef.rows): + parent = "?" + try: + parent = type_name(pe, row.Class.row) if row.Class else "?" + except Exception: + pass + names[0x0A000000 | (i + 1)] = f"{parent}::{str(row.Name)}" + for i, row in enumerate(md.Field.rows): + names[0x04000000 | (i + 1)] = str(row.Name) + for tbl, base in (("TypeDef", 0x02000000), ("TypeRef", 0x01000000)): + for i, row in enumerate(getattr(md, tbl).rows): + names[base | (i + 1)] = type_name(pe, row) + return names + + +def decode(pe, row, maps): + raw = pe.get_data(row.Rva, 12 + 65536) + body = read_method_body_from_bytes(raw) + out = [] + for ins in body.instructions: + op = getattr(ins.opcode, "name", str(ins.opcode)).lower() + operand = ins.operand + val = getattr(operand, "value", operand) + text = "" + if isinstance(val, int) and val > 0x02000000: + text = maps.get(val, f"0x{val:08x}") + if val >> 24 == 0x70: + try: + text = repr(pe.net.user_strings.get(val & 0xFFFFFF)) + except Exception: + text = "" + elif val is not None: + text = str(val) + out.append((ins.offset, op, text)) + return out + + +def main(): + path, cmd, pattern = sys.argv[1], sys.argv[2], sys.argv[3] + pe = dnfile.dnPE(path) + maps = build_maps(pe) + rx = re.compile(pattern) + md = pe.net.mdtables + rows = [(0x06000000 | (i + 1), row) for i, row in enumerate(md.MethodDef.rows)] + targets = [(t, r) for t, r in rows if rx.search(str(r.Name))] + if cmd == "list": + for t, r in targets: + print(f"{t:08x} {str(r.Name)}") + return + if cmd == "callers": + for t, r in rows: + try: + for off, op, text in decode(pe, r, maps): + if op in CALLISH and rx.search(text): + print(f"{str(r.Name)} @il+{off}: {op} {text}") + except Exception: + continue + return + for t, r in targets: + print(f"=== {t:08x} {str(r.Name)}") + try: + for off, op, text in decode(pe, r, maps): + print(f" +{off:4} {op:16} {text}") + except Exception as e: + print(f" ") + + +if __name__ == "__main__": + main() diff --git a/tools/string_refs.py b/tools/string_refs.py new file mode 100755 index 0000000..5fb383c --- /dev/null +++ b/tools/string_refs.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Find which IL methods reference a string stored in the native data section. + +Resolves: string bytes -> RVA -> FieldRva row -> Field token -> IL instructions. +Usage: string_refs.py [--context N] +""" +import re +import struct +import sys + +import dnfile + +CALLISH = {"call", "callvirt", "ldftn", "newobj", "ldsflda", "ldsfld", "ldstr", "ldtoken"} + + +def rva_of_offset(pe, off): + for s in pe.sections: + start = s.PointerToRawData + if start <= off < start + s.SizeOfRawData: + return s.VirtualAddress + (off - start) + return None + + +def main(): + path, needle = sys.argv[1], sys.argv[2] + data = open(path, "rb").read() + pe = dnfile.dnPE(path) + hits = [] + for enc in ("ascii", "utf-16-le"): + b = needle.encode(enc) + hits += [m.start() for m in re.finditer(re.escape(b), data)] + rvas = {rva_of_offset(pe, h) for h in hits} - {None} + print(f"needle {needle!r}: {len(hits)} occurrences, rvas: {[hex(r) for r in rvas]}") + + fields = {} # field token -> FieldRva rva + for i, row in enumerate(pe.net.mdtables.FieldRva.rows): + fields[0x04000000 | (i + 1)] = row.Rva + matching = {t: r for t, r in fields.items() if r in rvas} + print("matching field tokens:", {hex(t): hex(r) for t, r in matching.items()}) + + names = {} + for i, row in enumerate(pe.net.mdtables.Field.rows): + names[0x04000000 | (i + 1)] = str(row.Name) + # any string whose RVA sits inside the string's own blob (same field) + related = set(matching) + for t, r in fields.items(): + if any(r <= rv < r + len(needle) + 64 for rv in rvas): + related.add(t) + print("related fields:", {hex(t): names[t] for t in sorted(related)}) + + from dncil.cil.body.reader import read_method_body_from_bytes + maps = {} + for i, row in enumerate(pe.net.mdtables.MethodDef.rows): + maps[0x06000000 | (i + 1)] = str(row.Name) + for i, row in enumerate(pe.net.mdtables.MemberRef.rows): + maps[0x0A000000 | (i + 1)] = str(row.Name) + + for i, row in enumerate(pe.net.mdtables.MethodDef.rows): + try: + raw = pe.get_data(row.Rva, 12 + 65536) + body = read_method_body_from_bytes(raw) + except Exception: + continue + insns = body.instructions + for j, ins in enumerate(insns): + op = getattr(ins.opcode, "name", str(ins.opcode)).lower() + if op not in CALLISH: + continue + val = getattr(ins.operand, "value", ins.operand) + if val in related: + print(f"\n{str(row.Name)} @ +{ins.offset}: {op} {names.get(val, val)}") + for k in insns[max(0, j - 6):j + 10]: + kop = getattr(k.opcode, "name", "?").lower() + kval = getattr(k.operand, "value", k.operand) + txt = maps.get(kval, kval) if isinstance(kval, int) and kval > 0x02000000 else (kval if kval is not None else "") + mark = " <<<" if k is ins else "" + print(f" +{k.offset:6} {kop:14} {txt}{mark}") + + +if __name__ == "__main__": + main()