From 2551359f54a83e54d02fd68c61209a14a27370bf Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Thu, 11 Jun 2026 19:53:17 +0300 Subject: [PATCH] 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 --- .gitignore | 29 ++++ LICENSE | 21 +++ LaptopTester.spec | 38 +++++ README.md | 54 +++++++ README.ru.md | 54 +++++++ config.py | 57 ++++++++ requirements.txt | 5 + scanner.py | 193 ++++++++++++++++++++++++ sheet_config.example.json | 95 ++++++++++++ sheet_manager.py | 87 +++++++++++ tester.py | 299 ++++++++++++++++++++++++++++++++++++++ tools/README.md | 23 +++ 12 files changed, 955 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 LaptopTester.spec create mode 100644 README.md create mode 100644 README.ru.md create mode 100644 config.py create mode 100644 requirements.txt create mode 100644 scanner.py create mode 100644 sheet_config.example.json create mode 100644 sheet_manager.py create mode 100644 tester.py create mode 100644 tools/README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5bae153 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c13f991 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/LaptopTester.spec b/LaptopTester.spec new file mode 100644 index 0000000..d285c01 --- /dev/null +++ b/LaptopTester.spec @@ -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, +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..eef9c31 --- /dev/null +++ b/README.md @@ -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 `//` — 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 diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..d0466d2 --- /dev/null +++ b/README.ru.md @@ -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 diff --git a/config.py b/config.py new file mode 100644 index 0000000..e81ff68 --- /dev/null +++ b/config.py @@ -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", {}), + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..815383b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +PyQt5>=5.15 +WMI>=1.5 +gspread>=5.0 +google-auth>=2.0 +google-auth-oauthlib>=1.0 diff --git a/scanner.py b/scanner.py new file mode 100644 index 0000000..f142eac --- /dev/null +++ b/scanner.py @@ -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 "Не сброшен" diff --git a/sheet_config.example.json b/sheet_config.example.json new file mode 100644 index 0000000..9fdf754 --- /dev/null +++ b/sheet_config.example.json @@ -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" + ] + } +} diff --git a/sheet_manager.py b/sheet_manager.py new file mode 100644 index 0000000..6831a04 --- /dev/null +++ b/sheet_manager.py @@ -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})" diff --git a/tester.py b/tester.py new file mode 100644 index 0000000..0d9d892 --- /dev/null +++ b/tester.py @@ -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() diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..6161c1f --- /dev/null +++ b/tools/README.md @@ -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/) |