commit e17264fb950c8806b4f688cb792c68ba473a75fa Author: bearincapsule Date: Thu Jun 11 19:53:17 2026 +0300 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 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/) |