From 1ce560f13a41b96ff60ac7606b99e12f11a90fa4 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sat, 13 Jun 2026 19:14:20 +0300 Subject: [PATCH] update copyright year to 2026, add architecture docs --- ARCHITECTURE.md | 107 ++++++++++++++++++++++++++++++++++++++++++++ ARCHITECTURE.ru.md | 109 +++++++++++++++++++++++++++++++++++++++++++++ LICENSE | 2 +- 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 ARCHITECTURE.md create mode 100644 ARCHITECTURE.ru.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..65f846e --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,107 @@ +# LaptopTester Architecture + +## Files + +``` +config.py # Load sheet_config.json, validate, expose AppConfig dataclass +scanner.py # WMI/smartctl hardware detection in QThread; matching logic +sheet_manager.py # Google Sheets auth (service account or OAuth) and row upsert +tester.py # PyQt5 GUI: pages, QLineEdit/QComboBox fields, thread signals +sheet_config.example.json # Template: worksheet, column numbers, hardware lists, brand aliases +requirements.txt # Dependencies: PyQt5, WMI, gspread, google-auth libs +LaptopTester.spec # PyInstaller config (datas, hidden imports) +``` + +## How hardware scan flows + +1. User starts app → `TesterApp.__init__` checks admin rights, builds UI (3 pages: load, hardware, tests) +2. `start_scan()` spawns `HardwareScanner` QThread with `AppConfig` +3. Scanner queries WMI for brand, model, CPU, RAM; smartctl for SSD SMART data +4. `find_best_match(raw_wmi_name, config_list)` normalizes names and tokens to match against lists + - Exact normalized match wins + - Falls back to longest list entry whose tokens all appear in raw name + - For GPU entries like "NVIDIA MX: MX450", also tries matching only the model part (after `:`) +5. Scanner emits `log()` signal → progress label updates; emits `finished(dict)` → `on_scan_finished()` +6. `on_scan_finished()` populates combo boxes: finds matching list entry, calls `setCurrentIndex()` + +## How results flow to Google Sheets + +1. User fills hardware page, checks tests, clicks "ОТПРАВИТЬ" +2. `finish_testing()` gathers all form data into dict +3. If second battery group checked, adds `Bat2_Cyc`/`Bat2_Cap` +4. Validates serial number (non-empty) — shows warning if missing +5. Creates `SheetManager(url, cfg)` → connects to sheet (service account or OAuth) +6. Calls `send_test_results(data_dict)`: + - Finds row by serial number in `cfg.serial_column` + - If found: updates; if not found: appends after last filled row + - Iterates config columns: for each key in `cfg.columns`, writes corresponding data value +7. Returns status message, shows in QMessageBox + +## Adding new hardware checks + +1. Edit `sheet_config.example.json` `lists.gpus` (or other list) +2. Add brand alias if needed: `brand_aliases: {"Raw Name": "Canonical"}` +3. Restart app — scanner uses new lists + +To add a _new type_ of check (e.g., WiFi module): + +1. Add to `sheet_config.example.json` columns: `"WiFi": 51` (pick next free column) +2. Add to `tester.py` `init_tests_ui()`: + ```python + add(17, "WiFi", ["+", "-", "Не инфы"], "🌐", None) + ``` +3. Add to `finish_testing()` data dict: + ```python + "WiFi": self.checks["WiFi"].currentText(), + ``` +4. Update example config to include in `columns` so it gets written + +## Adding new hardware detection + +1. Add field to `scanner.py` `HardwareScanner.run()`: + ```python + self.log.emit("New hardware...") + data["NewField"] = self.detect_new_field() + ``` +2. Add corresponding column to `sheet_config.example.json` +3. Add combo box or text field to `tester.py` hardware page +4. Add to data dict in `finish_testing()` + +## Gotchas + +- **WMI names are messy:** "NVIDIA GeForce RTX 3060" vs config "NVIDIA RTX: 3060". Token matching handles this, but config lists must be carefully curated (ditch duplicates like both "GeForce GT 620M" and "NVIDIA GT: GT 620M"). +- **SSD sizes:** Drives report decimal GB (1 TB = 1e12 bytes), but older `int(bytes / 1024**3)` gave binary GiB. Now matches by closest size ±7%, so 240 GB at 256e9 bytes finds "240 GB" correctly. +- **AMD Ryzen format:** WMI says "Ryzen 5 3500U", config lists use "R5 3500U". Scanner normalizes "Ryzen X" → "RX" before matching. +- **Column order matters:** `sheet_config.json` `columns` dict values are 1-indexed Google Sheets column numbers. Serial column is `serial_column` (used for find/upsert logic), separate from `columns` map. +- **Rescan:** Once started, `HardwareScanner` thread blocks until done. If user clicks Rescan again before done, old thread keeps running (potential race). Check `scanner.isRunning()` before starting new one, or emit a signal to prevent UI clicks. +- **Password reset:** `net accounts /maxpwage:unlimited` changes _system state_. Runs silently in background; returncode 0 = success. Needs admin rights or fails silently. +- **Empty serial number:** `sheet_manager` checks for non-empty serial; if empty, returns error string (not written to sheet). Caller (`tester.py`) should validate before calling. + +## Config format + +`sheet_config.json` structure: +```json +{ + "worksheet": "Laptops", + "serial_column": 21, + "columns": { + "Brand": 19, + "CPU": 22, + ... + }, + "brand_aliases": { + "Hewlett-Packard": "HP", + ... + }, + "lists": { + "brands": ["HP", "Lenovo", ...], + "models": [...], + "cpus": [...], + "ram": [...], + "ssd": [...], + "gpus": ["", "NVIDIA RTX: 3060", ...] + } +} +``` + +Empty string in GPU list = "no dedicated GPU" (matches Intel/AMD integrated). diff --git a/ARCHITECTURE.ru.md b/ARCHITECTURE.ru.md new file mode 100644 index 0000000..5439a9c --- /dev/null +++ b/ARCHITECTURE.ru.md @@ -0,0 +1,109 @@ +[English](ARCHITECTURE.md) + +# LaptopTester: архитектура + +## Файлы + +``` +config.py # Загрузка sheet_config.json, валидация, AppConfig dataclass +scanner.py # Определение железа (WMI/smartctl) в QThread; логика матчинга +sheet_manager.py # Google Sheets auth (service account или OAuth) и запись строк +tester.py # PyQt5 GUI: страницы, QLineEdit/QComboBox, сигналы потока +sheet_config.example.json # Шаблон: имя листа, номера столбцов, списки железа, алиасы брендов +requirements.txt # Зависимости: PyQt5, WMI, gspread, google-auth +LaptopTester.spec # Конфиг PyInstaller (datas, hidden imports) +``` + +## Поток сканирования железа + +1. Пользователь запускает приложение → `TesterApp.__init__` проверяет права админа, строит UI (3 страницы: загрузка, железо, тесты) +2. `start_scan()` создаёт `HardwareScanner` QThread с `AppConfig` +3. Scanner запрашивает WMI (бренд, модель, CPU, RAM) и smartctl (SMART данные SSD) +4. `find_best_match(raw_wmi_name, config_list)` нормализует имена и токены для сопоставления + - Точное совпадение нормализованного названия побеждает + - Fallback: самый длинный элемент из списка, чьи токены всё содержатся в сыром имени + - Для GPU типа "NVIDIA MX: MX450" также пытается матчить только часть после `:` +5. Scanner отправляет `log()` сигнал → обновляется label прогресса; отправляет `finished(dict)` → вызывает `on_scan_finished()` +6. `on_scan_finished()` заполняет комбо-боксы: находит совпадающий элемент списка, вызывает `setCurrentIndex()` + +## Поток передачи результатов в Google Sheets + +1. Пользователь заполняет страницу железа, отмечает тесты, нажимает "ОТПРАВИТЬ" +2. `finish_testing()` собирает все данные формы в dict +3. Если включена группа второй батареи, добавляет `Bat2_Cyc`/`Bat2_Cap` +4. Валидирует серийный номер (не пустой) — показывает warning если пуст +5. Создаёт `SheetManager(url, cfg)` → подключается к таблице (service account или OAuth) +6. Вызывает `send_test_results(data_dict)`: + - Ищет строку по серийному номеру в `cfg.serial_column` + - Если найдена: обновляет; если не найдена: добавляет после последней заполненной + - Итерирует столбцы из конфига: для каждого ключа в `cfg.columns` пишет соответствующее значение +7. Возвращает сообщение статуса, показывает в QMessageBox + +## Добавление нового теста железа + +1. Отредактируй `sheet_config.example.json` раздел `lists.gpus` (или другой список) +2. Добавь алиас бренда если нужно: `brand_aliases: {"Сырое имя": "Каноническое"}` +3. Перезапусти приложение — scanner использует новые списки + +Чтобы добавить _новый тип_ проверки (например, WiFi): + +1. Добавь в `sheet_config.example.json` столбец: `"WiFi": 51` (выбери свободный номер) +2. Добавь в `tester.py` функцию `init_tests_ui()`: + ```python + add(17, "WiFi", ["+", "-", "Нет инфы"], "🌐", None) + ``` +3. Добавь в `finish_testing()` в data dict: + ```python + "WiFi": self.checks["WiFi"].currentText(), + ``` +4. Обнови example-конфиг чтобы включить в `columns` — тогда будет писаться в таблицу + +## Добавление нового определения железа + +1. Добавь поле в `scanner.py` метод `HardwareScanner.run()`: + ```python + self.log.emit("New hardware...") + data["NewField"] = self.detect_new_field() + ``` +2. Добавь соответствующий столбец в `sheet_config.example.json` +3. Добавь комбо-бокс или текстовое поле в `tester.py` страницу железа +4. Добавь в data dict в `finish_testing()` + +## Хитрости + +- **WMI имена грязные:** "NVIDIA GeForce RTX 3060" vs конфиг "NVIDIA RTX: 3060". Токенный матчинг это решает, но списки конфига нужно тщательно курировать (убирать дубликаты как "GeForce GT 620M" и "NVIDIA GT: GT 620M" одновременно). +- **Размеры SSD:** Диски报告ют десятичный GB (1 TB = 1e12 байт), но старый `int(bytes / 1024**3)` давал двоичный GiB. Теперь матчим по ближайшему размеру ±7%, так что 240 GB при 256e9 байт находит "240 GB" корректно. +- **AMD Ryzen формат:** WMI говорит "Ryzen 5 3500U", конфиг-списки используют "R5 3500U". Scanner нормализует "Ryzen X" → "RX" перед матчингом. +- **Порядок столбцов важен:** `sheet_config.json` ключи `columns` — это 1-индексированные номера столбцов Google Sheets. Столбец серийника — `serial_column` (используется для поиска/обновления), отдельно от карты `columns`. +- **Рескан:** Запущенный `HardwareScanner` поток блокирует UI до завершения. Если пользователь кликнет Рескан до конца, старый поток продолжает работать (potential race). Проверь `scanner.isRunning()` перед стартом нового, или заблокируй клик кнопки сигналом. +- **Сброс пароля:** `net accounts /maxpwage:unlimited` изменяет _состояние системы_. Выполняется молча в фоне; returncode 0 = успех. Нужны права админа или падает молча. +- **Пустой серийник:** `sheet_manager` проверяет на непустоту; если пуст, возвращает строку-ошибку (не пишется в таблицу). Вызывающий код (`tester.py`) должен валидировать перед вызовом. + +## Формат конфигурации + +Структура `sheet_config.json`: +```json +{ + "worksheet": "Laptops", + "serial_column": 21, + "columns": { + "Brand": 19, + "CPU": 22, + ... + }, + "brand_aliases": { + "Hewlett-Packard": "HP", + ... + }, + "lists": { + "brands": ["HP", "Lenovo", ...], + "models": [...], + "cpus": [...], + "ram": [...], + "ssd": [...], + "gpus": ["", "NVIDIA RTX: 3060", ...] + } +} +``` + +Пустая строка в списке GPU = "нет дискретной видеокарты" (матчится с Intel/AMD встроенными). diff --git a/LICENSE b/LICENSE index 99dc809..a429eb6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Andrew Pimenov +Copyright (c) 2026 Andrew Pimenov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal