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:
Andrew Pimenov 2026-06-11 19:53:17 +03:00
commit 2551359f54
12 changed files with 955 additions and 0 deletions

29
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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/) |