mirror of
https://github.com/Horizontal42/LaptopTester.git
synced 2026-11-10 19:32:02 +03:00
refactor for public release
split into config/scanner/sheet_manager modules; hardware lists and column map moved to sheet_config.json (example provided); fix crash on submit (missing inp_tester), row=2 overwrite bug, GPU token matching, SSD decimal-vs-binary mismatch, net accounts returncode check, rescan page switch; tools/ gitignored with download README
This commit is contained in:
commit
e17264fb95
12 changed files with 955 additions and 0 deletions
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
__pycache__/
|
||||||
|
*.exe
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# secrets and private config — copy sheet_config.example.json and fill in
|
||||||
|
sheet_config.json
|
||||||
|
service_key.json
|
||||||
|
token.json
|
||||||
|
credentials.json
|
||||||
|
settings.ini
|
||||||
|
|
||||||
|
# third-party test utilities — not redistributable, see tools/README.md
|
||||||
|
tools/*
|
||||||
|
!tools/README.md
|
||||||
|
|
||||||
|
# internal/AI tool files
|
||||||
|
ТЕст/
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
*.md.bak
|
||||||
|
.aider*
|
||||||
|
bugs.md
|
||||||
|
*_ai_notes*
|
||||||
|
llm_context*
|
||||||
|
|
||||||
|
# PyInstaller leftovers
|
||||||
|
*.spec.bak
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
38
LaptopTester.spec
Normal file
38
LaptopTester.spec
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['tester.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='LaptopTester',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=False,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
54
README.md
Normal file
54
README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
[Русский](README.ru.md)
|
||||||
|
|
||||||
|
# LaptopTester
|
||||||
|
|
||||||
|
A Windows QA tool for laptop acceptance testing: auto-detects hardware, walks you through a checklist, writes results to a Google Sheet.
|
||||||
|
|
||||||
|
```
|
||||||
|
python tester.py
|
||||||
|
# Scans WMI + smartctl → fills in brand, model, CPU, RAM, SSD, GPU
|
||||||
|
# → checklist page for sound/screen/keyboard/camera/... → one click sends to Google Sheets
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** The app runs `net accounts /maxpwage:unlimited` on startup to reset Windows password expiry. It requires **administrator privileges**.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
**Requirements:** Windows 10/11, Python 3.9+
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Copy `sheet_config.example.json` → `sheet_config.json` and adjust to your spreadsheet (worksheet name, column numbers, hardware lists).
|
||||||
|
2. Download the test utilities listed in [`tools/README.md`](tools/README.md) into the `tools/` folder.
|
||||||
|
3. Add Google credentials (one of):
|
||||||
|
- `service_key.json` — service account key (recommended for shared use)
|
||||||
|
- `credentials.json` — OAuth 2.0 client JSON (runs a browser login on first use)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Hardware autodetect:** reads brand, model, CPU, RAM from WMI; disk capacity and SMART data (power-on hours, cycle count, health grade) from `smartctl`; GPU from WMI — matched against your configured lists.
|
||||||
|
- **Checklist page:** sound, screen, touchscreen, keyboard, camera, mic, touchpad, ports, biometrics, CMOS battery, BIOS/Windows password, LTE, drivers, display colours — each a pass/fail combo box.
|
||||||
|
- **Google Sheets write:** finds the row by serial number (updates if found, appends if not), writes all fields in one batch call.
|
||||||
|
|
||||||
|
## Handy things
|
||||||
|
|
||||||
|
- **Second battery:** check the "Вторая батарея" group box to enter a second battery's cycles/capacity.
|
||||||
|
- **Re-scan:** "Рескан" button reruns hardware detection without restarting the app.
|
||||||
|
- **SMART grade:** SSD info field shows `<grade>/<hours>/<cycles>` — A (<1000 h), B (1000–15000 h), C (>15000 h).
|
||||||
|
|
||||||
|
## For developers
|
||||||
|
|
||||||
|
Stack: Python 3, PyQt5, WMI, gspread, google-auth. Build: `pyinstaller LaptopTester.spec`
|
||||||
|
|
||||||
|
Column map and hardware option lists live in `sheet_config.json` — no code change needed to adapt to a different spreadsheet.
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Optional third-party utilities (not included — see `tools/README.md` for download links):
|
||||||
|
smartmontools, NirSoft BatteryInfoView, SoftwareOK IsMyLcdOK / IsMyTouchScreenOK, PassMark KeyboardTest, OCCT, CrystalDiskInfo.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
54
README.ru.md
Normal file
54
README.ru.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
[English](README.md)
|
||||||
|
|
||||||
|
# LaptopTester
|
||||||
|
|
||||||
|
Windows-инструмент для QA-приёмки ноутбуков: автоматически определяет комплектацию, проводит через чеклист тестирования и записывает результаты в Google Sheets.
|
||||||
|
|
||||||
|
```
|
||||||
|
python tester.py
|
||||||
|
# Сканирует WMI + smartctl → заполняет бренд, модель, CPU, RAM, SSD, GPU
|
||||||
|
# → страница чеклиста: звук/экран/клавиатура/камера/... → одна кнопка отправляет в Google Sheets
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Важно:** При запуске программа выполняет `net accounts /maxpwage:unlimited` (сброс срока действия пароля Windows). Требуются права **администратора**.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
**Требования:** Windows 10/11, Python 3.9+
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Скопируйте `sheet_config.example.json` → `sheet_config.json` и настройте под свою таблицу (имя листа, номера столбцов, списки железа).
|
||||||
|
2. Скачайте утилиты для тестирования из [`tools/README.md`](tools/README.md) в папку `tools/`.
|
||||||
|
3. Добавьте Google-ключи (на выбор):
|
||||||
|
- `service_key.json` — ключ сервисного аккаунта (рекомендуется для командного использования)
|
||||||
|
- `credentials.json` — OAuth 2.0 client JSON (при первом запуске откроется браузер для входа)
|
||||||
|
|
||||||
|
## Что умеет
|
||||||
|
|
||||||
|
- **Автоопределение железа:** бренд, модель, CPU, RAM — через WMI; объём диска и данные SMART (часы работы, циклы перезарядки, оценка состояния) — через `smartctl`; GPU — через WMI. Всё сопоставляется со списками из конфига.
|
||||||
|
- **Страница чеклиста:** звук, экран, тачскрин, клавиатура, камера, микрофон, тачпад, разъёмы, биометрия, батарейка CMOS, пароли BIOS/Windows, LTE, драйверы, цвета — каждый пункт выпадающим списком.
|
||||||
|
- **Запись в Google Sheets:** ищет строку по серийному номеру (обновляет найденную, добавляет новую), записывает все поля одним пакетным запросом.
|
||||||
|
|
||||||
|
## Полезное
|
||||||
|
|
||||||
|
- **Вторая батарея:** включите группу «Вторая батарея» чтобы ввести циклы/ёмкость второй АКБ.
|
||||||
|
- **Рескан:** кнопка «🔄 Рескан» повторно запускает определение железа без перезапуска программы.
|
||||||
|
- **Оценка SMART:** поле SSD показывает `<оценка>/<часы>/<циклы>` — A (<1000 ч), B (1000–15000 ч), C (>15000 ч).
|
||||||
|
|
||||||
|
## Для разработчиков
|
||||||
|
|
||||||
|
Стек: Python 3, PyQt5, WMI, gspread, google-auth. Сборка: `pyinstaller LaptopTester.spec`
|
||||||
|
|
||||||
|
Карта столбцов и списки опций железа хранятся в `sheet_config.json` — адаптация под другую таблицу не требует правки кода.
|
||||||
|
|
||||||
|
## Авторства
|
||||||
|
|
||||||
|
Необязательные сторонние утилиты (не включены — ссылки на скачивание в `tools/README.md`):
|
||||||
|
smartmontools, NirSoft BatteryInfoView, SoftwareOK IsMyLcdOK / IsMyTouchScreenOK, PassMark KeyboardTest, OCCT, CrystalDiskInfo.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT
|
||||||
57
config.py
Normal file
57
config.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Loads sheet_config.json: spreadsheet layout and hardware option lists.
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
CONFIG_NAME = "sheet_config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def get_base_path() -> str:
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return os.path.dirname(sys.executable)
|
||||||
|
return os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AppConfig:
|
||||||
|
worksheet: str
|
||||||
|
serial_column: int
|
||||||
|
columns: dict
|
||||||
|
brands: list
|
||||||
|
models: list
|
||||||
|
cpus: list
|
||||||
|
ram: list
|
||||||
|
ssd: list
|
||||||
|
gpus: list
|
||||||
|
brand_aliases: dict
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> AppConfig:
|
||||||
|
path = os.path.join(get_base_path(), CONFIG_NAME)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"{CONFIG_NAME} not found next to the program.\n"
|
||||||
|
f"Copy sheet_config.example.json to {CONFIG_NAME} and adjust it."
|
||||||
|
)
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
|
||||||
|
required = ["worksheet", "serial_column", "columns", "lists"]
|
||||||
|
missing = [k for k in required if k not in raw]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"{CONFIG_NAME}: missing keys: {', '.join(missing)}")
|
||||||
|
|
||||||
|
lists = raw["lists"]
|
||||||
|
return AppConfig(
|
||||||
|
worksheet=raw["worksheet"],
|
||||||
|
serial_column=int(raw["serial_column"]),
|
||||||
|
columns={k: int(v) for k, v in raw["columns"].items()},
|
||||||
|
brands=lists.get("brands", []),
|
||||||
|
models=lists.get("models", []),
|
||||||
|
cpus=lists.get("cpus", []),
|
||||||
|
ram=lists.get("ram", []),
|
||||||
|
ssd=lists.get("ssd", []),
|
||||||
|
gpus=lists.get("gpus", []),
|
||||||
|
brand_aliases=raw.get("brand_aliases", {}),
|
||||||
|
)
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
PyQt5>=5.15
|
||||||
|
WMI>=1.5
|
||||||
|
gspread>=5.0
|
||||||
|
google-auth>=2.0
|
||||||
|
google-auth-oauthlib>=1.0
|
||||||
193
scanner.py
Normal file
193
scanner.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
# Hardware detection: WMI, smartctl, matching raw names against configured lists.
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import wmi
|
||||||
|
from PyQt5.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
|
from config import AppConfig, get_base_path
|
||||||
|
|
||||||
|
NO_WINDOW = subprocess.CREATE_NO_WINDOW
|
||||||
|
CPU_PATTERN = re.compile(r"(i\d-\w+|R\d \w+|N\d+|E2-\w+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(s: str) -> str:
|
||||||
|
return " ".join(re.sub(r"[:(),®™]", " ", s.upper()).split())
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens(s: str) -> set:
|
||||||
|
return set(_normalize(s).split())
|
||||||
|
|
||||||
|
|
||||||
|
def find_best_match(value: str, allowed: list) -> str:
|
||||||
|
"""Match a raw WMI name against a configured list.
|
||||||
|
|
||||||
|
Exact normalized match wins; otherwise the longest list entry whose
|
||||||
|
tokens all appear in the raw name. For entries formatted as "VENDOR
|
||||||
|
FAMILY: MODEL" (e.g. "NVIDIA MX: MX450"), also tries matching only the
|
||||||
|
model part so that WMI names like "NVIDIA GeForce MX450" still hit.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
norm_val = _normalize(value)
|
||||||
|
val_tokens = set(norm_val.split())
|
||||||
|
|
||||||
|
for item in allowed:
|
||||||
|
if item and _normalize(item) == norm_val:
|
||||||
|
return item
|
||||||
|
|
||||||
|
best = ""
|
||||||
|
for item in allowed:
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
key_toks = _tokens(item.split(": ", 1)[1]) if ": " in item else _tokens(item)
|
||||||
|
if (_tokens(item) <= val_tokens or key_toks <= val_tokens) and len(item) > len(best):
|
||||||
|
best = item
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_size_bytes(label: str) -> float:
|
||||||
|
n = float(label.split()[0])
|
||||||
|
return n * 1e12 if "TB" in label.upper() else n * 1e9
|
||||||
|
|
||||||
|
|
||||||
|
def match_ssd_volume(capacity_bytes: int, ssd_list: list) -> str:
|
||||||
|
"""Pick the closest advertised size within 7% (drives report decimal GB)."""
|
||||||
|
if not capacity_bytes:
|
||||||
|
return ""
|
||||||
|
best, best_diff = "", 0.07
|
||||||
|
for label in ssd_list:
|
||||||
|
size = _parse_size_bytes(label)
|
||||||
|
diff = abs(capacity_bytes - size) / size
|
||||||
|
if diff < best_diff:
|
||||||
|
best, best_diff = label, diff
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareScanner(QThread):
|
||||||
|
finished = pyqtSignal(dict)
|
||||||
|
log = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, cfg: AppConfig):
|
||||||
|
super().__init__()
|
||||||
|
self.cfg = cfg
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
data = {}
|
||||||
|
try:
|
||||||
|
c = wmi.WMI()
|
||||||
|
|
||||||
|
self.log.emit("BIOS...")
|
||||||
|
for bios in c.Win32_BIOS():
|
||||||
|
data["Serial"] = (bios.SerialNumber or "").strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
self.log.emit("System...")
|
||||||
|
for sys_info in c.Win32_ComputerSystem():
|
||||||
|
data["Brand"] = self.match_brand((sys_info.Manufacturer or "").strip())
|
||||||
|
data["Model"] = find_best_match((sys_info.Model or "").strip(), self.cfg.models)
|
||||||
|
data["RAM"] = self.match_ram(sys_info.TotalPhysicalMemory)
|
||||||
|
break
|
||||||
|
|
||||||
|
self.log.emit("CPU...")
|
||||||
|
for cpu in c.Win32_Processor():
|
||||||
|
data["CPU"] = self.match_cpu((cpu.Name or "").strip())
|
||||||
|
break
|
||||||
|
|
||||||
|
self.log.emit("GPU...")
|
||||||
|
data["GPU"] = self.detect_gpu(c)
|
||||||
|
|
||||||
|
self.log.emit("Disk...")
|
||||||
|
vol, info = self.detect_disk()
|
||||||
|
data["SSD_Vol"] = vol
|
||||||
|
data["SSD_Info"] = info
|
||||||
|
|
||||||
|
self.log.emit("Password expiry...")
|
||||||
|
data["Win_Pass"] = self.reset_password_expiry()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Scan error: {e}")
|
||||||
|
|
||||||
|
self.finished.emit(data)
|
||||||
|
|
||||||
|
def match_brand(self, raw: str) -> str:
|
||||||
|
alias = self.cfg.brand_aliases.get(raw)
|
||||||
|
if alias:
|
||||||
|
return alias
|
||||||
|
return find_best_match(raw, self.cfg.brands)
|
||||||
|
|
||||||
|
def match_ram(self, total_bytes) -> str:
|
||||||
|
try:
|
||||||
|
gb = round(int(total_bytes or 0) / 1024**3)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return ""
|
||||||
|
# WMI rarely reports DDR generation; the list is ordered so DDR4 wins,
|
||||||
|
# the tester corrects it manually when needed.
|
||||||
|
target = f"{gb} GB"
|
||||||
|
for r in self.cfg.ram:
|
||||||
|
if target in r:
|
||||||
|
return r
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def match_cpu(self, raw: str) -> str:
|
||||||
|
# WMI reports "AMD Ryzen 5 3500U ...", the lists use the short "R5 3500U" form
|
||||||
|
raw = re.sub(r"Ryzen\s+(\d)", r"R\1", raw, flags=re.IGNORECASE)
|
||||||
|
match = find_best_match(raw, self.cfg.cpus)
|
||||||
|
if match:
|
||||||
|
return match
|
||||||
|
m = CPU_PATTERN.search(raw)
|
||||||
|
return find_best_match(m.group(0), self.cfg.cpus) if m else ""
|
||||||
|
|
||||||
|
def detect_gpu(self, c) -> str:
|
||||||
|
try:
|
||||||
|
for gpu in c.Win32_VideoController():
|
||||||
|
match = find_best_match(gpu.Name or "", self.cfg.gpus)
|
||||||
|
if match:
|
||||||
|
return match
|
||||||
|
except Exception as e:
|
||||||
|
print(f"GPU error: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def detect_disk(self):
|
||||||
|
smart_path = os.path.join(get_base_path(), "tools", "smartctl.exe")
|
||||||
|
if not os.path.exists(smart_path):
|
||||||
|
return "", ""
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[smart_path, "-j", "-a", "/dev/pd0"],
|
||||||
|
capture_output=True, text=True, creationflags=NO_WINDOW,
|
||||||
|
)
|
||||||
|
js = json.loads(proc.stdout)
|
||||||
|
capacity = js.get("user_capacity", {}).get("bytes", 0)
|
||||||
|
return match_ssd_volume(capacity, self.cfg.ssd), self.parse_smart(js)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Disk error: {e}")
|
||||||
|
return "", "Err/0/0"
|
||||||
|
|
||||||
|
def parse_smart(self, js: dict) -> str:
|
||||||
|
hours = cycles = 0
|
||||||
|
if "nvme_smart_health_information_log" in js:
|
||||||
|
log = js["nvme_smart_health_information_log"]
|
||||||
|
hours = log.get("power_on_hours", 0)
|
||||||
|
cycles = log.get("power_cycles", 0)
|
||||||
|
elif "ata_smart_attributes" in js:
|
||||||
|
for item in js["ata_smart_attributes"].get("table", []):
|
||||||
|
if item["id"] == 9:
|
||||||
|
hours = item["raw"]["value"]
|
||||||
|
if item["id"] == 12:
|
||||||
|
cycles = item["raw"]["value"]
|
||||||
|
|
||||||
|
grade = "B"
|
||||||
|
if hours < 1000:
|
||||||
|
grade = "A"
|
||||||
|
elif hours > 15000:
|
||||||
|
grade = "C"
|
||||||
|
return f"{grade}/{hours}/{cycles}"
|
||||||
|
|
||||||
|
def reset_password_expiry(self) -> str:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["net", "accounts", "/maxpwage:unlimited"],
|
||||||
|
capture_output=True, creationflags=NO_WINDOW,
|
||||||
|
)
|
||||||
|
return "Сброшен" if proc.returncode == 0 else "Не сброшен"
|
||||||
95
sheet_config.example.json
Normal file
95
sheet_config.example.json
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
{
|
||||||
|
"worksheet": "Laptops",
|
||||||
|
"serial_column": 21,
|
||||||
|
"columns": {
|
||||||
|
"Brand": 19,
|
||||||
|
"Model": 20,
|
||||||
|
"Serial": 21,
|
||||||
|
"CPU": 22,
|
||||||
|
"RAM": 23,
|
||||||
|
"SSD_Vol": 24,
|
||||||
|
"LTE": 25,
|
||||||
|
"Screen": 26,
|
||||||
|
"GPU": 27,
|
||||||
|
"Bios_Pass": 30,
|
||||||
|
"CMOS": 31,
|
||||||
|
"Bat_Conn": 32,
|
||||||
|
"Ports": 33,
|
||||||
|
"Sound": 34,
|
||||||
|
"Camera": 35,
|
||||||
|
"SSD_Info": 36,
|
||||||
|
"Bat1_Cyc": 37,
|
||||||
|
"Bat1_Cap": 38,
|
||||||
|
"Bat2_Cyc": 39,
|
||||||
|
"Bat2_Cap": 40,
|
||||||
|
"Colors": 41,
|
||||||
|
"Kbd": 42,
|
||||||
|
"Mic": 43,
|
||||||
|
"Biometrics": 44,
|
||||||
|
"Drivers": 45,
|
||||||
|
"Win_Pass": 46,
|
||||||
|
"Touchscreen": 47,
|
||||||
|
"Touchpad_Btn": 48,
|
||||||
|
"Touchpad_Sens": 49,
|
||||||
|
"Comment": 50
|
||||||
|
},
|
||||||
|
"brand_aliases": {
|
||||||
|
"Hewlett-Packard": "HP",
|
||||||
|
"LENOVO": "Lenovo",
|
||||||
|
"Dell Inc.": "Dell",
|
||||||
|
"Micro-Star International Co., Ltd.": "MSI",
|
||||||
|
"ASUSTeK COMPUTER INC.": "ASUS",
|
||||||
|
"Microsoft Corporation": "Microsoft",
|
||||||
|
"FUJITSU": "Fujitsu",
|
||||||
|
"SAMSUNG ELECTRONICS CO., LTD.": "Samsung"
|
||||||
|
},
|
||||||
|
"lists": {
|
||||||
|
"brands": ["HP", "Lenovo", "Dell", "MSI", "ASUS", "Microsoft", "Fujitsu", "Samsung"],
|
||||||
|
"models": [
|
||||||
|
"840 G6", "820 G4", "840 G3", "840 G4", "840 G5", "840 G7", "840 G8", "840 Aero G8", "840 G9",
|
||||||
|
"845 G7", "845 G8", "845 G10", "850 G3", "850 G4", "850 G5", "850 G6", "850 G7", "850 G8", "855 G8", "860 G10",
|
||||||
|
"1040 G3", "x360 1030 G3",
|
||||||
|
"ProBook 430 G4", "ProBook 430 G5", "ProBook 430 G6", "ProBook 430 G7",
|
||||||
|
"ProBook 440 G4", "ProBook 440 G6", "ProBook 440 G7",
|
||||||
|
"ProBook 445 G7", "ProBook 445 G8", "ProBook 635 Aero G7",
|
||||||
|
"ProBook 640 G2", "ProBook 640 G3",
|
||||||
|
"ProBook 650 G1", "ProBook 650 G2", "ProBook 650 G3", "ProBook 650 G4", "ProBook 6560b",
|
||||||
|
"ProBook x360 435 G8", "ProBook x360 440 G1",
|
||||||
|
"470 G8 Notebook", "Notebook 15",
|
||||||
|
"ThinkPad T14 Gen 1", "ThinkPad T14s Gen 1", "ThinkPad T14 Gen 2a", "ThinkPad T14 Gen 3 Magnesium",
|
||||||
|
"ThinkPad T430i", "ThinkPad T430u", "ThinkPad T440s", "ThinkPad T450", "ThinkPad T450s", "ThinkPad T460",
|
||||||
|
"ThinkPad T470", "ThinkPad T470s", "ThinkPad T480", "ThinkPad T490s", "ThinkPad T495",
|
||||||
|
"ThinkPad T560", "ThinkPad T570", "ThinkPad T580", "ThinkPad T13 Gen 2",
|
||||||
|
"ThinkPad E14 Gen 2", "ThinkPad E14 Gen 3", "ThinkPad E15", "ThinkPad L470",
|
||||||
|
"ThinkPad X1 Tablet Gen 3", "Ideapad MIIX 510-12IKB",
|
||||||
|
"ThinkBook 14 Gen 2", "ThinkBook 14 Gen 3", "ThinkBook 15 Gen 2",
|
||||||
|
"XPS 15 9570", "Latitude 5420", "Latitude 7490", "Latitude 7400", "Precision 7760",
|
||||||
|
"G15 5511", "Creator Z16P B12UGST", "Vector GP66 12 UGS",
|
||||||
|
"ExpertBook B5402FE", "ZenBook UX430U", "ZenBook UX32V",
|
||||||
|
"Surface Pro 6", "Amilo", "np530u4c"
|
||||||
|
],
|
||||||
|
"cpus": [
|
||||||
|
"i5-8365U", "i3-7100U", "i5-2410M", "i5-3337U", "i5-4200M", "i5-4210M", "i5-4300U", "i5-5300U",
|
||||||
|
"i5-6200U", "i5-6300U", "i5-7200U", "i5-7300U", "i5-8250U", "i5-8265U", "i5-8350U",
|
||||||
|
"i5-10210U", "i5-10310U", "i5-10510U", "i5-1135G7", "i5-1145G7", "i5-1155G7",
|
||||||
|
"i7-3517U", "i7-4700MQ", "i7-6600U", "i7-7600U", "i7-8550U", "i7-8565U", "i7-8650U", "i7-8665U", "i7-8750H",
|
||||||
|
"i7-10510U", "i7-10610U", "i7-1165G7", "i7-11800H", "i7-11850H", "i7-1185G7", "i7-12700H", "i7-1355U", "i9-11950H",
|
||||||
|
"R5 3500U", "R5 4500U", "R5 4650U", "R5 5500U", "R5 5650U",
|
||||||
|
"R7 4750U", "R7 5700U", "R7 5800U", "R7 5850U", "R7 6850U", "R5 7540U",
|
||||||
|
"E2-9000e Radeon R2", "Xeon W-11855M"
|
||||||
|
],
|
||||||
|
"ram": [
|
||||||
|
"DDR4: 4 GB", "DDR4: 8 GB", "DDR4: 12 GB", "DDR4: 16 GB", "DDR4: 20 GB", "DDR4: 24 GB", "DDR4: 32 GB",
|
||||||
|
"DDR3: 4 GB", "DDR3: 8 GB", "DDR3: 12 GB", "DDR3: 16 GB", "DDR3: 20 GB", "DDR3: 32 GB",
|
||||||
|
"DDR5: 8 GB", "DDR5: 16 GB", "DDR5: 32 GB"
|
||||||
|
],
|
||||||
|
"ssd": ["128 GB", "180 GB", "240 GB", "256 GB", "320 GB", "512 GB", "1 TB"],
|
||||||
|
"gpus": [
|
||||||
|
"",
|
||||||
|
"AMD Radeon 550X", "RX 540",
|
||||||
|
"NVIDIA RTX: 3070 Ti", "NVIDIA RTX: 3060", "NVIDIA RTX: A4000", "NVIDIA RTX: A5000",
|
||||||
|
"NVIDIA MX: MX130", "NVIDIA MX: MX450",
|
||||||
|
"NVIDIA GT: GT 620M"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
87
sheet_manager.py
Normal file
87
sheet_manager.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Google Sheets access: service-account or OAuth auth, row upsert by serial number.
|
||||||
|
import os
|
||||||
|
|
||||||
|
import gspread
|
||||||
|
from google.auth.transport.requests import Request
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||||
|
|
||||||
|
from config import AppConfig, get_base_path
|
||||||
|
|
||||||
|
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
|
||||||
|
|
||||||
|
# gspread < 6 raises CellNotFound, gspread >= 6 returns None from find()
|
||||||
|
CELL_NOT_FOUND = getattr(gspread.exceptions, "CellNotFound", ())
|
||||||
|
|
||||||
|
|
||||||
|
class SheetManager:
|
||||||
|
def __init__(self, sheet_url: str, cfg: AppConfig):
|
||||||
|
self.sheet_url = sheet_url
|
||||||
|
self.cfg = cfg
|
||||||
|
|
||||||
|
base = get_base_path()
|
||||||
|
self.service_key = os.path.join(base, "service_key.json")
|
||||||
|
self.creds_file = os.path.join(base, "credentials.json")
|
||||||
|
self.token_file = os.path.join(base, "token.json")
|
||||||
|
|
||||||
|
self.doc = self.connect()
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
if os.path.exists(self.service_key):
|
||||||
|
client = gspread.service_account(filename=self.service_key)
|
||||||
|
return client.open_by_url(self.sheet_url)
|
||||||
|
|
||||||
|
creds = None
|
||||||
|
if os.path.exists(self.token_file):
|
||||||
|
creds = Credentials.from_authorized_user_file(self.token_file, SCOPES)
|
||||||
|
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
try:
|
||||||
|
creds.refresh(Request())
|
||||||
|
self.save_token(creds)
|
||||||
|
except Exception:
|
||||||
|
creds = None
|
||||||
|
|
||||||
|
if not creds or not creds.valid:
|
||||||
|
creds = self.perform_login()
|
||||||
|
|
||||||
|
client = gspread.authorize(creds)
|
||||||
|
return client.open_by_url(self.sheet_url)
|
||||||
|
|
||||||
|
def perform_login(self):
|
||||||
|
if not os.path.exists(self.creds_file):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"No Google credentials found: put service_key.json (service account) "
|
||||||
|
"or credentials.json (OAuth client) next to the program."
|
||||||
|
)
|
||||||
|
flow = InstalledAppFlow.from_client_secrets_file(self.creds_file, SCOPES)
|
||||||
|
creds = flow.run_local_server(port=0)
|
||||||
|
self.save_token(creds)
|
||||||
|
return creds
|
||||||
|
|
||||||
|
def save_token(self, creds):
|
||||||
|
with open(self.token_file, "w") as f:
|
||||||
|
f.write(creds.to_json())
|
||||||
|
|
||||||
|
def send_test_results(self, data: dict) -> str:
|
||||||
|
ws = self.doc.worksheet(self.cfg.worksheet)
|
||||||
|
serial = data.get("Serial", "").strip()
|
||||||
|
|
||||||
|
serial_col = self.cfg.serial_column
|
||||||
|
try:
|
||||||
|
cell = ws.find(serial, in_column=serial_col)
|
||||||
|
except CELL_NOT_FOUND:
|
||||||
|
cell = None
|
||||||
|
|
||||||
|
if cell:
|
||||||
|
row, status = cell.row, "Обновлен"
|
||||||
|
else:
|
||||||
|
row, status = len(ws.col_values(serial_col)) + 1, "Создан новый"
|
||||||
|
|
||||||
|
cells = [
|
||||||
|
gspread.Cell(row, col, str(data[key]).strip())
|
||||||
|
for key, col in self.cfg.columns.items()
|
||||||
|
if key in data
|
||||||
|
]
|
||||||
|
ws.update_cells(cells)
|
||||||
|
return f"Успех: {serial} -> строка {row} ({status})"
|
||||||
299
tester.py
Normal file
299
tester.py
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
# Laptop QA wizard: scans hardware, collects test results, pushes them to Google Sheets.
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PyQt5.QtCore import QSettings, Qt
|
||||||
|
from PyQt5.QtGui import QFont, QIcon
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QApplication, QComboBox, QFormLayout, QGridLayout, QGroupBox, QHBoxLayout,
|
||||||
|
QLabel, QLineEdit, QMainWindow, QMessageBox, QPushButton, QScrollArea,
|
||||||
|
QStackedWidget, QTextEdit, QVBoxLayout, QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from config import get_base_path, load_config
|
||||||
|
from scanner import HardwareScanner
|
||||||
|
from sheet_manager import SheetManager
|
||||||
|
|
||||||
|
# key (config column), label, options, optional tool button: text + command
|
||||||
|
CHECKS = [
|
||||||
|
("Sound", "Звук", ["+ / +", "+ / -", "- / +", "- / -"], "▶️", "sound_test.mp3"),
|
||||||
|
("Screen", "Экран", ["+", "-", "Нет инфы"], "🖥️", "IsMyLcdOK.exe"),
|
||||||
|
("Touchscreen", "Тачскрин", ["-", "+", "Его нет"], "👆", "IsMyTouchScreenOK_x64.exe"),
|
||||||
|
("Kbd", "Клавиатура", ["+", "-", "Нет инфы"], "⌨️", "KeyboardTest.exe"),
|
||||||
|
("Camera", "Камера", ["+", "-", "Нет инфы"], "📷", "Camera.exe"),
|
||||||
|
("Mic", "Микрофон", ["+", "-", "Нет инфы"], "🎤", "control mmsys.cpl sounds"),
|
||||||
|
("Touchpad_Sens", "Тачпад (Сенсор)", ["+", "-", "Нет инфы"], None, None),
|
||||||
|
("Touchpad_Btn", "Тачпад (Кнопки)", ["+", "-", "Их нет"], None, None),
|
||||||
|
("Ports", "Разъемы", ["+", "-", "Нет инфы"], None, None),
|
||||||
|
("Biometrics", "Сканер лица/пальца", ["- / -", "+ / +", "+ / -", "- / +"], "🔐", "devmgmt.msc"),
|
||||||
|
("CMOS", "Батарейка CMOS", ["+", "-", "Нет инфы"], None, None),
|
||||||
|
("Bios_Pass", "Пароль BIOS", ["Сброшен", "Не сброшен"], None, None),
|
||||||
|
("Win_Pass", "Срок пароля Win", ["Сброшен", "Не сброшен"], None, None),
|
||||||
|
("LTE", "LTE", ["LTE", "-"], None, None),
|
||||||
|
("Bat_Conn", "Подключение АКБ", ["Подключен", "Не подключен"], None, None),
|
||||||
|
("Drivers", "Драйверы", ["+", "-", "Нет инфы"], None, None),
|
||||||
|
("Colors", "Цвета", ["+", "-", "Нет инфы"], None, None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_tool(filename: str):
|
||||||
|
path = os.path.join(get_base_path(), "tools", filename)
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
os.startfile(path)
|
||||||
|
except OSError as e:
|
||||||
|
QMessageBox.critical(None, "Ошибка", str(e))
|
||||||
|
elif "Camera" in filename:
|
||||||
|
subprocess.Popen("start microsoft.windows.camera:", shell=True)
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(None, "Ошибка", f"Файл не найден:\n{path}")
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin() -> bool:
|
||||||
|
try:
|
||||||
|
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class TesterApp(QMainWindow):
|
||||||
|
def __init__(self, cfg):
|
||||||
|
super().__init__()
|
||||||
|
self.cfg = cfg
|
||||||
|
self.setWindowTitle("Laptop QA Wizard")
|
||||||
|
self.resize(600, 800)
|
||||||
|
|
||||||
|
base = get_base_path()
|
||||||
|
self.settings = QSettings(os.path.join(base, "settings.ini"), QSettings.IniFormat)
|
||||||
|
|
||||||
|
icon_path = os.path.join(base, "icon.ico")
|
||||||
|
if os.path.exists(icon_path):
|
||||||
|
self.setWindowIcon(QIcon(icon_path))
|
||||||
|
|
||||||
|
if not is_admin():
|
||||||
|
QMessageBox.warning(self, "Внимание", "Запустите от Админа!")
|
||||||
|
|
||||||
|
self.scanner = None
|
||||||
|
self.init_ui()
|
||||||
|
self.start_scan()
|
||||||
|
|
||||||
|
def init_ui(self):
|
||||||
|
self.stack = QStackedWidget()
|
||||||
|
self.setCentralWidget(self.stack)
|
||||||
|
|
||||||
|
self.page_load = QWidget()
|
||||||
|
l_load = QVBoxLayout(self.page_load)
|
||||||
|
self.lbl_load = QLabel("Сканирование...")
|
||||||
|
self.lbl_load.setAlignment(Qt.AlignCenter)
|
||||||
|
self.lbl_load.setFont(QFont("Arial", 16))
|
||||||
|
l_load.addWidget(self.lbl_load)
|
||||||
|
self.stack.addWidget(self.page_load)
|
||||||
|
|
||||||
|
self.page_hw = QWidget()
|
||||||
|
self.init_hw_ui()
|
||||||
|
self.stack.addWidget(self.page_hw)
|
||||||
|
|
||||||
|
self.page_tests = QWidget()
|
||||||
|
self.init_tests_ui()
|
||||||
|
self.stack.addWidget(self.page_tests)
|
||||||
|
|
||||||
|
def make_combo(self, items) -> QComboBox:
|
||||||
|
cb = QComboBox()
|
||||||
|
cb.addItems(items)
|
||||||
|
return cb
|
||||||
|
|
||||||
|
def init_hw_ui(self):
|
||||||
|
layout = QVBoxLayout(self.page_hw)
|
||||||
|
scroll = QScrollArea()
|
||||||
|
content = QWidget()
|
||||||
|
form = QFormLayout(content)
|
||||||
|
|
||||||
|
self.inp_serial = QLineEdit()
|
||||||
|
self.inp_brand = self.make_combo(self.cfg.brands)
|
||||||
|
self.inp_model = self.make_combo(self.cfg.models)
|
||||||
|
self.inp_cpu = self.make_combo(self.cfg.cpus)
|
||||||
|
self.inp_ram = self.make_combo(self.cfg.ram)
|
||||||
|
self.inp_ssd_vol = self.make_combo(self.cfg.ssd)
|
||||||
|
self.inp_ssd_info = QLineEdit()
|
||||||
|
self.inp_gpu = self.make_combo(self.cfg.gpus)
|
||||||
|
|
||||||
|
hb_bat = QHBoxLayout()
|
||||||
|
self.inp_bat1_cyc = QLineEdit()
|
||||||
|
self.inp_bat1_cyc.setPlaceholderText("Циклы")
|
||||||
|
self.inp_bat1_cap = QLineEdit()
|
||||||
|
self.inp_bat1_cap.setPlaceholderText("Емкость")
|
||||||
|
btn_bat = QPushButton("🔋 BatView")
|
||||||
|
btn_bat.clicked.connect(lambda: run_tool("battery_view.exe"))
|
||||||
|
hb_bat.addWidget(self.inp_bat1_cyc)
|
||||||
|
hb_bat.addWidget(self.inp_bat1_cap)
|
||||||
|
hb_bat.addWidget(btn_bat)
|
||||||
|
|
||||||
|
self.grp_bat2 = QGroupBox("Вторая батарея")
|
||||||
|
self.grp_bat2.setCheckable(True)
|
||||||
|
self.grp_bat2.setChecked(False)
|
||||||
|
hb_bat2 = QHBoxLayout(self.grp_bat2)
|
||||||
|
self.inp_bat2_cyc = QLineEdit()
|
||||||
|
self.inp_bat2_cyc.setPlaceholderText("Циклы 2")
|
||||||
|
self.inp_bat2_cap = QLineEdit()
|
||||||
|
self.inp_bat2_cap.setPlaceholderText("Емкость 2")
|
||||||
|
hb_bat2.addWidget(self.inp_bat2_cyc)
|
||||||
|
hb_bat2.addWidget(self.inp_bat2_cap)
|
||||||
|
|
||||||
|
form.addRow("Серийный номер:", self.inp_serial)
|
||||||
|
form.addRow("Бренд:", self.inp_brand)
|
||||||
|
form.addRow("Модель:", self.inp_model)
|
||||||
|
form.addRow("CPU:", self.inp_cpu)
|
||||||
|
form.addRow("RAM:", self.inp_ram)
|
||||||
|
form.addRow("SSD Объем:", self.inp_ssd_vol)
|
||||||
|
form.addRow("SSD Инфо:", self.inp_ssd_info)
|
||||||
|
form.addRow("GPU:", self.inp_gpu)
|
||||||
|
form.addRow("АКБ 1:", hb_bat)
|
||||||
|
form.addRow(self.grp_bat2)
|
||||||
|
|
||||||
|
self.inp_url = QLineEdit()
|
||||||
|
self.inp_url.setPlaceholderText("URL Таблицы")
|
||||||
|
self.inp_url.setText(self.settings.value("sheet_url", ""))
|
||||||
|
form.addRow("Google URL:", self.inp_url)
|
||||||
|
|
||||||
|
scroll.setWidget(content)
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
layout.addWidget(scroll)
|
||||||
|
|
||||||
|
h_btns = QHBoxLayout()
|
||||||
|
btn_rescan = QPushButton("🔄 Рескан")
|
||||||
|
btn_rescan.clicked.connect(self.start_scan)
|
||||||
|
btn_next = QPushButton("Далее ->")
|
||||||
|
btn_next.setFixedHeight(40)
|
||||||
|
btn_next.clicked.connect(lambda: self.stack.setCurrentIndex(2))
|
||||||
|
h_btns.addWidget(btn_rescan)
|
||||||
|
h_btns.addWidget(btn_next)
|
||||||
|
layout.addLayout(h_btns)
|
||||||
|
|
||||||
|
def init_tests_ui(self):
|
||||||
|
layout = QVBoxLayout(self.page_tests)
|
||||||
|
scroll = QScrollArea()
|
||||||
|
content = QWidget()
|
||||||
|
grid = QGridLayout(content)
|
||||||
|
self.checks = {}
|
||||||
|
|
||||||
|
for r, (key, label, opts, bt_txt, bt_cmd) in enumerate(CHECKS):
|
||||||
|
cb = self.make_combo(opts)
|
||||||
|
grid.addWidget(QLabel(label), r, 0)
|
||||||
|
grid.addWidget(cb, r, 1)
|
||||||
|
if bt_txt:
|
||||||
|
b = QPushButton(bt_txt)
|
||||||
|
if bt_cmd.endswith((".exe", ".mp3")):
|
||||||
|
b.clicked.connect(lambda _, c=bt_cmd: run_tool(c))
|
||||||
|
else:
|
||||||
|
b.clicked.connect(lambda _, c=bt_cmd: subprocess.Popen(c, shell=True))
|
||||||
|
grid.addWidget(b, r, 2)
|
||||||
|
self.checks[key] = cb
|
||||||
|
|
||||||
|
row = len(CHECKS)
|
||||||
|
grid.addWidget(QLabel("Комментарий:"), row, 0)
|
||||||
|
self.inp_comment = QTextEdit()
|
||||||
|
self.inp_comment.setMaximumHeight(60)
|
||||||
|
grid.addWidget(self.inp_comment, row, 1, 1, 2)
|
||||||
|
|
||||||
|
scroll.setWidget(content)
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
layout.addWidget(scroll)
|
||||||
|
|
||||||
|
h_btns = QHBoxLayout()
|
||||||
|
btn_back = QPushButton("<- Назад")
|
||||||
|
btn_back.clicked.connect(lambda: self.stack.setCurrentIndex(1))
|
||||||
|
btn_fin = QPushButton("🚀 ОТПРАВИТЬ")
|
||||||
|
btn_fin.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold;")
|
||||||
|
btn_fin.setFixedHeight(40)
|
||||||
|
btn_fin.clicked.connect(self.finish_testing)
|
||||||
|
h_btns.addWidget(btn_back)
|
||||||
|
h_btns.addWidget(btn_fin)
|
||||||
|
layout.addLayout(h_btns)
|
||||||
|
|
||||||
|
def start_scan(self):
|
||||||
|
if self.scanner and self.scanner.isRunning():
|
||||||
|
return
|
||||||
|
self.stack.setCurrentIndex(0)
|
||||||
|
self.scanner = HardwareScanner(self.cfg)
|
||||||
|
self.scanner.log.connect(self.lbl_load.setText)
|
||||||
|
self.scanner.finished.connect(self.on_scan_finished)
|
||||||
|
self.scanner.start()
|
||||||
|
|
||||||
|
def on_scan_finished(self, data):
|
||||||
|
self.inp_serial.setText(data.get("Serial", ""))
|
||||||
|
self.inp_ssd_info.setText(data.get("SSD_Info", ""))
|
||||||
|
|
||||||
|
def set_combo(cb, val):
|
||||||
|
if not val:
|
||||||
|
return
|
||||||
|
idx = cb.findText(val)
|
||||||
|
if idx >= 0:
|
||||||
|
cb.setCurrentIndex(idx)
|
||||||
|
|
||||||
|
set_combo(self.inp_brand, data.get("Brand", ""))
|
||||||
|
set_combo(self.inp_model, data.get("Model", ""))
|
||||||
|
set_combo(self.inp_cpu, data.get("CPU", ""))
|
||||||
|
set_combo(self.inp_ram, data.get("RAM", ""))
|
||||||
|
set_combo(self.inp_ssd_vol, data.get("SSD_Vol", ""))
|
||||||
|
set_combo(self.inp_gpu, data.get("GPU", ""))
|
||||||
|
|
||||||
|
if data.get("Win_Pass") == "Сброшен":
|
||||||
|
self.checks["Win_Pass"].setCurrentText("Сброшен")
|
||||||
|
|
||||||
|
self.stack.setCurrentIndex(1)
|
||||||
|
|
||||||
|
def finish_testing(self):
|
||||||
|
serial = self.inp_serial.text().strip()
|
||||||
|
if not serial:
|
||||||
|
QMessageBox.warning(self, "Ошибка", "Пустой серийный номер!")
|
||||||
|
return
|
||||||
|
url = self.inp_url.text().strip()
|
||||||
|
if not url:
|
||||||
|
QMessageBox.warning(self, "Ошибка", "Укажите URL таблицы!")
|
||||||
|
return
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"Serial": serial,
|
||||||
|
"Brand": self.inp_brand.currentText(),
|
||||||
|
"Model": self.inp_model.currentText(),
|
||||||
|
"CPU": self.inp_cpu.currentText(),
|
||||||
|
"RAM": self.inp_ram.currentText(),
|
||||||
|
"SSD_Vol": self.inp_ssd_vol.currentText(),
|
||||||
|
"SSD_Info": self.inp_ssd_info.text(),
|
||||||
|
"GPU": self.inp_gpu.currentText(),
|
||||||
|
"Bat1_Cyc": self.inp_bat1_cyc.text(),
|
||||||
|
"Bat1_Cap": self.inp_bat1_cap.text(),
|
||||||
|
"Comment": self.inp_comment.toPlainText(),
|
||||||
|
}
|
||||||
|
for key, cb in self.checks.items():
|
||||||
|
data[key] = cb.currentText()
|
||||||
|
if self.grp_bat2.isChecked():
|
||||||
|
data["Bat2_Cyc"] = self.inp_bat2_cyc.text()
|
||||||
|
data["Bat2_Cap"] = self.inp_bat2_cap.text()
|
||||||
|
|
||||||
|
self.settings.setValue("sheet_url", url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
manager = SheetManager(url, self.cfg)
|
||||||
|
res = manager.send_test_results(data)
|
||||||
|
QMessageBox.information(self, "Готово", res)
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Ошибка отправки", str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
try:
|
||||||
|
cfg = load_config()
|
||||||
|
except (FileNotFoundError, ValueError) as e:
|
||||||
|
QMessageBox.critical(None, "Ошибка конфигурации", str(e))
|
||||||
|
sys.exit(1)
|
||||||
|
window = TesterApp(cfg)
|
||||||
|
window.show()
|
||||||
|
sys.exit(app.exec_())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
23
tools/README.md
Normal file
23
tools/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# tools/
|
||||||
|
|
||||||
|
Third-party utilities the app launches. They are not redistributable, so download
|
||||||
|
them yourself and place them in this folder with these exact file names.
|
||||||
|
|
||||||
|
Used by the app:
|
||||||
|
|
||||||
|
| File | What it is | Where to get it |
|
||||||
|
|------|------------|-----------------|
|
||||||
|
| `smartctl.exe` | SSD health/capacity readout (required for disk autodetect) | [smartmontools](https://www.smartmontools.org/) — take `smartctl.exe` from the installed `bin/` |
|
||||||
|
| `battery_view.exe` | Battery cycles/capacity viewer | [NirSoft BatteryInfoView](https://www.nirsoft.net/utils/battery_information_view.html) — rename `BatteryInfoView.exe` |
|
||||||
|
| `IsMyLcdOK.exe` | Dead-pixel / LCD test | [SoftwareOK](http://www.softwareok.com/?seite=Microsoft/IsMyLcdOK) |
|
||||||
|
| `IsMyTouchScreenOK_x64.exe` | Touchscreen test | [SoftwareOK](http://www.softwareok.com/?seite=Software/IsMyTouchScreenOK) |
|
||||||
|
| `KeyboardTest.exe` | Keyboard test (shareware) | [PassMark KeyboardTest](https://www.passmark.com/products/keytest/) |
|
||||||
|
| `Camera.exe` | Camera test (optional — without it the app opens the Windows Camera app) | any portable camera viewer |
|
||||||
|
| `sound_test.mp3` | Stereo speaker test track | any track with distinct left/right channels |
|
||||||
|
|
||||||
|
Optional extras (launched manually, not from the app):
|
||||||
|
|
||||||
|
| File | What it is | Where to get it |
|
||||||
|
|------|------------|-----------------|
|
||||||
|
| `OCCT.exe` | Stress test | [OCBASE/OCCT](https://www.ocbase.com/) |
|
||||||
|
| `CrystalDiskInfo/` | Disk SMART GUI | [CrystalDiskInfo](https://crystalmark.info/en/software/crystaldiskinfo/) |
|
||||||
Loading…
Add table
Reference in a new issue