docs: bagian Updates (pemeriksaan & pembaruan satu klik dari GitHub Releases) versi 1.2.2
942 lines
39 KiB
Python
Executable File
942 lines
39 KiB
Python
Executable File
#!/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 re
|
|
import tempfile
|
|
import urllib.request
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import traceback
|
|
|
|
from PySide6.QtCore import QPointF, QRectF, Qt, QThread, QTimer, QUrl, Signal
|
|
from PySide6.QtGui import (QAction, QColor, QConicalGradient, QDesktopServices, 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
|
|
|
|
APP_VERSION = "1.2.2"
|
|
REPO = "oxpoe/Gamemax-linux-apps"
|
|
RELEASES_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
|
UPDATE_ASSET = "GAMEMAX-LCD-x86_64.AppImage"
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
import gamemax_report as gr # noqa: E402
|
|
|
|
DONATE_URL = "https://donasi.oxpoe.qzz.io/"
|
|
GITHUB_URL = "https://github.com/oxpoe/Gamemax-linux-apps"
|
|
|
|
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 version_tuple(tag):
|
|
parts = re.findall(r"\d+", tag or "")[:3]
|
|
return tuple(int(p) for p in parts) or (0,)
|
|
|
|
|
|
def ensure_ca_bundle():
|
|
"""Bundel PyInstaller tidak menemukan CA sistem sendiri; arahkan OpenSSL ke sana."""
|
|
if os.environ.get("SSL_CERT_FILE"):
|
|
return
|
|
for path in ("/etc/ssl/certs/ca-certificates.crt", # Arch/Debian
|
|
"/etc/pki/tls/certs/ca-bundle.crt", # Fedora/openSUSE
|
|
"/etc/ssl/cert.pem"): # Alpine/BSD
|
|
if os.path.exists(path):
|
|
os.environ["SSL_CERT_FILE"] = path
|
|
return
|
|
|
|
|
|
def latest_release():
|
|
"""(tag, url aset) dari rilis terbaru GitHub — dipanggil dari thread pekerja."""
|
|
ensure_ca_bundle()
|
|
req = urllib.request.Request(RELEASES_API, headers={
|
|
"Accept": "application/vnd.github+json", "User-Agent": "gamemax-lcd"})
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
data = json.load(resp)
|
|
asset = next((a for a in data.get("assets", []) if a["name"] == UPDATE_ASSET), None)
|
|
return data.get("tag_name", ""), (asset or {}).get("browser_download_url", "")
|
|
|
|
|
|
def installed_appimage():
|
|
"""Path AppImage yang sedang berjalan (kosong kalau dijalankan dari sumber)."""
|
|
return os.environ.get("APPIMAGE", "")
|
|
|
|
|
|
def open_link(url, parent=None):
|
|
"""Buka tautan di peramban; kalau gagal, tampilkan pesannya di log."""
|
|
if not QDesktopServices.openUrl(QUrl(url)):
|
|
if parent is not None:
|
|
parent.log(f"tidak bisa membuka tautan: {url}")
|
|
|
|
|
|
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
|
|
self.result = None
|
|
|
|
def run(self):
|
|
try:
|
|
self.result = self.fn()
|
|
self.done.emit(self.label, True, self.result if isinstance(self.result, str) else "")
|
|
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 & 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)
|
|
|
|
# ---- tautan
|
|
links = QHBoxLayout()
|
|
donate = QPushButton("❤ Donasi")
|
|
donate.setToolTip(DONATE_URL)
|
|
donate.clicked.connect(lambda: open_link(DONATE_URL, self))
|
|
github = QPushButton("GitHub")
|
|
github.setToolTip(GITHUB_URL)
|
|
github.clicked.connect(lambda: open_link(GITHUB_URL, self))
|
|
links.addWidget(donate)
|
|
links.addWidget(github)
|
|
links.addStretch(1)
|
|
self.check_button = QPushButton("Periksa pembaruan")
|
|
self.check_button.setToolTip("Bandingkan dengan rilis terbaru di GitHub")
|
|
self.check_button.clicked.connect(lambda: self.start_task("memeriksa pembaruan", self.do_check_update))
|
|
links.addWidget(self.check_button)
|
|
self.update_button = QPushButton("Perbarui")
|
|
self.update_button.setStyleSheet("font-weight:bold;")
|
|
self.update_button.hide()
|
|
self.update_button.clicked.connect(self.on_update_clicked)
|
|
links.addWidget(self.update_button)
|
|
links.addStretch(1)
|
|
self.version_label = QLabel(f"v{APP_VERSION}")
|
|
self.version_label.setStyleSheet("color:#8a8f8a;")
|
|
links.addWidget(self.version_label)
|
|
root.addLayout(links)
|
|
|
|
# ---- 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 installed_appimage(): # hanya pada versi terpasang
|
|
QTimer.singleShot(2500, lambda: self.start_task("memeriksa pembaruan",
|
|
self.do_check_update))
|
|
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()),
|
|
}
|
|
|
|
# -------------------------------------------------------------- pembaruan
|
|
def do_check_update(self):
|
|
tag, url = latest_release()
|
|
self._pending_update = (tag, url) if url and version_tuple(tag) > version_tuple(APP_VERSION) else None
|
|
if not tag:
|
|
return "tidak bisa membaca daftar rilis GitHub"
|
|
if self._pending_update:
|
|
return f"versi baru tersedia: {tag} (terpasang v{APP_VERSION})"
|
|
return f"sudah versi terbaru (v{APP_VERSION})"
|
|
|
|
def on_update_clicked(self):
|
|
pending = getattr(self, "_pending_update", None)
|
|
target = installed_appimage()
|
|
if not pending:
|
|
return
|
|
if not target:
|
|
self.log("tidak berjalan dari AppImage — perbarui manual dari halaman Releases")
|
|
return
|
|
tag, url = pending
|
|
self.start_task(f"mengunduh {tag}", lambda: self.do_update(tag, url, target))
|
|
|
|
def do_update(self, tag, url, target):
|
|
ensure_ca_bundle()
|
|
tmp = target + ".new"
|
|
urllib.request.urlretrieve(url, tmp)
|
|
os.chmod(tmp, 0o755)
|
|
os.replace(tmp, target) # ganti berkas secara atomik
|
|
# sender di layanan juga diambil dari bundel baru
|
|
with tempfile.TemporaryDirectory() as td:
|
|
subprocess.run([target, "--appimage-extract"], cwd=td,
|
|
capture_output=True, timeout=180)
|
|
sender = os.path.join(td, "squashfs-root/usr/bin/_internal/gamemax_report.py")
|
|
if os.path.exists(sender):
|
|
os.makedirs(INSTALL_DIR, exist_ok=True)
|
|
shutil.copyfile(sender, os.path.join(INSTALL_DIR, "gamemax_report.py"))
|
|
systemctl("restart", SERVICE)
|
|
return f"diperbarui ke {tag} — tutup jendela ini lalu buka lagi untuk memakai versi baru"
|
|
|
|
# -------------------------------------------------------------- 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 ""))
|
|
pending = getattr(self, "_pending_update", None)
|
|
if label == "memeriksa pembaruan" and pending:
|
|
self.update_button.setText(f"Perbarui ke {pending[0]}")
|
|
self.update_button.show()
|
|
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)
|
|
if os.environ.get("GAMEMAX_DEBUG"):
|
|
print(text, flush=True)
|
|
|
|
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> · "
|
|
f"Layanan: <b>{'AKTIF' if active else 'nonaktif'}</b> · "
|
|
f"Mulai otomatis: <b>{'ya' if enabled else 'tidak'}</b>"
|
|
+ (" · 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))
|
|
check_action = QAction("Periksa pembaruan", menu)
|
|
check_action.triggered.connect(lambda: self.window.start_task(
|
|
"memeriksa pembaruan", self.window.do_check_update))
|
|
donate_action = QAction("❤ Donasi", menu)
|
|
donate_action.triggered.connect(lambda: open_link(DONATE_URL, self.window))
|
|
github_action = QAction("GitHub", menu)
|
|
github_action.triggered.connect(lambda: open_link(GITHUB_URL, self.window))
|
|
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(check_action)
|
|
menu.addAction(donate_action)
|
|
menu.addAction(github_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())),
|
|
("periksa pembaruan", w.do_check_update))
|
|
for name, fn in steps:
|
|
try:
|
|
out = fn()
|
|
print(f"[ OK ] {name}" + (f" — {out}" if isinstance(out, str) and out else ""))
|
|
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())
|