""" EU-Utility - Skill Scanner Plugin Uses core OCR and Log services via PluginAPI. """ import re import json from datetime import datetime from pathlib import Path from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTableWidget, QTableWidgetItem, QProgressBar, QFrame, QGroupBox, QTextEdit, QSplitter, QComboBox ) from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer from plugins.base_plugin import BasePlugin class SkillOCRThread(QThread): """OCR scan using core service.""" scan_complete = pyqtSignal(dict) scan_error = pyqtSignal(str) progress_update = pyqtSignal(str) def __init__(self, ocr_service): super().__init__() self.ocr_service = ocr_service def run(self): """Perform OCR using core service.""" try: self.progress_update.emit("Capturing screen...") # Use core OCR service result = self.ocr_service.recognize() if 'error' in result and result['error']: self.scan_error.emit(result['error']) return self.progress_update.emit("Parsing skills...") # Parse skills from text skills_data = self._parse_skills(result.get('text', '')) self.scan_complete.emit(skills_data) except Exception as e: self.scan_error.emit(str(e)) def _parse_skills(self, text): """Parse skill data from OCR text with improved handling for 3-column layout.""" skills = {} # Ranks in Entropia Universe (including multi-word ranks) # Single word ranks SINGLE_RANKS = [ 'Newbie', 'Inept', 'Beginner', 'Amateur', 'Average', 'Skilled', 'Expert', 'Professional', 'Master', 'Champion', 'Legendary', 'Guru', 'Astonishing', 'Remarkable', 'Outstanding', 'Marvelous', 'Prodigious', 'Amazing', 'Incredible', 'Awesome' ] # Multi-word ranks (must be checked first - longer matches first) MULTI_RANKS = [ 'Arch Master', 'Grand Master' ] # Combine: multi-word first (so they match before single word), then single ALL_RANKS = MULTI_RANKS + SINGLE_RANKS rank_pattern = '|'.join(ALL_RANKS) # Clean up the text - remove common headers and junk text = text.replace('SKILLS', '').replace('ALL CATEGORIES', '') text = text.replace('SKILL NAME', '').replace('RANK', '').replace('POINTS', '') # Remove category names that appear as standalone words for category in ['Attributes', 'COMBAT', 'Combat', 'Design', 'Construction', 'Defense', 'General', 'Handgun', 'Heavy Melee Weapons', 'Heavy Weapons', 'Information', 'Inflict Melee Damage', 'Inflict Ranged Damage', 'Light Melee Weapons', 'Longblades', 'Medical', 'Mining', 'Science', 'Social', 'Beauty', 'Mindforce']: text = text.replace(category, ' ') # Remove extra whitespace text = ' '.join(text.split()) # Find all skills in the text using finditer for match in re.finditer( rf'([A-Za-z][A-Za-z\s]{{2,50}}?)\s+({rank_pattern})\s+(\d{{1,6}})(?:\s|$)', text, re.IGNORECASE ): skill_name = match.group(1).strip() rank = match.group(2) points = int(match.group(3)) # Clean up skill name - remove common words that might be prepended skill_name = re.sub(r'^(Skill|SKILL)\s*', '', skill_name, flags=re.IGNORECASE) skill_name = skill_name.strip() # Validate - points should be reasonable (not too small) if points > 0 and skill_name and len(skill_name) > 2: skills[skill_name] = { 'rank': rank, 'points': points, 'scanned_at': datetime.now().isoformat() } print(f"[SkillScanner] Parsed: {skill_name} = {rank} ({points})") # If no skills found with primary method, try alternative if not skills: skills = self._parse_skills_alternative(text, ALL_RANKS) return skills def _parse_skills_alternative(self, text, ranks): """Alternative parser for when text is heavily merged.""" skills = {} # Find all rank positions in the text for rank in ranks: # Look for pattern: [text] [Rank] [number] pattern = rf'([A-Z][a-z]{{2,}}(?:\s+[A-Z][a-z]{{2,}}){{0,3}})\s+{re.escape(rank)}\s+(\d{{1,6}})' matches = re.finditer(pattern, text, re.IGNORECASE) for match in matches: skill_name = match.group(1).strip() points = int(match.group(2)) # Clean skill name skill_name = re.sub(r'^(Skill|SKILL)\s*', '', skill_name, flags=re.IGNORECASE) if points > 0 and len(skill_name) > 2: skills[skill_name] = { 'rank': rank, 'points': points, 'scanned_at': datetime.now().isoformat() } return skills class SkillScannerPlugin(BasePlugin): """Scan skills using core OCR and track gains via core Log service.""" name = "Skill Scanner" version = "2.1.0" author = "ImpulsiveFPS" description = "Uses core OCR and Log services" hotkey = "ctrl+shift+s" # Signal for thread-safe hotkey scanning hotkey_triggered = pyqtSignal() def initialize(self): """Setup skill scanner.""" self.data_file = Path("data/skill_tracker.json") self.data_file.parent.mkdir(parents=True, exist_ok=True) # Load saved data self.skills_data = {} self.skill_gains = [] self._load_data() # Multi-page scanning state self.current_scan_session = {} # Skills collected in current multi-page scan self.pages_scanned = 0 # Connect hotkey signal self.hotkey_triggered.connect(self._scan_page_for_multi) # Subscribe to skill gain events from core Log service try: from core.plugin_api import get_api api = get_api() # Check if log service available log_service = api.services.get('log') if log_service: print(f"[SkillScanner] Connected to core Log service") except Exception as e: print(f"[SkillScanner] Could not connect to Log service: {e}") def _load_data(self): """Load saved skill data.""" if self.data_file.exists(): try: with open(self.data_file, 'r') as f: data = json.load(f) self.skills_data = data.get('skills', {}) self.skill_gains = data.get('gains', []) except: pass def _save_data(self): """Save skill data.""" with open(self.data_file, 'w') as f: json.dump({ 'skills': self.skills_data, 'gains': self.skill_gains }, f, indent=2) def get_ui(self): """Create skill scanner UI.""" widget = QWidget() layout = QVBoxLayout(widget) layout.setSpacing(15) layout.setContentsMargins(0, 0, 0, 0) # Header header = QLabel("Skill Tracker") header.setStyleSheet("font-size: 18px; font-weight: bold; color: white;") layout.addWidget(header) # Info about core services info = self._get_service_status() info_label = QLabel(info) info_label.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;") layout.addWidget(info_label) # Splitter splitter = QSplitter(Qt.Orientation.Vertical) # Scan section scan_group = QGroupBox("OCR Scan (Core Service)") scan_layout = QVBoxLayout(scan_group) # Buttons row buttons_layout = QHBoxLayout() scan_btn = QPushButton("Scan Skills Window") scan_btn.setStyleSheet(""" QPushButton { background-color: #ff8c42; color: white; padding: 12px; border: none; border-radius: 4px; font-weight: bold; } """) scan_btn.clicked.connect(self._scan_skills) buttons_layout.addWidget(scan_btn) reset_btn = QPushButton("Reset Data") reset_btn.setStyleSheet(""" QPushButton { background-color: #ff4757; color: white; padding: 12px; border: none; border-radius: 4px; font-weight: bold; } """) reset_btn.clicked.connect(self._reset_data) buttons_layout.addWidget(reset_btn) scan_layout.addLayout(buttons_layout) self.scan_progress = QLabel("Ready to scan") self.scan_progress.setStyleSheet("color: rgba(255,255,255,150);") scan_layout.addWidget(self.scan_progress) self.skills_table = QTableWidget() self.skills_table.setColumnCount(3) self.skills_table.setHorizontalHeaderLabels(["Skill", "Rank", "Points"]) self.skills_table.horizontalHeader().setStretchLastSection(True) scan_layout.addWidget(self.skills_table) splitter.addWidget(scan_group) # Multi-Page Scanning section multi_page_group = QGroupBox("Multi-Page Scanner") multi_page_layout = QVBoxLayout(multi_page_group) # Mode selection mode_layout = QHBoxLayout() mode_layout.addWidget(QLabel("Mode:")) self.scan_mode_combo = QComboBox() self.scan_mode_combo.addItems(["Smart Auto + Hotkey Fallback", "Manual Hotkey Only", "Manual Click Only"]) self.scan_mode_combo.currentIndexChanged.connect(self._on_scan_mode_changed) mode_layout.addWidget(self.scan_mode_combo) mode_layout.addStretch() multi_page_layout.addLayout(mode_layout) # Instructions self.instructions_label = QLabel( "🤖 SMART MODE:\n" "1. Position Skills window\n" "2. Click 'Start Smart Scan'\n" "3. Navigate pages in EU - auto-detect will scan for you\n" "4. If auto fails, use hotkey F12 to scan manually\n" "5. Click 'Save All' when done" ) self.instructions_label.setStyleSheet("color: #888; font-size: 11px;") multi_page_layout.addWidget(self.instructions_label) # Hotkey info self.hotkey_info = QLabel("Hotkey: F12 = Scan Current Page") self.hotkey_info.setStyleSheet("color: #4ecdc4; font-weight: bold;") multi_page_layout.addWidget(self.hotkey_info) # Status row status_layout = QHBoxLayout() self.multi_page_status = QLabel("⏳ Ready to scan page 1") self.multi_page_status.setStyleSheet("color: #ff8c42; font-size: 14px;") status_layout.addWidget(self.multi_page_status) self.pages_scanned_label = QLabel("Pages: 0") self.pages_scanned_label.setStyleSheet("color: #4ecdc4;") status_layout.addWidget(self.pages_scanned_label) self.total_skills_label = QLabel("Skills: 0") self.total_skills_label.setStyleSheet("color: #4ecdc4;") status_layout.addWidget(self.total_skills_label) status_layout.addStretch() multi_page_layout.addLayout(status_layout) # Buttons mp_buttons_layout = QHBoxLayout() self.scan_page_btn = QPushButton("▶️ Start Smart Scan") self.scan_page_btn.setStyleSheet(""" QPushButton { background-color: #4ecdc4; color: #141f23; padding: 12px; border: none; border-radius: 4px; font-weight: bold; font-size: 13px; } QPushButton:hover { background-color: #3dbdb4; } """) self.scan_page_btn.clicked.connect(self._start_smart_scan) mp_buttons_layout.addWidget(self.scan_page_btn) save_all_btn = QPushButton("💾 Save All Scanned") save_all_btn.setStyleSheet(""" QPushButton { background-color: #ff8c42; color: white; padding: 12px; border: none; border-radius: 4px; font-weight: bold; font-size: 13px; } """) save_all_btn.clicked.connect(self._save_multi_page_scan) mp_buttons_layout.addWidget(save_all_btn) clear_session_btn = QPushButton("🗑 Clear Session") clear_session_btn.setStyleSheet(""" QPushButton { background-color: #666; color: white; padding: 12px; border: none; border-radius: 4px; } """) clear_session_btn.clicked.connect(self._clear_multi_page_session) mp_buttons_layout.addWidget(clear_session_btn) multi_page_layout.addLayout(mp_buttons_layout) # Current session table self.session_table = QTableWidget() self.session_table.setColumnCount(3) self.session_table.setHorizontalHeaderLabels(["Skill", "Rank", "Points"]) self.session_table.horizontalHeader().setStretchLastSection(True) self.session_table.setMaximumHeight(200) self.session_table.setStyleSheet(""" QTableWidget { background-color: #0d1117; border: 1px solid #333; } QTableWidget::item { padding: 4px; color: #c9d1d9; } """) multi_page_layout.addWidget(self.session_table) splitter.addWidget(multi_page_group) # Log section log_group = QGroupBox("Log Tracking (Core Service)") log_layout = QVBoxLayout(log_group) log_status = QLabel("Skill gains tracked from chat log") log_status.setStyleSheet("color: #4ecdc4;") log_layout.addWidget(log_status) self.gains_text = QTextEdit() self.gains_text.setReadOnly(True) self.gains_text.setMaximumHeight(150) self.gains_text.setPlaceholderText("Recent skill gains from core Log service...") log_layout.addWidget(self.gains_text) self.total_gains_label = QLabel(f"Total gains: {len(self.skill_gains)}") self.total_gains_label.setStyleSheet("color: rgba(255,255,255,150);") log_layout.addWidget(self.total_gains_label) splitter.addWidget(log_group) layout.addWidget(splitter) self._refresh_skills_table() return widget def _get_service_status(self) -> str: """Get status of core services.""" try: from core.ocr_service import get_ocr_service from core.log_reader import get_log_reader ocr = get_ocr_service() log = get_log_reader() ocr_status = "✓" if ocr.is_available() else "✗" log_status = "✓" if log.is_available() else "✗" return f"Core Services - OCR: {ocr_status} Log: {log_status}" except: return "Core Services - status unknown" def _scan_skills(self): """Start OCR scan using core service.""" try: from core.ocr_service import get_ocr_service ocr_service = get_ocr_service() if not ocr_service.is_available(): self.scan_progress.setText("Error: OCR service not available") return self.scanner = SkillOCRThread(ocr_service) self.scanner.scan_complete.connect(self._on_scan_complete) self.scanner.scan_error.connect(self._on_scan_error) self.scanner.progress_update.connect(self._on_scan_progress) self.scanner.start() except Exception as e: self.scan_progress.setText(f"Error: {e}") def _on_scan_progress(self, message): self.scan_progress.setText(message) def _on_scan_complete(self, skills_data): self.skills_data.update(skills_data) self._save_data() self._refresh_skills_table() self.scan_progress.setText(f"Found {len(skills_data)} skills") def _reset_data(self): """Reset all skill data.""" from PyQt6.QtWidgets import QMessageBox reply = QMessageBox.question( None, "Reset Skill Data", "Are you sure you want to clear all scanned skill data?\n\nThis cannot be undone.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: self.skills_data = {} self.skill_gains = [] self._save_data() self._refresh_skills_table() self.gains_text.clear() self.total_gains_label.setText("Total gains: 0") self.scan_progress.setText("Data cleared") def _on_scan_error(self, error): self.scan_progress.setText(f"Error: {error}") self.scan_progress.setText(f"Error: {error}") def _refresh_skills_table(self): self.skills_table.setRowCount(len(self.skills_data)) for i, (name, data) in enumerate(sorted(self.skills_data.items())): self.skills_table.setItem(i, 0, QTableWidgetItem(name)) self.skills_table.setItem(i, 1, QTableWidgetItem(data.get('rank', '-'))) self.skills_table.setItem(i, 2, QTableWidgetItem(str(data.get('points', 0)))) def _scan_page_for_multi(self): """Scan current page and add to multi-page session.""" from PyQt6.QtCore import QTimer self.multi_page_status.setText("📷 Scanning...") self.multi_page_status.setStyleSheet("color: #ffd93d;") self.scan_page_btn.setEnabled(False) # Run scan in thread from threading import Thread def do_scan(): try: from core.ocr_service import get_ocr_service import re from datetime import datetime ocr_service = get_ocr_service() if not ocr_service.is_available(): self._update_multi_page_status("Error: OCR not available", error=True) return # Capture and OCR result = ocr_service.recognize() text = result.get('text', '') # Parse skills skills = self._parse_skills_from_text(text) # Add to session for skill_name, data in skills.items(): self.current_scan_session[skill_name] = data self.pages_scanned += 1 # Update UI from PyQt6.QtCore import QMetaObject, Qt, Q_ARG QMetaObject.invokeMethod( self, "_update_session_table", Qt.ConnectionType.QueuedConnection, Q_ARG(object, self.current_scan_session) ) # Show success with checkmark and beep self._update_multi_page_status( f"✅ Page {self.pages_scanned} scanned! {len(skills)} skills found. Click Next Page in game →", success=True ) # Play beep sound self._play_beep() except Exception as e: self._update_multi_page_status(f"Error: {str(e)}", error=True) finally: from PyQt6.QtCore import QMetaObject, Qt, Q_ARG QMetaObject.invokeMethod( self.scan_page_btn, "setEnabled", Qt.ConnectionType.QueuedConnection, Q_ARG(bool, True) ) thread = Thread(target=do_scan) thread.daemon = True thread.start() def _parse_skills_from_text(self, text): """Parse skills from OCR text.""" skills = {} # Ranks in Entropia Universe - multi-word first for proper matching SINGLE_RANKS = [ 'Newbie', 'Inept', 'Beginner', 'Amateur', 'Average', 'Skilled', 'Expert', 'Professional', 'Master', 'Champion', 'Legendary', 'Guru', 'Astonishing', 'Remarkable', 'Outstanding', 'Marvelous', 'Prodigious', 'Amazing', 'Incredible', 'Awesome' ] MULTI_RANKS = ['Arch Master', 'Grand Master'] ALL_RANKS = MULTI_RANKS + SINGLE_RANKS rank_pattern = '|'.join(ALL_RANKS) # Clean text text = text.replace('SKILLS', '').replace('ALL CATEGORIES', '') text = text.replace('SKILL NAME', '').replace('RANK', '').replace('POINTS', '') # Remove category names for category in ['Attributes', 'COMBAT', 'Combat', 'Design', 'Construction', 'Defense', 'General', 'Handgun', 'Heavy Melee Weapons', 'Heavy Weapons', 'Information', 'Inflict Melee Damage', 'Inflict Ranged Damage', 'Light Melee Weapons', 'Longblades', 'Medical', 'Mining', 'Science', 'Social', 'Beauty', 'Mindforce']: text = text.replace(category, ' ') text = ' '.join(text.split()) # Find all skills import re for match in re.finditer( rf'([A-Za-z][A-Za-z\s]{{2,50}}?)\s+({rank_pattern})\s+(\d{{1,6}})(?:\s|$)', text, re.IGNORECASE ): skill_name = match.group(1).strip() rank = match.group(2) points = int(match.group(3)) skill_name = re.sub(r'^(Skill|SKILL)\s*', '', skill_name, flags=re.IGNORECASE) skill_name = skill_name.strip() if points > 0 and skill_name and len(skill_name) > 2: skills[skill_name] = {'rank': rank, 'points': points} return skills def _update_multi_page_status(self, message, success=False, error=False): """Update multi-page status label.""" from PyQt6.QtCore import QMetaObject, Qt, Q_ARG color = "#4ecdc4" if success else "#ff4757" if error else "#ff8c42" QMetaObject.invokeMethod( self.multi_page_status, "setText", Qt.ConnectionType.QueuedConnection, Q_ARG(str, message) ) QMetaObject.invokeMethod( self.multi_page_status, "setStyleSheet", Qt.ConnectionType.QueuedConnection, Q_ARG(str, f"color: {color}; font-size: 14px;") ) # Update counters QMetaObject.invokeMethod( self.pages_scanned_label, "setText", Qt.ConnectionType.QueuedConnection, Q_ARG(str, f"Pages: {self.pages_scanned}") ) QMetaObject.invokeMethod( self.total_skills_label, "setText", Qt.ConnectionType.QueuedConnection, Q_ARG(str, f"Skills: {len(self.current_scan_session)}") ) def _play_beep(self): """Play a beep sound to notify user.""" try: import winsound winsound.MessageBeep(winsound.MB_OK) except: # Fallback - try to use system beep try: print('\a') # ASCII bell character except: pass def _update_session_table(self, skills): """Update the session table with current scan data.""" self.session_table.setRowCount(len(skills)) for i, (name, data) in enumerate(sorted(skills.items())): self.session_table.setItem(i, 0, QTableWidgetItem(name)) self.session_table.setItem(i, 1, QTableWidgetItem(data.get('rank', '-'))) self.session_table.setItem(i, 2, QTableWidgetItem(str(data.get('points', 0)))) def _save_multi_page_scan(self): """Save all scanned skills from multi-page session.""" if not self.current_scan_session: from PyQt6.QtWidgets import QMessageBox QMessageBox.information(None, "No Data", "No skills scanned yet. Scan some pages first!") return # Merge with existing data self.skills_data.update(self.current_scan_session) self._save_data() self._refresh_skills_table() from PyQt6.QtWidgets import QMessageBox QMessageBox.information( None, "Scan Complete", f"Saved {len(self.current_scan_session)} skills from {self.pages_scanned} pages!" ) # Clear session after saving self._clear_multi_page_session() def _clear_multi_page_session(self): """Clear the current multi-page scanning session.""" self.current_scan_session = {} self.pages_scanned = 0 self.auto_scan_active = False self.session_table.setRowCount(0) self.multi_page_status.setText("⏳ Ready to scan page 1") self.multi_page_status.setStyleSheet("color: #ff8c42; font-size: 14px;") self.pages_scanned_label.setText("Pages: 0") self.total_skills_label.setText("Skills: 0") # Unregister hotkey if active self._unregister_hotkey() def _on_scan_mode_changed(self, index): """Handle scan mode change.""" modes = [ "🤖 SMART MODE:\n1. Position Skills window\n2. Click 'Start Smart Scan'\n3. Navigate pages - auto-detect will scan\n4. If auto fails, press F12\n5. Click 'Save All' when done", "⌨️ HOTKEY MODE:\n1. Position Skills window\n2. Navigate to page 1 in EU\n3. Press F12 to scan each page\n4. Click Next Page in EU\n5. Repeat F12 for each page", "🖱️ MANUAL MODE:\n1. Position Skills window\n2. Click 'Scan Current Page'\n3. Wait for beep\n4. Click Next Page in EU\n5. Repeat" ] self.instructions_label.setText(modes[index]) def _start_smart_scan(self): """Start smart auto-scan with hotkey fallback.""" mode = self.scan_mode_combo.currentIndex() if mode == 0: # Smart Auto + Hotkey self._start_auto_scan_with_hotkey() elif mode == 1: # Hotkey only self._register_hotkey() self._update_multi_page_status("Hotkey F12 ready! Navigate to first page and press F12", success=True) else: # Manual click self._scan_page_for_multi() def _start_auto_scan_with_hotkey(self): """Start auto-detection with fallback to hotkey.""" self.auto_scan_active = True self.auto_scan_failures = 0 self.last_page_number = None # Register F12 hotkey as fallback self._register_hotkey() # Start monitoring self._update_multi_page_status("🤖 Auto-detect started! Navigate to page 1...", success=True) # Start auto-detection timer self.auto_scan_timer = QTimer() self.auto_scan_timer.timeout.connect(self._check_for_page_change) self.auto_scan_timer.start(500) # Check every 500ms def _register_hotkey(self): """Register F12 hotkey for manual scan.""" try: import keyboard keyboard.on_press_key('f12', lambda e: self._hotkey_scan()) self.hotkey_registered = True except Exception as e: print(f"[SkillScanner] Could not register hotkey: {e}") self.hotkey_registered = False def _unregister_hotkey(self): """Unregister hotkey.""" try: if hasattr(self, 'hotkey_registered') and self.hotkey_registered: import keyboard keyboard.unhook_all() self.hotkey_registered = False except: pass # Stop auto-scan timer if hasattr(self, 'auto_scan_timer') and self.auto_scan_timer: self.auto_scan_timer.stop() self.auto_scan_active = False def _hotkey_scan(self): """Scan triggered by F12 hotkey - thread safe via signal.""" # Emit signal to safely call from hotkey thread self.hotkey_triggered.emit() def _check_for_page_change(self): """Auto-detect page changes by monitoring page number area.""" if not self.auto_scan_active: return try: from PIL import ImageGrab import pytesseract # Capture page number area (bottom center of skills window) # This is approximate - may need adjustment screen = ImageGrab.grab() width, height = screen.size # Try to capture the page number area (bottom center, small region) # EU skills window shows page like "1/12" at bottom page_area = (width // 2 - 50, height - 100, width // 2 + 50, height - 50) page_img = ImageGrab.grab(bbox=page_area) # OCR just the page number page_text = pytesseract.image_to_string(page_img, config='--psm 7 -c tessedit_char_whitelist=0123456789/') # Extract current page number import re match = re.search(r'(\d+)/(\d+)', page_text) if match: current_page = int(match.group(1)) total_pages = int(match.group(2)) # If page changed, trigger scan if self.last_page_number is not None and current_page != self.last_page_number: self._update_multi_page_status(f"📄 Page change detected: {current_page}/{total_pages}", success=True) self._scan_page_for_multi() self.last_page_number = current_page else: # Failed to detect page number self.auto_scan_failures += 1 if self.auto_scan_failures >= 10: # After 5 seconds of failures self._fallback_to_hotkey() except Exception as e: self.auto_scan_failures += 1 if self.auto_scan_failures >= 10: self._fallback_to_hotkey() def _fallback_to_hotkey(self): """Fallback to hotkey mode when auto-detection fails.""" if hasattr(self, 'auto_scan_timer') and self.auto_scan_timer: self.auto_scan_timer.stop() self.auto_scan_active = False # Keep hotkey registered self._update_multi_page_status( "⚠️ Auto-detect unreliable. Use F12 hotkey to scan each page manually!", error=True ) # Play alert sound self._play_beep()