GAMEMAX IceBurg LCD Linux bridge: sender + tray app

Reverse-engineered Linux driver for the LCD panel of the GAMEMAX IceBurg
Digital AIO cooler (HID 5131:2007). Includes:

- src/gamemax_report.py  pure-stdlib sender: reads hwmon/proc sensors and
  streams the 65-byte HID report the firmware understands (no deps)
- src/gamemax_gui.py     PySide6 tray app: animated monitor, fan/temperature
  channel pickers, autostart, blank mode, single instance, crash log
- packaging/             AppImage build script, udev rule, systemd user unit
- tools/                 IL dump helpers used to read the vendor MSIL
- docs/                  full reverse-engineering analysis
- DISCLAIMER.md          Linux-compatibility project, not affiliated with vendor

Co-authored-by: REA (managed-code analysis)
This commit is contained in:
oxpoeandREA (managed-code analysis) committed 2026-10-13 06:26:52 +07:00
1 parent b083919692
commit f58a011f4d
15 files changed
+1893

No files matched your search

+15
View File
@@ -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
+39
View File
@@ -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.
+123
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+204
View File
@@ -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 `<Module>`.
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=`<br>
`/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).
+5
View File
@@ -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"
+63
View File
@@ -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)"
+13
View File
@@ -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
+804
View File
@@ -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 "
"<b>Pengaturan</b> lalu klik <b>Simpan &amp; terapkan</b>.")
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: <b>{node or 'tidak terdeteksi'}</b> &nbsp;·&nbsp; "
f"Layanan: <b>{'AKTIF' if active else 'nonaktif'}</b> &nbsp;·&nbsp; "
f"Mulai otomatis: <b>{'ya' if enabled else 'tidak'}</b>"
+ (" &nbsp;·&nbsp; Layar: <b style='color:#ff9060'>KOSONG (nilai nol)</b>"
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())
+443
View File
@@ -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())
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Dump MSIL of methods in a .NET/mixed-mode assembly with resolved operands.
Usage:
ildump.py <assembly> list <regex>
ildump.py <assembly> dump <regex>
ildump.py <assembly> callers <regex>
"""
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 = "<bad string>"
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" <decode failed: {e}>")
if __name__ == "__main__":
main()
+81
View File
@@ -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 <assembly> <needle> [--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()