diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..a69afe6 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,5 @@ +""" +EU-Utility Core Module + +Core services and base classes for the plugin system. +""" diff --git a/core/__pycache__/__init__.cpython-312.pyc b/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..67ddb9b Binary files /dev/null and b/core/__pycache__/__init__.cpython-312.pyc differ diff --git a/core/base_plugin.py b/core/base_plugin.py new file mode 100644 index 0000000..ab9ad5d --- /dev/null +++ b/core/base_plugin.py @@ -0,0 +1,129 @@ +""" +Base Plugin class for EU-Utility plugin system. +All plugins must inherit from this class. +""" + +from abc import ABC, abstractmethod +from typing import Optional, Any, Dict, List + + +class BasePlugin(ABC): + """ + Abstract base class for all EU-Utility plugins. + + Plugins must implement: + - name: Plugin identifier + - description: Brief description + - version: Plugin version + - on_start(): Initialization logic + - on_stop(): Cleanup logic + """ + + # Plugin metadata (override in subclass) + name: str = "base_plugin" + description: str = "Base plugin class" + version: str = "1.0.0" + author: str = "Unknown" + + def __init__(self): + self._initialized = False + self._config: Dict[str, Any] = {} + self._clipboard_manager: Optional[Any] = None + + @abstractmethod + def on_start(self) -> None: + """Called when the plugin is started. Initialize resources here.""" + pass + + @abstractmethod + def on_stop(self) -> None: + """Called when the plugin is stopped. Clean up resources here.""" + pass + + def is_initialized(self) -> bool: + """Check if the plugin has been initialized.""" + return self._initialized + + def get_config(self) -> Dict[str, Any]: + """Get plugin configuration.""" + return self._config.copy() + + def set_config(self, config: Dict[str, Any]) -> None: + """Set plugin configuration.""" + self._config = config.copy() + + # Clipboard Service Integration + + def _set_clipboard_manager(self, manager: Any) -> None: + """Internal method to set the clipboard manager reference.""" + self._clipboard_manager = manager + + def copy_to_clipboard(self, text: str) -> bool: + """ + Copy text to the system clipboard. + + Args: + text: The text to copy + + Returns: + True if successful, False otherwise + + Example: + # Copy coordinates to clipboard + self.copy_to_clipboard("123, 456") + """ + if self._clipboard_manager is None: + print(f"[{self.name}] Clipboard manager not available") + return False + return self._clipboard_manager.copy(text, source=self.name) + + def paste_from_clipboard(self) -> Optional[str]: + """ + Get the current content from the system clipboard. + + Returns: + The clipboard content, or None if unavailable + + Example: + # Read user-pasted value + user_input = self.paste_from_clipboard() + if user_input: + process_input(user_input) + """ + if self._clipboard_manager is None: + print(f"[{self.name}] Clipboard manager not available") + return None + return self._clipboard_manager.paste() + + def get_clipboard_history(self, limit: Optional[int] = None) -> List[Dict[str, Any]]: + """ + Get the clipboard history. + + Args: + limit: Maximum number of entries to return (default: all) + + Returns: + List of clipboard history entries as dictionaries + + Example: + # Get last 10 clipboard entries + history = self.get_clipboard_history(limit=10) + for entry in history: + print(f"{entry['timestamp']}: {entry['content']}") + """ + if self._clipboard_manager is None: + print(f"[{self.name}] Clipboard manager not available") + return [] + + entries = self._clipboard_manager.get_history(limit=limit) + return [entry.to_dict() for entry in entries] + + def clear_clipboard_history(self) -> None: + """Clear the clipboard history.""" + if self._clipboard_manager is None: + print(f"[{self.name}] Clipboard manager not available") + return + self._clipboard_manager.clear_history() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(name='{self.name}', version='{self.version}')" diff --git a/core/plugin_api.py b/core/plugin_api.py new file mode 100644 index 0000000..eccf3f8 --- /dev/null +++ b/core/plugin_api.py @@ -0,0 +1,235 @@ +""" +Plugin API for EU-Utility plugin system. +Manages plugin lifecycle and provides service registration. +""" + +import importlib +import os +import sys +from pathlib import Path +from typing import Dict, List, Optional, Type, Any + +from core.base_plugin import BasePlugin +from core.clipboard import ClipboardManager, get_clipboard_manager + + +class PluginAPI: + """ + Plugin API for EU-Utility. + + Responsibilities: + - Plugin discovery and loading + - Plugin lifecycle management (start/stop) + - Service registration (clipboard, etc.) + """ + + def __init__(self): + self._plugins: Dict[str, BasePlugin] = {} + self._plugin_classes: Dict[str, Type[BasePlugin]] = {} + self._clipboard_manager: Optional[ClipboardManager] = None + self._services_registered = False + + # Service Registration + + def register_clipboard_service(self, auto_start_monitoring: bool = False) -> ClipboardManager: + """ + Register and initialize the clipboard service. + + This makes clipboard functionality available to all plugins. + Must be called before loading plugins that use clipboard features. + + Args: + auto_start_monitoring: Whether to start clipboard monitoring automatically + + Returns: + The initialized ClipboardManager instance + + Example: + api = PluginAPI() + api.register_clipboard_service(auto_start_monitoring=True) + api.load_plugins() + """ + self._clipboard_manager = get_clipboard_manager() + + if auto_start_monitoring: + self._clipboard_manager.start_monitoring() + print("[PluginAPI] Clipboard monitoring started") + + # Inject clipboard manager into all loaded plugins + for plugin in self._plugins.values(): + plugin._set_clipboard_manager(self._clipboard_manager) + + print("[PluginAPI] Clipboard service registered") + return self._clipboard_manager + + def get_clipboard_manager(self) -> Optional[ClipboardManager]: + """Get the clipboard manager if registered.""" + return self._clipboard_manager + + # Plugin Management + + def load_plugin(self, plugin_class: Type[BasePlugin]) -> BasePlugin: + """ + Load a single plugin class. + + Args: + plugin_class: The plugin class to instantiate + + Returns: + The instantiated plugin + """ + plugin = plugin_class() + + # Inject clipboard manager if available + if self._clipboard_manager: + plugin._set_clipboard_manager(self._clipboard_manager) + + self._plugins[plugin.name] = plugin + self._plugin_classes[plugin.name] = plugin_class + + print(f"[PluginAPI] Loaded plugin: {plugin.name} v{plugin.version}") + return plugin + + def load_plugins_from_directory(self, directory: str = "plugins") -> List[BasePlugin]: + """ + Discover and load all plugins from a directory. + + Args: + directory: Path to the plugins directory + + Returns: + List of loaded plugins + """ + plugins_dir = Path(directory) + if not plugins_dir.exists(): + print(f"[PluginAPI] Plugins directory not found: {directory}") + return [] + + # Add plugins directory to path + if str(plugins_dir.absolute()) not in sys.path: + sys.path.insert(0, str(plugins_dir.absolute())) + + loaded = [] + + for file_path in plugins_dir.glob("*.py"): + if file_path.name.startswith("_"): + continue + + try: + module_name = file_path.stem + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + # Find plugin classes in the module + for attr_name in dir(module): + attr = getattr(module, attr_name) + if (isinstance(attr, type) and + issubclass(attr, BasePlugin) and + attr is not BasePlugin and + not getattr(attr, '_abstract', False)): + plugin = self.load_plugin(attr) + loaded.append(plugin) + + except Exception as e: + print(f"[PluginAPI] Failed to load plugin from {file_path}: {e}") + + return loaded + + def unload_plugin(self, name: str) -> None: + """Unload a plugin by name.""" + if name in self._plugins: + plugin = self._plugins[name] + if plugin.is_initialized(): + plugin.on_stop() + del self._plugins[name] + del self._plugin_classes[name] + print(f"[PluginAPI] Unloaded plugin: {name}") + + def get_plugin(self, name: str) -> Optional[BasePlugin]: + """Get a loaded plugin by name.""" + return self._plugins.get(name) + + def get_all_plugins(self) -> List[BasePlugin]: + """Get all loaded plugins.""" + return list(self._plugins.values()) + + # Lifecycle + + def start_all(self) -> None: + """Start all loaded plugins.""" + for plugin in self._plugins.values(): + try: + plugin.on_start() + plugin._initialized = True + print(f"[PluginAPI] Started plugin: {plugin.name}") + except Exception as e: + print(f"[PluginAPI] Failed to start plugin {plugin.name}: {e}") + + def stop_all(self) -> None: + """Stop all loaded plugins.""" + for plugin in self._plugins.values(): + try: + plugin.on_stop() + plugin._initialized = False + print(f"[PluginAPI] Stopped plugin: {plugin.name}") + except Exception as e: + print(f"[PluginAPI] Error stopping plugin {plugin.name}: {e}") + + # Stop clipboard monitoring if active + if self._clipboard_manager and self._clipboard_manager.is_monitoring(): + self._clipboard_manager.stop_monitoring() + + def reload_plugin(self, name: str) -> Optional[BasePlugin]: + """Reload a plugin by name.""" + if name not in self._plugin_classes: + return None + + self.unload_plugin(name) + + # Reload module + plugin_class = self._plugin_classes[name] + module = sys.modules.get(plugin_class.__module__) + if module: + importlib.reload(module) + plugin_class = getattr(module, plugin_class.__name__) + + return self.load_plugin(plugin_class) + + # Utilities + + def get_plugin_info(self) -> List[Dict[str, Any]]: + """Get information about all loaded plugins.""" + return [ + { + 'name': p.name, + 'description': p.description, + 'version': p.version, + 'author': p.author, + 'initialized': p.is_initialized() + } + for p in self._plugins.values() + ] + + def broadcast(self, method_name: str, *args, **kwargs) -> Dict[str, Any]: + """ + Call a method on all plugins and collect results. + + Args: + method_name: Name of the method to call + *args, **kwargs: Arguments to pass to the method + + Returns: + Dictionary mapping plugin names to results + """ + results = {} + for name, plugin in self._plugins.items(): + method = getattr(plugin, method_name, None) + if callable(method): + try: + results[name] = method(*args, **kwargs) + except Exception as e: + results[name] = e + return results diff --git a/main.py b/main.py new file mode 100644 index 0000000..fdad854 --- /dev/null +++ b/main.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +EU-Utility - Main Entry Point + +Initializes the core services and manages the plugin system. +""" + +import sys +import signal +from pathlib import Path + +# Ensure core is importable +sys.path.insert(0, str(Path(__file__).parent)) + +from core.plugin_api import PluginAPI +from core.clipboard import get_clipboard_manager + + +class EUUtility: + """ + Main application class for EU-Utility. + + Responsibilities: + - Initialize core services (clipboard, etc.) + - Manage plugin lifecycle + - Handle graceful shutdown + """ + + def __init__(self): + self.plugin_api = PluginAPI() + self.running = False + self._setup_signal_handlers() + + def _setup_signal_handlers(self) -> None: + """Setup handlers for graceful shutdown.""" + signal.signal(signal.SIGINT, self._signal_handler) + signal.signal(signal.SIGTERM, self._signal_handler) + + def _signal_handler(self, signum, frame) -> None: + """Handle shutdown signals.""" + print("\n[EU-Utility] Shutdown signal received...") + self.stop() + sys.exit(0) + + def initialize(self, auto_start_clipboard_monitor: bool = False) -> None: + """ + Initialize EU-Utility core services. + + Args: + auto_start_clipboard_monitor: Whether to monitor clipboard changes + """ + print("=" * 50) + print("EU-Utility - Initializing...") + print("=" * 50) + + # Initialize clipboard manager (core service) + self._init_clipboard_service(auto_start_clipboard_monitor) + + # Load plugins + self._load_plugins() + + print("=" * 50) + print("EU-Utility - Ready") + print("=" * 50) + + def _init_clipboard_service(self, auto_start_monitor: bool = False) -> None: + """ + Initialize the clipboard service. + This is a core service available to all plugins. + """ + print("[EU-Utility] Initializing clipboard service...") + + clipboard_manager = self.plugin_api.register_clipboard_service( + auto_start_monitoring=auto_start_monitor + ) + + if clipboard_manager.is_available(): + print("[EU-Utility] ✓ Clipboard service initialized") + stats = clipboard_manager.get_stats() + print(f" - History entries: {stats['history_count']}") + print(f" - Max history: {stats['max_history']}") + print(f" - Monitoring: {stats['is_monitoring']}") + else: + print("[EU-Utility] ⚠ Clipboard service unavailable (pyperclip not installed)") + + def _load_plugins(self) -> None: + """Load plugins from the plugins directory.""" + print("[EU-Utility] Loading plugins...") + plugins = self.plugin_api.load_plugins_from_directory("plugins") + + if plugins: + print(f"[EU-Utility] Loaded {len(plugins)} plugin(s)") + for plugin in plugins: + print(f" - {plugin.name} v{plugin.version}") + else: + print("[EU-Utility] No plugins found") + + # Start all plugins + self.plugin_api.start_all() + + def run(self) -> None: + """ + Run the main application loop. + Blocks until stop() is called. + """ + self.running = True + + try: + while self.running: + # Main loop - can be extended for CLI, GUI, etc. + # For now, just keep alive with sleep + import time + time.sleep(0.1) + except KeyboardInterrupt: + pass + finally: + self.stop() + + def stop(self) -> None: + """Stop EU-Utility and cleanup resources.""" + if not self.running and not self.plugin_api.get_all_plugins(): + return + + print("\n[EU-Utility] Shutting down...") + + # Stop all plugins + self.plugin_api.stop_all() + + self.running = False + print("[EU-Utility] Goodbye!") + + def get_clipboard_manager(self): + """Get the clipboard manager instance.""" + return self.plugin_api.get_clipboard_manager() + + +def main(): + """Main entry point.""" + app = EUUtility() + + # Initialize with clipboard monitoring enabled + app.initialize(auto_start_clipboard_monitor=True) + + # Run the application + app.run() + + +if __name__ == "__main__": + main() diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/test_plugin.py b/plugins/test_plugin.py new file mode 100644 index 0000000..36d647f --- /dev/null +++ b/plugins/test_plugin.py @@ -0,0 +1,139 @@ +""" +TestPlugin - Demonstrates Clipboard Manager functionality + +This plugin showcases: +- Copy coordinates to clipboard +- Read pasted values from user +- Access clipboard history +""" + +import time +import threading +from core.base_plugin import BasePlugin + + +class TestPlugin(BasePlugin): + """Test plugin for demonstrating clipboard functionality.""" + + name = "test_plugin" + description = "Tests clipboard copy, paste, and history features" + version = "1.0.0" + author = "EU-Utility" + + def __init__(self): + super().__init__() + self._test_thread = None + self._running = False + + def on_start(self) -> None: + """Start the test plugin.""" + print(f"[{self.name}] Starting test plugin...") + self._running = True + + # Run tests in background thread + self._test_thread = threading.Thread(target=self._run_tests, daemon=True) + self._test_thread.start() + + def on_stop(self) -> None: + """Stop the test plugin.""" + print(f"[{self.name}] Stopping test plugin...") + self._running = False + + def _run_tests(self) -> None: + """Run clipboard tests after a short delay.""" + time.sleep(1) # Wait for everything to initialize + + if not self._running: + return + + print(f"\n{'='*60}") + print(f"[{self.name}] RUNNING CLIPBOARD TESTS") + print(f"{'='*60}") + + # Test 1: Copy coordinates to clipboard + self._test_copy_coordinates() + + time.sleep(0.5) + + # Test 2: Read pasted values + self._test_paste() + + time.sleep(0.5) + + # Test 3: Access clipboard history + self._test_history() + + print(f"\n{'='*60}") + print(f"[{self.name}] ALL TESTS COMPLETE") + print(f"{'='*60}\n") + + def _test_copy_coordinates(self) -> None: + """Test copying coordinates to clipboard.""" + print(f"\n[{self.name}] Test 1: Copy coordinates to clipboard") + print(f"[{self.name}] " + "-" * 40) + + test_coords = [ + ("100, 200", "Simple coordinates"), + ("45.5231, -122.6765", "GPS coordinates (Portland, OR)"), + ("x: 150, y: 300", "Named coordinates"), + ] + + for coords, desc in test_coords: + success = self.copy_to_clipboard(coords) + if success: + print(f"[{self.name}] ✓ Copied: {coords} ({desc})") + else: + print(f"[{self.name}] ✗ Failed to copy: {coords}") + time.sleep(0.3) + + def _test_paste(self) -> None: + """Test reading pasted values from clipboard.""" + print(f"\n[{self.name}] Test 2: Read pasted values from clipboard") + print(f"[{self.name}] " + "-" * 40) + + # Copy something first + self.copy_to_clipboard("Test paste value") + time.sleep(0.2) + + # Read it back + pasted = self.paste_from_clipboard() + if pasted: + print(f"[{self.name}] ✓ Pasted value: '{pasted}'") + else: + print(f"[{self.name}] ✗ Failed to paste (clipboard may be empty)") + + # Try reading current clipboard (may be user content) + current = self.paste_from_clipboard() + print(f"[{self.name}] Current clipboard: '{current[:50] if current else 'None'}...' " + if current and len(current) > 50 + else f"[{self.name}] Current clipboard: '{current}'") + + def _test_history(self) -> None: + """Test accessing clipboard history.""" + print(f"\n[{self.name}] Test 3: Access clipboard history") + print(f"[{self.name}] " + "-" * 40) + + # Get full history + history = self.get_clipboard_history() + print(f"[{self.name}] Total history entries: {len(history)}") + + # Get limited history + recent = self.get_clipboard_history(limit=5) + print(f"[{self.name}] Recent entries (up to 5):") + + for i, entry in enumerate(recent[:5], 1): + content = entry['content'] + source = entry.get('source', 'unknown') + if len(content) > 40: + content = content[:40] + "..." + print(f"[{self.name}] {i}. [{source}] {content}") + + def test_clear_history(self) -> None: + """ + Test clearing clipboard history. + This is a manual test method - not run automatically. + """ + print(f"[{self.name}] Clearing clipboard history...") + self.clear_clipboard_history() + history = self.get_clipboard_history() + print(f"[{self.name}] History cleared. Entries: {len(history)}") diff --git a/projects/EU-Utility/assets/sounds/alert.wav b/projects/EU-Utility/assets/sounds/alert.wav new file mode 100644 index 0000000..0eae80b Binary files /dev/null and b/projects/EU-Utility/assets/sounds/alert.wav differ diff --git a/projects/EU-Utility/assets/sounds/error.wav b/projects/EU-Utility/assets/sounds/error.wav new file mode 100644 index 0000000..0769c6b Binary files /dev/null and b/projects/EU-Utility/assets/sounds/error.wav differ diff --git a/projects/EU-Utility/assets/sounds/global.wav b/projects/EU-Utility/assets/sounds/global.wav new file mode 100644 index 0000000..03caadf Binary files /dev/null and b/projects/EU-Utility/assets/sounds/global.wav differ diff --git a/projects/EU-Utility/assets/sounds/hof.wav b/projects/EU-Utility/assets/sounds/hof.wav new file mode 100644 index 0000000..70c46f1 Binary files /dev/null and b/projects/EU-Utility/assets/sounds/hof.wav differ diff --git a/projects/EU-Utility/assets/sounds/skill_gain.wav b/projects/EU-Utility/assets/sounds/skill_gain.wav new file mode 100644 index 0000000..2699e86 Binary files /dev/null and b/projects/EU-Utility/assets/sounds/skill_gain.wav differ diff --git a/projects/EU-Utility/core/__init__.py b/projects/EU-Utility/core/__init__.py index 8c94138..620707e 100644 --- a/projects/EU-Utility/core/__init__.py +++ b/projects/EU-Utility/core/__init__.py @@ -1,2 +1,8 @@ -# EU-Utility +# EU-Utility Core Package __version__ = "1.0.0" + +# Export main classes for convenience +from .plugin_api import PluginAPI, APIType, get_api, APIEndpoint +from .nexus_api import NexusAPI, get_nexus_api, EntityType, SearchResult, ItemDetails, MarketData +from .ocr_service import OCRService, get_ocr_service, OCRResult +from .log_reader import LogReader, get_log_reader diff --git a/projects/EU-Utility/core/audio.py b/projects/EU-Utility/core/audio.py new file mode 100644 index 0000000..731cb62 --- /dev/null +++ b/projects/EU-Utility/core/audio.py @@ -0,0 +1,222 @@ +""" +EU-Utility - Audio/Sound Service + +Sound playback for notifications and alerts. +Part of core - plugins access via PluginAPI. +""" + +import platform +import threading +from pathlib import Path +from typing import Dict, Optional +from dataclasses import dataclass + + +@dataclass +class SoundConfig: + """Sound configuration.""" + enabled: bool = True + volume: float = 0.7 + sounds_dir: Optional[Path] = None + + +class AudioManager: + """ + Core audio service for sound playback. + Uses QSoundEffect when available, falls back to system tools. + """ + + _instance = None + _lock = threading.Lock() + + def __new__(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + + self._initialized = True + self._config = SoundConfig() + self._sound_cache: Dict[str, any] = {} + self._available = False + self._backend = None + + self._init_backend() + + def _init_backend(self): + """Initialize audio backend.""" + # Try QSoundEffect first (best Qt integration) + try: + from PyQt6.QtMultimedia import QSoundEffect + self._available = True + self._backend = 'qt' + print("[Audio] Using Qt Multimedia backend") + return + except ImportError: + pass + + # Try playsound + try: + from playsound import playsound + self._available = True + self._backend = 'playsound' + print("[Audio] Using playsound backend") + return + except ImportError: + pass + + # Fallback to system tools + system = platform.system() + if system == "Windows": + try: + import winsound + self._available = True + self._backend = 'winsound' + print("[Audio] Using Windows winsound backend") + return + except ImportError: + pass + + print("[Audio] WARNING: No audio backend available") + print("[Audio] Install one of: PyQt6.QtMultimedia, playsound") + + def is_available(self) -> bool: + """Check if audio is available.""" + return self._available + + def play_sound(self, sound_key: str = "default"): + """Play a sound by key.""" + if not self._available or not self._config.enabled: + return + + sound_file = self._get_sound_file(sound_key) + if not sound_file: + # Use system beep as fallback + self._play_beep() + return + + try: + if self._backend == 'qt': + self._play_qt(sound_file) + elif self._backend == 'playsound': + self._play_playsound(sound_file) + elif self._backend == 'winsound': + self._play_winsound(sound_file) + except Exception as e: + print(f"[Audio] Play error: {e}") + + def _get_sound_file(self, key: str) -> Optional[Path]: + """Get sound file path.""" + if not self._config.sounds_dir: + return None + + sound_map = { + 'default': 'notification.wav', + 'global': 'global.wav', + 'hof': 'hof.wav', + 'skill_gain': 'skill.wav', + 'alert': 'alert.wav', + 'error': 'error.wav', + 'success': 'success.wav', + } + + filename = sound_map.get(key, f"{key}.wav") + sound_file = self._config.sounds_dir / filename + + # Try alternative extensions + if not sound_file.exists(): + for ext in ['.mp3', '.ogg', '.wav']: + alt = sound_file.with_suffix(ext) + if alt.exists(): + return alt + + return sound_file if sound_file.exists() else None + + def _play_qt(self, sound_file: Path): + """Play using Qt.""" + from PyQt6.QtMultimedia import QSoundEffect + + sound_key = str(sound_file) + if sound_key not in self._sound_cache: + effect = QSoundEffect() + effect.setSource(str(sound_file)) + effect.setVolume(self._config.volume) + self._sound_cache[sound_key] = effect + + effect = self._sound_cache[sound_key] + effect.play() + + def _play_playsound(self, sound_file: Path): + """Play using playsound.""" + from playsound import playsound + import threading + + # playsound blocks, so run in thread + def play(): + try: + playsound(str(sound_file), block=False) + except: + pass + + threading.Thread(target=play, daemon=True).start() + + def _play_winsound(self, sound_file: Path): + """Play using Windows winsound.""" + import winsound + + try: + winsound.PlaySound(str(sound_file), winsound.SND_ASYNC) + except: + winsound.MessageBeep() + + def _play_beep(self): + """System beep fallback.""" + system = platform.system() + if system == "Windows": + try: + import winsound + winsound.MessageBeep() + except: + pass + else: + print('\a') # Bell character + + def set_volume(self, volume: float): + """Set volume (0.0 - 1.0).""" + self._config.volume = max(0.0, min(1.0, volume)) + + # Update cached effects + for effect in self._sound_cache.values(): + if hasattr(effect, 'setVolume'): + effect.setVolume(self._config.volume) + + def set_enabled(self, enabled: bool): + """Enable/disable sounds.""" + self._config.enabled = enabled + + def is_muted(self) -> bool: + """Check if muted.""" + return not self._config.enabled + + def mute(self): + """Mute sounds.""" + self._config.enabled = False + + def unmute(self): + """Unmute sounds.""" + self._config.enabled = True + + def set_sounds_directory(self, path: Path): + """Set directory containing sound files.""" + self._config.sounds_dir = Path(path) + + +def get_audio_manager() -> AudioManager: + """Get global AudioManager instance.""" + return AudioManager() diff --git a/projects/EU-Utility/core/clipboard.py b/projects/EU-Utility/core/clipboard.py new file mode 100644 index 0000000..b0aae85 --- /dev/null +++ b/projects/EU-Utility/core/clipboard.py @@ -0,0 +1,159 @@ +""" +EU-Utility - Clipboard Manager + +Cross-platform clipboard access with history. +Part of core - plugins access via PluginAPI. +""" + +import json +import threading +from pathlib import Path +from typing import List, Optional +from collections import deque +from dataclasses import dataclass, asdict +from datetime import datetime + + +@dataclass +class ClipboardEntry: + """A single clipboard entry.""" + text: str + timestamp: str + source: str = "unknown" + + +class ClipboardManager: + """ + Core clipboard service with history tracking. + Uses pyperclip for cross-platform compatibility. + """ + + _instance = None + _lock = threading.Lock() + + def __new__(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self, max_history: int = 100, history_file: Path = None): + if self._initialized: + return + + self._initialized = True + self._max_history = max_history + self._history: deque = deque(maxlen=max_history) + self._history_file = history_file or Path("data/clipboard_history.json") + self._monitoring = False + self._last_clipboard = "" + + self._load_history() + + def _load_history(self): + """Load clipboard history from file.""" + if self._history_file.exists(): + try: + with open(self._history_file, 'r') as f: + data = json.load(f) + for entry in data: + self._history.append(ClipboardEntry(**entry)) + except Exception as e: + print(f"[Clipboard] Error loading history: {e}") + + def _save_history(self): + """Save clipboard history to file.""" + try: + self._history_file.parent.mkdir(parents=True, exist_ok=True) + data = [asdict(entry) for entry in self._history] + with open(self._history_file, 'w') as f: + json.dump(data, f, indent=2) + except Exception as e: + print(f"[Clipboard] Error saving history: {e}") + + def copy(self, text: str, source: str = "plugin") -> bool: + """Copy text to clipboard. + + Args: + text: Text to copy + source: Source identifier for history + + Returns: + True if successful + """ + try: + import pyperclip + pyperclip.copy(text) + + # Add to history + entry = ClipboardEntry( + text=text, + timestamp=datetime.now().isoformat(), + source=source + ) + self._history.append(entry) + self._save_history() + + return True + except Exception as e: + print(f"[Clipboard] Copy error: {e}") + return False + + def paste(self) -> str: + """Paste text from clipboard. + + Returns: + Clipboard content or empty string + """ + try: + import pyperclip + return pyperclip.paste() or "" + except Exception as e: + print(f"[Clipboard] Paste error: {e}") + return "" + + def get_history(self, limit: int = None) -> List[ClipboardEntry]: + """Get clipboard history. + + Args: + limit: Maximum entries to return (None for all) + + Returns: + List of clipboard entries (newest first) + """ + history = list(self._history) + if limit: + history = history[-limit:] + return list(reversed(history)) + + def clear_history(self): + """Clear clipboard history.""" + self._history.clear() + self._save_history() + + def is_available(self) -> bool: + """Check if clipboard is available.""" + try: + import pyperclip + pyperclip.paste() + return True + except: + return False + + +def get_clipboard_manager() -> ClipboardManager: + """Get global ClipboardManager instance.""" + return ClipboardManager() + + +# Convenience functions +def copy_to_clipboard(text: str) -> bool: + """Quick copy to clipboard.""" + return get_clipboard_manager().copy(text) + + +def paste_from_clipboard() -> str: + """Quick paste from clipboard.""" + return get_clipboard_manager().paste() diff --git a/projects/EU-Utility/core/main.py b/projects/EU-Utility/core/main.py index 762806c..ba51e19 100644 --- a/projects/EU-Utility/core/main.py +++ b/projects/EU-Utility/core/main.py @@ -39,8 +39,16 @@ from core.overlay_widgets import OverlayManager from core.plugin_api import get_api, APIType from core.log_reader import get_log_reader from core.ocr_service import get_ocr_service +from core.screenshot import get_screenshot_service +from core.notifications import get_notification_manager, NotificationManager +from core.nexus_api import get_nexus_api from core.http_client import get_http_client from core.window_manager import get_window_manager +from core.event_bus import get_event_bus +from core.tasks import get_task_manager +from core.audio import get_audio_manager +from core.clipboard import get_clipboard_manager +from core.data_store import get_data_store class HotkeyHandler(QObject): @@ -61,6 +69,7 @@ class EUUtilityApp: self.settings = None self.overlay_manager = None self.api = None + self.notification_manager = None def run(self): """Start the application.""" @@ -77,6 +86,18 @@ class EUUtilityApp: self.api = get_api() self._setup_api_services() + # Initialize Event Bus + print("Initializing Event Bus...") + self.event_bus = get_event_bus() + self._print_event_bus_stats() + + # Initialize Notification Manager + print("Initializing Notification Manager...") + self.notification_manager = get_notification_manager() + self.notification_manager.initialize(self.app) + self.api.register_notification_service(self.notification_manager) + print("[Core] Notification service registered") + # Load settings self.settings = get_settings() @@ -116,11 +137,14 @@ class EUUtilityApp: print("Or double-click the floating icon") print(f"Loaded {len(self.plugin_manager.get_all_plugins())} plugins") + # Show Event Bus stats + self._print_event_bus_stats() + # Run return self.app.exec() def _setup_api_services(self): - """Setup shared API services - OCR and Log are core services.""" + """Setup shared API services - Window, OCR and Log are core services.""" # Initialize and start Log Reader print("[Core] Initializing Log Reader...") self.log_reader = get_log_reader() @@ -132,6 +156,31 @@ class EUUtilityApp: # Register Log service with API self.api.register_log_service(self.log_reader.read_lines) + # Initialize Window Manager (Windows-only, gracefully handles Linux) + print("[Core] Initializing Window Manager...") + self.window_manager = get_window_manager() + if self.window_manager.is_available(): + # Try to find EU window on startup + eu_window = self.window_manager.find_eu_window() + if eu_window: + print(f"[Core] Found EU window: {eu_window.title} ({eu_window.width}x{eu_window.height})") + else: + print("[Core] EU window not found - will retry when needed") + # Register Window service with API + self.api.register_window_service(self.window_manager) + else: + print("[Core] Window Manager not available (Windows only)") + + # Screenshot Service - Initialize on startup (lightweight) + print("[Core] Initializing Screenshot Service...") + self.screenshot_service = get_screenshot_service() + if self.screenshot_service.is_available(): + self.api.register_screenshot_service(self.screenshot_service) + backends = self.screenshot_service.get_available_backends() + print(f"[Core] Screenshot Service ready (backends: {', '.join(backends)})") + else: + print("[Core] Screenshot Service not available - install pillow or pyautogui") + # OCR Service - LAZY INITIALIZATION (don't init on startup) # It will initialize on first use print("[Core] OCR Service configured (lazy init)") @@ -140,12 +189,93 @@ class EUUtilityApp: # Register OCR service with API (lazy - will init on first call) self.api.register_ocr_service(self._lazy_ocr_handler) - print("[Core] API services registered: OCR (lazy), Log") + # Initialize Nexus API Service + print("[Core] Initializing Nexus API Service...") + self.nexus_api = get_nexus_api() + self.api.register_nexus_service(self.nexus_api) + + # HTTP Client - Initialize on startup + print("[Core] Initializing HTTP Client...") + try: + self.http_client = get_http_client( + cache_dir="cache/http", + default_cache_ttl=3600, + rate_limit_delay=0.1, # Small delay between requests + max_retries=3, + backoff_factor=0.5, + respect_cache_control=True + ) + self.api.register_http_service(self.http_client) + print("[Core] HTTP Client initialized with caching") + except Exception as e: + print(f"[Core] HTTP Client initialization failed: {e}") + + # Initialize Audio Service + print("[Core] Initializing Audio Service...") + self.audio_manager = get_audio_manager() + if self.audio_manager.is_available(): + self.api.register_audio_service(self.audio_manager) + backend = self.audio_manager.get_backend() + volume = int(self.audio_manager.get_volume() * 100) + print(f"[Core] Audio Service ready (backend: {backend}, volume: {volume}%)") + else: + print("[Core] Audio Service not available - no audio backend found") + + # Initialize Task Manager + print("[Core] Initializing Task Manager...") + try: + self.task_manager = get_task_manager(max_workers=4) + self.task_manager.initialize() + self.api.register_task_service(self.task_manager) + print("[Core] Task Manager initialized with 4 workers") + except Exception as e: + print(f"[Core] Task Manager initialization failed: {e}") + + # Initialize Clipboard Manager + print("[Core] Initializing Clipboard Manager...") + self.clipboard_manager = get_clipboard_manager() + if self.clipboard_manager.is_available(): + self.api.register_clipboard_service(self.clipboard_manager) + print("[Core] Clipboard Service ready") + else: + print("[Core] Clipboard Service not available - install pyperclip") + + # Initialize Data Store + print("[Core] Initializing Data Store...") + self.data_store = get_data_store() + self.api.register_data_service(self.data_store) + print("[Core] Data Store ready") + + print("[Core] API services registered: Window, Screenshot, OCR (lazy), Log, Nexus, HTTP, Audio, Tasks, Clipboard, Data") def _lazy_ocr_handler(self, region=None): """Lazy OCR handler - triggers init on first use.""" return self.ocr_service.recognize(region=region) + def _print_event_bus_stats(self): + """Print Event Bus statistics on startup.""" + if not hasattr(self, 'event_bus') or not self.event_bus: + return + + stats = self.event_bus.get_stats() + print("\n" + "=" * 50) + print("📊 Event Bus Statistics") + print("=" * 50) + print(f" Total Events Published: {stats.get('total_published', 0)}") + print(f" Total Events Delivered: {stats.get('total_delivered', 0)}") + print(f" Active Subscriptions: {stats.get('active_subscriptions', 0)}") + print(f" Events Per Minute: {stats.get('events_per_minute', 0)}") + print(f" Avg Delivery Time: {stats.get('avg_delivery_ms', 0)} ms") + print(f" Errors: {stats.get('errors', 0)}") + + top_types = stats.get('top_event_types', {}) + if top_types: + print(f"\n Top Event Types:") + for event_type, count in list(top_types.items())[:5]: + print(f" • {event_type}: {count}") + + print("=" * 50 + "\n") + def _setup_hotkeys(self): """Setup global hotkeys.""" if KEYBOARD_AVAILABLE: @@ -204,6 +334,27 @@ class EUUtilityApp: if hasattr(self, 'log_reader'): self.log_reader.stop() + # Close all notifications + if self.notification_manager: + self.notification_manager.close_all() + + # Shutdown Event Bus + if hasattr(self, 'event_bus') and self.event_bus: + print("[Core] Shutting down Event Bus...") + self.event_bus.shutdown() + + # Shutdown Audio + if hasattr(self, 'audio_manager') and self.audio_manager: + print("[Core] Shutting down Audio...") + self.audio_manager.shutdown() + + # Shutdown Task Manager + if hasattr(self, 'task_manager') and self.task_manager: + print("[Core] Shutting down Task Manager...") + self.task_manager.shutdown(wait=True, timeout=30.0) + + # Window manager has no persistent resources to clean up + if self.overlay_manager: self.overlay_manager.hide_all() if self.plugin_manager: diff --git a/projects/EU-Utility/core/ocr_service.py b/projects/EU-Utility/core/ocr_service.py index 3791cd4..28f054c 100644 --- a/projects/EU-Utility/core/ocr_service.py +++ b/projects/EU-Utility/core/ocr_service.py @@ -112,7 +112,7 @@ class OCRService: def capture_screen(self, region: Tuple[int, int, int, int] = None) -> 'Image.Image': """ - Capture screen or region. + Capture screen or region using the ScreenshotService. Args: region: (x, y, width, height) or None for full screen @@ -121,18 +121,29 @@ class OCRService: PIL Image """ try: - import pyautogui + from core.screenshot import get_screenshot_service + screenshot_service = get_screenshot_service() if region: x, y, width, height = region - screenshot = pyautogui.screenshot(region=(x, y, width, height)) + return screenshot_service.capture_region(x, y, width, height) else: - screenshot = pyautogui.screenshot() - - return screenshot - - except ImportError: - raise RuntimeError("pyautogui not installed. Run: pip install pyautogui") + return screenshot_service.capture(full_screen=True) + + except Exception as e: + print(f"[OCR] Screenshot service failed, falling back: {e}") + # Fallback to direct pyautogui capture + try: + import pyautogui + + if region: + x, y, width, height = region + return pyautogui.screenshot(region=(x, y, width, height)) + else: + return pyautogui.screenshot() + + except ImportError: + raise RuntimeError("pyautogui not installed. Run: pip install pyautogui") def recognize(self, image=None, region: Tuple[int, int, int, int] = None) -> Dict[str, Any]: """ diff --git a/projects/EU-Utility/core/plugin_api.py b/projects/EU-Utility/core/plugin_api.py index 38a1d6d..11d66c7 100644 --- a/projects/EU-Utility/core/plugin_api.py +++ b/projects/EU-Utility/core/plugin_api.py @@ -3,16 +3,33 @@ EU-Utility - Plugin API System Shared API for cross-plugin communication and common functionality. Allows plugins to expose APIs and use shared services. +Includes Enhanced Event Bus for typed event handling. """ -from typing import Dict, Any, Callable, Optional, List +from typing import Dict, Any, Callable, Optional, List, Type, TypeVar from dataclasses import dataclass from enum import Enum import json from datetime import datetime from pathlib import Path -from core.data_store import get_data_store +# Import Enhanced Event Bus +from core.event_bus import ( + get_event_bus, + BaseEvent, + SkillGainEvent, + LootEvent, + DamageEvent, + GlobalEvent, + ChatEvent, + EconomyEvent, + SystemEvent, + EventCategory, + EventFilter, +) + +# Import Task Manager +from core.tasks import TaskManager, TaskPriority, TaskStatus, Task class APIType(Enum): @@ -22,7 +39,7 @@ class APIType(Enum): DATA = "data" # Shared data storage UTILITY = "utility" # Helper functions SERVICE = "service" # Background services - NEXUS = "nexus" # Entropia Nexus API + EVENT = "event" # Event bus operations @dataclass @@ -54,8 +71,9 @@ class PluginAPI: self.apis: Dict[str, APIEndpoint] = {} self.services: Dict[str, Any] = {} self.data_cache: Dict[str, Any] = {} - self.http_client = None - self._notification_service = None + + # Initialize Event Bus + self._event_bus = get_event_bus() self._initialized = True # ========== API Registration ========== @@ -102,81 +120,6 @@ class PluginAPI: return [ep for ep in self.apis.values() if ep.api_type == api_type] return list(self.apis.values()) - # ========== Window Service ========== - - def register_window_service(self, window_manager): - """Register the Window Manager service.""" - self.services['window'] = window_manager - print("[API] Window Manager service registered") - - def get_eu_window(self) -> Optional[Dict[str, Any]]: - """Get information about the Entropia Universe window. - - Returns: - Dict with window info or None if not found - { - 'handle': int, - 'title': str, - 'rect': (left, top, right, bottom), - 'width': int, - 'height': int, - 'is_visible': bool, - 'is_focused': bool - } - """ - window_service = self.services.get('window') - if not window_service: - return None - - try: - window_info = window_service.find_eu_window() - if window_info: - return { - 'handle': window_info.handle, - 'title': window_info.title, - 'rect': window_info.rect, - 'width': window_info.width, - 'height': window_info.height, - 'is_visible': window_info.is_visible, - 'is_focused': window_info.is_focused - } - return None - except Exception as e: - print(f"[API] Window error: {e}") - return None - - def is_eu_focused(self) -> bool: - """Check if EU window is currently focused. - - Returns: - True if EU is active window, False otherwise - """ - window_service = self.services.get('window') - if not window_service: - return False - - try: - return window_service.is_window_focused() - except Exception as e: - print(f"[API] Window focus error: {e}") - return False - - def bring_eu_to_front(self) -> bool: - """Bring EU window to front and focus it. - - Returns: - True if successful, False otherwise - """ - window_service = self.services.get('window') - if not window_service: - return False - - try: - return window_service.bring_to_front() - except Exception as e: - print(f"[API] Bring to front error: {e}") - return False - # ========== OCR Service ========== def register_ocr_service(self, ocr_handler: Callable): @@ -202,6 +145,86 @@ class PluginAPI: print(f"[API] OCR error: {e}") return {"text": "", "confidence": 0, "error": str(e)} + # ========== Screenshot Service ========== + + def register_screenshot_service(self, screenshot_service): + """Register the screenshot service. + + Args: + screenshot_service: ScreenshotService instance + """ + self.services['screenshot'] = screenshot_service + print("[API] Screenshot service registered") + + def capture_screen(self, full_screen: bool = True): + """Capture screenshot. + + Args: + full_screen: If True, capture entire screen + + Returns: + PIL Image object + """ + screenshot_service = self.services.get('screenshot') + if not screenshot_service: + raise RuntimeError("Screenshot service not available") + + try: + return screenshot_service.capture(full_screen=full_screen) + except Exception as e: + print(f"[API] Screenshot error: {e}") + raise + + def capture_region(self, x: int, y: int, width: int, height: int): + """Capture specific screen region. + + Args: + x: Left coordinate + y: Top coordinate + width: Region width + height: Region height + + Returns: + PIL Image object + """ + screenshot_service = self.services.get('screenshot') + if not screenshot_service: + raise RuntimeError("Screenshot service not available") + + try: + return screenshot_service.capture_region(x, y, width, height) + except Exception as e: + print(f"[API] Screenshot region error: {e}") + raise + + def get_last_screenshot(self): + """Get the most recent screenshot. + + Returns: + PIL Image or None if no screenshots taken yet + """ + screenshot_service = self.services.get('screenshot') + if not screenshot_service: + return None + + return screenshot_service.get_last_screenshot() + + def save_screenshot(self, image, filename: Optional[str] = None) -> Path: + """Save screenshot to file. + + Args: + image: PIL Image to save + filename: Optional filename (auto-generated if None) + + Returns: + Path to saved file + """ + screenshot_service = self.services.get('screenshot') + if not screenshot_service: + raise RuntimeError("Screenshot service not available") + + return screenshot_service.save_screenshot(image, filename) + # ========== Log Service ========== def register_log_service(self, log_handler: Callable): @@ -228,205 +251,6 @@ class PluginAPI: print(f"[API] Log error: {e}") return [] - # ========== Nexus API Service ========== - - def register_nexus_service(self, nexus_api_instance): - """Register the Nexus API service instance.""" - self.services['nexus'] = nexus_api_instance - print("[API] Nexus service registered") - - def nexus_search(self, query: str, entity_type: str = "all", limit: int = 20) -> list: - """Search Entropia Nexus for items, mobs, or all entities. - - Args: - query: Search term (e.g., "ArMatrix", "Atrox") - entity_type: Type of entity to search - "items", "mobs", or "all" - limit: Maximum results (default 20, max 100) - - Returns: - List of search results with id, name, type, category, icon_url - - Example: - # Search for items - results = api.nexus_search("ArMatrix", "items") - - # Search for mobs - mobs = api.nexus_search("Atrox", "mobs") - - # Search everything - all_results = api.nexus_search("Omegaton") - """ - nexus = self.services.get('nexus') - if not nexus: - print("[API] Nexus service not available") - return [] - - try: - entity_type = entity_type.lower() - if entity_type == "items": - results = nexus.search_items(query, limit) - elif entity_type == "mobs": - results = nexus.search_mobs(query, limit) - else: - results = nexus.search_all(query, limit) - - # Convert SearchResult objects to dicts for easier consumption - return [ - { - 'id': r.id, - 'name': r.name, - 'type': r.type, - 'category': r.category, - 'icon_url': r.icon_url - } - for r in results - ] - except Exception as e: - print(f"[API] Nexus search error: {e}") - return [] - - def nexus_get_item_details(self, item_id: str) -> dict: - """Get detailed information about a specific item from Nexus. - - Args: - item_id: The item's unique identifier - - Returns: - Dict with item details or None if not found - """ - nexus = self.services.get('nexus') - if not nexus: - print("[API] Nexus service not available") - return None - - try: - details = nexus.get_item_details(item_id) - if not details: - return None - - return { - 'id': details.id, - 'name': details.name, - 'description': details.description, - 'category': details.category, - 'weight': details.weight, - 'tt_value': details.tt_value, - 'decay': details.decay, - 'ammo_consumption': details.ammo_consumption, - 'damage': details.damage, - 'range': details.range, - 'accuracy': details.accuracy, - 'durability': details.durability, - 'requirements': details.requirements, - 'materials': details.materials - } - except Exception as e: - print(f"[API] Nexus get_item_details error: {e}") - return None - - def nexus_get_market_data(self, item_id: str) -> dict: - """Get market data for a specific item from Nexus. - - Args: - item_id: The item's unique identifier - - Returns: - Dict with market data or None if not found - """ - nexus = self.services.get('nexus') - if not nexus: - print("[API] Nexus service not available") - return None - - try: - market = nexus.get_market_data(item_id) - if not market: - return None - - return { - 'item_id': market.item_id, - 'item_name': market.item_name, - 'current_markup': market.current_markup, - 'avg_markup_7d': market.avg_markup_7d, - 'avg_markup_30d': market.avg_markup_30d, - 'volume_24h': market.volume_24h, - 'volume_7d': market.volume_7d, - 'buy_orders': market.buy_orders, - 'sell_orders': market.sell_orders, - 'last_updated': market.last_updated.isoformat() if market.last_updated else None - } - except Exception as e: - print(f"[API] Nexus get_market_data error: {e}") - return None - - # ========== HTTP Service ========== - - def register_http_service(self, http_client): - """Register the HTTP client service.""" - self.http_client = http_client - print("[API] HTTP service registered") - - def http_get(self, url: str, cache_ttl: int = 3600, headers: Dict[str, str] = None, **kwargs) -> Dict[str, Any]: - """ - Perform HTTP GET request with caching. - - Args: - url: URL to fetch - cache_ttl: Cache time-to-live in seconds (0 to disable) - headers: Additional headers - **kwargs: Additional arguments (params, use_cache, etc.) - - Returns: - Dict with 'status_code', 'headers', 'content', 'text', 'json', 'from_cache' - """ - if not self.http_client: - raise RuntimeError("HTTP service not available") - - try: - return self.http_client.get(url, cache_ttl=cache_ttl, headers=headers, **kwargs) - except Exception as e: - print(f"[API] HTTP GET error: {e}") - raise - - def http_post(self, url: str, data=None, json=None, headers: Dict[str, str] = None, **kwargs) -> Dict[str, Any]: - """ - Perform HTTP POST request. - - Args: - url: URL to post to - data: Form data, bytes, or string - json: JSON data - headers: Additional headers - **kwargs: Additional arguments - - Returns: - Dict with 'status_code', 'headers', 'content', 'text', 'json', 'from_cache' - """ - if not self.http_client: - raise RuntimeError("HTTP service not available") - - try: - return self.http_client.post(url, data=data, json=json, headers=headers, **kwargs) - except Exception as e: - print(f"[API] HTTP POST error: {e}") - raise - - def http_clear_cache(self): - """Clear all HTTP cache entries.""" - if self.http_client: - self.http_client.clear_cache() - - def http_invalidate_cache(self, url_pattern: str): - """Invalidate cache entries matching URL pattern.""" - if self.http_client: - self.http_client.invalidate_cache(url_pattern) - - def http_get_cache_stats(self) -> Dict[str, Any]: - """Get HTTP cache statistics.""" - if self.http_client: - return self.http_client.get_cache_stats() - return {} - # ========== Shared Data ========== def get_data(self, key: str, default=None) -> Any: @@ -437,8 +261,10 @@ class PluginAPI: """Set shared data.""" self.data_cache[key] = value + # ========== Legacy Event System (Backward Compatibility) ========== + def publish_event(self, event_type: str, data: Dict[str, Any]): - """Publish an event for other plugins.""" + """Publish an event for other plugins (legacy - use publish_typed).""" # Store in cache event_key = f"event:{event_type}" self.data_cache[event_key] = { @@ -455,12 +281,363 @@ class PluginAPI: print(f"[API] Subscriber error: {e}") def subscribe(self, event_type: str, callback: Callable): - """Subscribe to events.""" + """Subscribe to events (legacy - use subscribe_typed).""" key = f"subscribers:{event_type}" if key not in self.data_cache: self.data_cache[key] = [] self.data_cache[key].append(callback) + # ========== Enhanced Event Bus (Typed Events) ========== + + def publish_typed(self, event: BaseEvent) -> None: + """ + Publish a typed event to the Event Bus. + + Args: + event: A typed event instance (e.g., SkillGainEvent, LootEvent) + + Example: + api.publish_typed(SkillGainEvent( + skill_name="Rifle", + skill_value=25.5, + gain_amount=0.01 + )) + """ + self._event_bus.publish(event) + + def publish_typed_sync(self, event: BaseEvent) -> int: + """ + Publish a typed event synchronously. + Blocks until all callbacks complete. + Returns number of subscribers notified. + + Args: + event: A typed event instance + + Returns: + Number of subscribers that received the event + """ + return self._event_bus.publish_sync(event) + + def subscribe_typed( + self, + event_class: Type[BaseEvent], + callback: Callable, + **filter_kwargs + ) -> str: + """ + Subscribe to a specific event type with optional filtering. + + Args: + event_class: The event class to subscribe to + (SkillGainEvent, LootEvent, DamageEvent, etc.) + callback: Function to call when matching events occur + **filter_kwargs: Additional filter criteria + - min_damage: Minimum damage threshold (for DamageEvent) + - max_damage: Maximum damage threshold (for DamageEvent) + - mob_types: List of mob names to filter (for LootEvent) + - skill_names: List of skill names to filter (for SkillGainEvent) + - sources: List of event sources to filter + - replay_last: Number of recent events to replay to new subscriber + - predicate: Custom filter function (event) -> bool + + Returns: + Subscription ID (use with unsubscribe_typed) + + Example: + # Subscribe to all damage events + sub_id = api.subscribe_typed(DamageEvent, on_damage) + + # Subscribe to high damage events only + sub_id = api.subscribe_typed( + DamageEvent, + on_big_hit, + min_damage=100 + ) + + # Subscribe to loot from specific mobs with replay + sub_id = api.subscribe_typed( + LootEvent, + on_dragon_loot, + mob_types=["Dragon", "Drake"], + replay_last=10 + ) + """ + return self._event_bus.subscribe_typed(event_class, callback, **filter_kwargs) + + def unsubscribe_typed(self, subscription_id: str) -> bool: + """ + Unsubscribe from typed events. + + Args: + subscription_id: The subscription ID returned by subscribe_typed + + Returns: + True if subscription was found and removed + """ + return self._event_bus.unsubscribe(subscription_id) + + def get_recent_events( + self, + event_type: Type[BaseEvent] = None, + count: int = 100, + category: EventCategory = None + ) -> List[BaseEvent]: + """ + Get recent events from history. + + Args: + event_type: Filter by event class (e.g., SkillGainEvent) + count: Maximum number of events to return (default 100) + category: Filter by event category + + Returns: + List of matching events + + Example: + # Get last 50 skill gains + recent_skills = api.get_recent_events(SkillGainEvent, 50) + + # Get all recent combat events + combat_events = api.get_recent_events(category=EventCategory.COMBAT) + """ + return self._event_bus.get_recent_events(event_type, count, category) + + def get_event_stats(self) -> Dict[str, Any]: + """ + Get Event Bus statistics. + + Returns: + Dict with statistics: + - total_published: Total events published + - total_delivered: Total events delivered to subscribers + - active_subscriptions: Current number of active subscriptions + - events_per_minute: Average events per minute + - avg_delivery_ms: Average delivery time in milliseconds + - errors: Number of delivery errors + - top_event_types: Most common event types + """ + return self._event_bus.get_stats() + + def create_event_filter( + self, + event_types: List[Type[BaseEvent]] = None, + categories: List[EventCategory] = None, + **kwargs + ) -> EventFilter: + """ + Create an event filter for complex subscriptions. + + Args: + event_types: List of event classes to match + categories: List of event categories to match + **kwargs: Additional filter criteria + + Returns: + EventFilter object for use with subscribe() + """ + return EventFilter( + event_types=event_types, + categories=categories, + **kwargs + ) + + # ========== Task Service ========== + + def register_task_service(self, task_manager: TaskManager) -> None: + """Register the Task Manager service. + + Args: + task_manager: TaskManager instance + """ + self.services['tasks'] = task_manager + print("[API] Task Manager service registered") + + def run_in_background(self, func: Callable, *args, + priority: str = 'normal', + on_complete: Callable = None, + on_error: Callable = None, + **kwargs) -> str: + """Run a function in a background thread. + + Args: + func: Function to execute + *args: Positional arguments + priority: 'high', 'normal', or 'low' + on_complete: Callback when task completes (receives result) + on_error: Callback when task fails (receives exception) + **kwargs: Keyword arguments + + Returns: + Task ID for tracking/cancellation + + Example: + task_id = api.run_in_background( + heavy_calculation, + data, + priority='high', + on_complete=lambda result: print(f"Done: {result}") + ) + """ + task_manager = self.services.get('tasks') + if not task_manager: + raise RuntimeError("Task service not available") + + priority_map = { + 'high': TaskPriority.HIGH, + 'normal': TaskPriority.NORMAL, + 'low': TaskPriority.LOW + } + task_priority = priority_map.get(priority, TaskPriority.NORMAL) + + return task_manager.run_in_thread( + func, *args, + priority=task_priority, + on_complete=on_complete, + on_error=on_error, + **kwargs + ) + + def schedule_task(self, delay_ms: int, func: Callable, *args, + priority: str = 'normal', + on_complete: Callable = None, + on_error: Callable = None, + periodic: bool = False, + interval_ms: int = None, + **kwargs) -> str: + """Schedule a task for later execution. + + Args: + delay_ms: Delay before first execution (milliseconds) + func: Function to execute + *args: Positional arguments + priority: 'high', 'normal', or 'low' + on_complete: Completion callback + on_error: Error callback + periodic: If True, run repeatedly + interval_ms: Interval between periodic executions + **kwargs: Keyword arguments + + Returns: + Task ID + + Example: + # One-time delayed task + task_id = api.schedule_task( + 5000, + lambda: print("Delayed!"), + on_complete=lambda _: print("Done") + ) + + # Periodic task (every 10 seconds) + task_id = api.schedule_task( + 0, + fetch_data, + periodic=True, + interval_ms=10000, + on_complete=lambda data: update_ui(data) + ) + """ + task_manager = self.services.get('tasks') + if not task_manager: + raise RuntimeError("Task service not available") + + priority_map = { + 'high': TaskPriority.HIGH, + 'normal': TaskPriority.NORMAL, + 'low': TaskPriority.LOW + } + task_priority = priority_map.get(priority, TaskPriority.NORMAL) + + if periodic: + return task_manager.run_periodic( + interval_ms or delay_ms, + func, *args, + priority=task_priority, + on_complete=on_complete, + on_error=on_error, + run_immediately=(delay_ms == 0), + **kwargs + ) + else: + return task_manager.run_later( + delay_ms, + func, *args, + priority=task_priority, + on_complete=on_complete, + on_error=on_error, + **kwargs + ) + + def cancel_task(self, task_id: str) -> bool: + """Cancel a pending or running task. + + Args: + task_id: Task ID to cancel + + Returns: + True if cancelled, False if not found or already completed + """ + task_manager = self.services.get('tasks') + if not task_manager: + return False + + return task_manager.cancel_task(task_id) + + def get_task_status(self, task_id: str) -> Optional[str]: + """Get the status of a task. + + Args: + task_id: Task ID + + Returns: + Status string: 'pending', 'running', 'completed', 'failed', 'cancelled', or None + """ + task_manager = self.services.get('tasks') + if not task_manager: + return None + + status = task_manager.get_task_status(task_id) + if status: + return status.name.lower() + return None + + def wait_for_task(self, task_id: str, timeout: float = None) -> bool: + """Wait for a task to complete. + + Args: + task_id: Task ID to wait for + timeout: Maximum seconds to wait, or None for no timeout + + Returns: + True if completed, False if timeout + """ + task_manager = self.services.get('tasks') + if not task_manager: + return False + + return task_manager.wait_for_task(task_id, timeout) + + def connect_task_signal(self, signal_name: str, callback: Callable) -> bool: + """Connect to task signals for UI updates. + + Args: + signal_name: One of 'completed', 'failed', 'started', 'cancelled', 'periodic' + callback: Function to call when signal emits + + Returns: + True if connected + + Example: + api.connect_task_signal('completed', on_task_complete) + api.connect_task_signal('failed', on_task_error) + """ + task_manager = self.services.get('tasks') + if not task_manager: + return False + + return task_manager.connect_signal(signal_name, callback) + # ========== Utility APIs ========== def format_ped(self, value: float) -> str: @@ -490,89 +667,185 @@ class PluginAPI: return 0.0 return (price / tt) * 100 - # ========== Data Service ========== + # ========== Audio Service ========== - def register_data_service(self) -> bool: - """Register the data persistence service.""" + def register_audio_service(self, audio_manager): + """Register the audio service. + + Args: + audio_manager: AudioManager instance + """ + self.services['audio'] = audio_manager + print("[API] Audio service registered") + + def play_sound(self, filename_or_key: str, blocking: bool = False) -> bool: + """Play a sound by key or filename. + + Args: + filename_or_key: Sound key ('global', 'hof', 'skill_gain', 'alert', 'error') + or path to file + blocking: If True, wait for sound to complete (default: False) + + Returns: + True if sound was queued/played, False on error or if muted + + Examples: + api.play_sound('hof') # Play HOF sound + api.play_sound('skill_gain') # Play skill gain sound + api.play_sound('/path/to/custom.wav') + """ + audio = self.services.get('audio') + if not audio: + # Try to get audio manager directly + try: + from core.audio import get_audio_manager + audio = get_audio_manager() + self.services['audio'] = audio + except Exception as e: + print(f"[API] Audio service not available: {e}") + return False + try: - self.services['data'] = get_data_store() - return True + return audio.play_sound(filename_or_key, blocking) except Exception as e: - print(f"[API] Failed to register data service: {e}") + print(f"[API] Audio play error: {e}") return False - def save_data(self, plugin_id: str, key: str, data: Any) -> bool: - """Save data for a plugin. + def set_volume(self, volume: float) -> None: + """Set global audio volume. Args: - plugin_id: Unique identifier for the plugin - key: Key under which to store the data - data: Data to store (must be JSON serializable) - - Returns: - True if successful, False otherwise + volume: Volume level from 0.0 (mute) to 1.0 (max) """ - data_store = self.services.get('data') - if not data_store: - # Try to register and get data store - if self.register_data_service(): - data_store = self.services.get('data') - if not data_store: - raise RuntimeError("Data service not available") + audio = self.services.get('audio') + if not audio: + try: + from core.audio import get_audio_manager + audio = get_audio_manager() + self.services['audio'] = audio + except Exception as e: + print(f"[API] Audio service not available: {e}") + return try: - return data_store.save(plugin_id, key, data) + audio.set_volume(volume) except Exception as e: - print(f"[API] Error saving data for {plugin_id}/{key}: {e}") + print(f"[API] Audio volume error: {e}") + + def get_volume(self) -> float: + """Get current audio volume. + + Returns: + Current volume level (0.0 to 1.0) + """ + audio = self.services.get('audio') + if not audio: + try: + from core.audio import get_audio_manager + audio = get_audio_manager() + self.services['audio'] = audio + except Exception: + return 0.0 + + try: + return audio.get_volume() + except Exception: + return 0.0 + + def mute_audio(self) -> None: + """Mute all audio.""" + audio = self.services.get('audio') + if not audio: + try: + from core.audio import get_audio_manager + audio = get_audio_manager() + self.services['audio'] = audio + except Exception as e: + print(f"[API] Audio service not available: {e}") + return + + try: + audio.mute() + except Exception as e: + print(f"[API] Audio mute error: {e}") + + def unmute_audio(self) -> None: + """Unmute audio.""" + audio = self.services.get('audio') + if not audio: + try: + from core.audio import get_audio_manager + audio = get_audio_manager() + self.services['audio'] = audio + except Exception as e: + print(f"[API] Audio service not available: {e}") + return + + try: + audio.unmute() + except Exception as e: + print(f"[API] Audio unmute error: {e}") + + def toggle_mute_audio(self) -> bool: + """Toggle audio mute state. + + Returns: + New muted state (True if now muted) + """ + audio = self.services.get('audio') + if not audio: + try: + from core.audio import get_audio_manager + audio = get_audio_manager() + self.services['audio'] = audio + except Exception as e: + print(f"[API] Audio service not available: {e}") + return False + + try: + return audio.toggle_mute() + except Exception as e: + print(f"[API] Audio toggle mute error: {e}") return False - def load_data(self, plugin_id: str, key: str, default: Any = None) -> Any: - """Load data for a plugin. + def is_audio_muted(self) -> bool: + """Check if audio is muted. - Args: - plugin_id: Unique identifier for the plugin - key: Key of the data to load - default: Default value if key not found - Returns: - The stored data or default value + True if audio is muted """ - data_store = self.services.get('data') - if not data_store: - # Try to register and get data store - if self.register_data_service(): - data_store = self.services.get('data') - if not data_store: - return default + audio = self.services.get('audio') + if not audio: + try: + from core.audio import get_audio_manager + audio = get_audio_manager() + self.services['audio'] = audio + except Exception: + return False try: - return data_store.load(plugin_id, key, default) - except Exception as e: - print(f"[API] Error loading data for {plugin_id}/{key}: {e}") - return default + return audio.is_muted() + except Exception: + return False - def delete_data(self, plugin_id: str, key: str) -> bool: - """Delete data for a plugin. + def is_audio_available(self) -> bool: + """Check if audio service is available. - Args: - plugin_id: Unique identifier for the plugin - key: Key of the data to delete - Returns: - True if key existed and was deleted, False otherwise + True if audio backend is initialized and working """ - data_store = self.services.get('data') - if not data_store: - # Try to register and get data store - if self.register_data_service(): - data_store = self.services.get('data') - if not data_store: - raise RuntimeError("Data service not available") + audio = self.services.get('audio') + if not audio: + try: + from core.audio import get_audio_manager + audio = get_audio_manager() + self.services['audio'] = audio + except Exception: + return False try: - return data_store.delete(plugin_id, key) - except Exception as e: - print(f"[API] Error deleting data for {plugin_id}/{key}: {e}") + return audio.is_available() + except Exception: return False @@ -605,3 +878,27 @@ def register_api(name: str, api_type: APIType, description: str = ""): } return func return decorator + + +# ========== Event Type Exports ========== + +__all__ = [ + # API Classes + 'PluginAPI', + 'APIType', + 'APIEndpoint', + 'get_api', + 'register_api', + + # Event Bus Classes + 'BaseEvent', + 'SkillGainEvent', + 'LootEvent', + 'DamageEvent', + 'GlobalEvent', + 'ChatEvent', + 'EconomyEvent', + 'SystemEvent', + 'EventCategory', + 'EventFilter', +] diff --git a/projects/EU-Utility/core/plugin_store.py b/projects/EU-Utility/core/plugin_store.py index 11f74c8..50dd219 100644 --- a/projects/EU-Utility/core/plugin_store.py +++ b/projects/EU-Utility/core/plugin_store.py @@ -5,26 +5,27 @@ Fetch and install community plugins from GitHub. """ import json -import urllib.request import zipfile import shutil from pathlib import Path from PyQt6.QtCore import QObject, QThread, pyqtSignal +from core.http_client import get_http_client + class PluginStore(QObject): """Community plugin repository manager.""" - + # Repository configuration REPO_URL = "https://raw.githubusercontent.com/ImpulsiveFPS/EU-Utility-Plugins/main/" INDEX_URL = REPO_URL + "plugins.json" - + # Signals plugins_loaded = pyqtSignal(list) plugin_installed = pyqtSignal(str, bool) plugin_removed = pyqtSignal(str, bool) error_occurred = pyqtSignal(str) - + def __init__(self, plugins_dir="user_plugins"): super().__init__() self.plugins_dir = Path(plugins_dir) @@ -32,32 +33,32 @@ class PluginStore(QObject): self.available_plugins = [] self.installed_plugins = [] self._load_installed() - + def fetch_plugins(self): """Fetch available plugins from repository.""" self.fetch_thread = PluginFetchThread(self.INDEX_URL) self.fetch_thread.fetched.connect(self._on_plugins_fetched) self.fetch_thread.error.connect(self._on_fetch_error) self.fetch_thread.start() - + def _on_plugins_fetched(self, plugins): """Handle fetched plugins.""" self.available_plugins = plugins self.plugins_loaded.emit(plugins) - + def _on_fetch_error(self, error): """Handle fetch error.""" self.error_occurred.emit(error) - + def install_plugin(self, plugin_id): """Install a plugin from the store.""" plugin = self._find_plugin(plugin_id) if not plugin: self.plugin_installed.emit(plugin_id, False) return - + self.install_thread = PluginInstallThread( - plugin, + plugin, self.plugins_dir ) self.install_thread.installed.connect( @@ -65,37 +66,37 @@ class PluginStore(QObject): ) self.install_thread.error.connect(self._on_fetch_error) self.install_thread.start() - + def _on_installed(self, plugin_id, success): """Handle plugin installation.""" if success: self.installed_plugins.append(plugin_id) self._save_installed() self.plugin_installed.emit(plugin_id, success) - + def remove_plugin(self, plugin_id): """Remove an installed plugin.""" try: plugin_dir = self.plugins_dir / plugin_id if plugin_dir.exists(): shutil.rmtree(plugin_dir) - + if plugin_id in self.installed_plugins: self.installed_plugins.remove(plugin_id) self._save_installed() - + self.plugin_removed.emit(plugin_id, True) except Exception as e: self.error_occurred.emit(str(e)) self.plugin_removed.emit(plugin_id, False) - + def _find_plugin(self, plugin_id): """Find plugin by ID.""" for plugin in self.available_plugins: if plugin.get('id') == plugin_id: return plugin return None - + def _load_installed(self): """Load list of installed plugins.""" installed_file = self.plugins_dir / ".installed" @@ -105,17 +106,17 @@ class PluginStore(QObject): self.installed_plugins = json.load(f) except: self.installed_plugins = [] - + def _save_installed(self): """Save list of installed plugins.""" installed_file = self.plugins_dir / ".installed" with open(installed_file, 'w') as f: json.dump(self.installed_plugins, f) - + def is_installed(self, plugin_id): """Check if a plugin is installed.""" return plugin_id in self.installed_plugins - + def get_installed_plugins(self): """Get list of installed plugin IDs.""" return self.installed_plugins.copy() @@ -123,81 +124,89 @@ class PluginStore(QObject): class PluginFetchThread(QThread): """Background thread to fetch plugin index.""" - + fetched = pyqtSignal(list) error = pyqtSignal(str) - + def __init__(self, url): super().__init__() self.url = url - + def run(self): """Fetch plugin index.""" try: - req = urllib.request.Request( + http_client = get_http_client() + response = http_client.get( self.url, + cache_ttl=300, # 5 minute cache for plugin list headers={'User-Agent': 'EU-Utility/1.0'} ) - with urllib.request.urlopen(req, timeout=30) as response: - data = json.loads(response.read().decode('utf-8')) + if response.get('json'): + data = response['json'] self.fetched.emit(data.get('plugins', [])) + else: + # Try to parse JSON from text + data = json.loads(response['text']) + self.fetched.emit(data.get('plugins', [])) + except Exception as e: self.error.emit(str(e)) class PluginInstallThread(QThread): """Background thread to install a plugin.""" - + installed = pyqtSignal(bool) error = pyqtSignal(str) progress = pyqtSignal(str) - + def __init__(self, plugin, install_dir): super().__init__() self.plugin = plugin self.install_dir = Path(install_dir) - + def run(self): """Install plugin.""" try: self.progress.emit(f"Downloading {self.plugin['name']}...") - + # Download zip download_url = self.plugin.get('download_url') if not download_url: self.error.emit("No download URL") self.installed.emit(False) return - + temp_zip = self.install_dir / "temp.zip" - - req = urllib.request.Request( + + http_client = get_http_client() + response = http_client.get( download_url, + cache_ttl=0, # Don't cache downloads headers={'User-Agent': 'EU-Utility/1.0'} ) - with urllib.request.urlopen(req, timeout=60) as response: - with open(temp_zip, 'wb') as f: - f.write(response.read()) - + with open(temp_zip, 'wb') as f: + f.write(response['content']) + self.progress.emit("Extracting...") - + # Extract plugin_dir = self.install_dir / self.plugin['id'] if plugin_dir.exists(): shutil.rmtree(plugin_dir) plugin_dir.mkdir() - + with zipfile.ZipFile(temp_zip, 'r') as zip_ref: zip_ref.extractall(plugin_dir) - + # Cleanup temp_zip.unlink() - + self.progress.emit("Installed!") self.installed.emit(True) - + except Exception as e: self.error.emit(str(e)) self.installed.emit(False) diff --git a/projects/EU-Utility/core/screenshot.py b/projects/EU-Utility/core/screenshot.py new file mode 100644 index 0000000..a7ca7f9 --- /dev/null +++ b/projects/EU-Utility/core/screenshot.py @@ -0,0 +1,504 @@ +""" +EU-Utility - Screenshot Service Core Module + +Fast, reliable screen capture functionality for all plugins. +Part of core - not a plugin. Plugins access via PluginAPI. +""" + +import io +import os +import platform +import threading +from collections import deque +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple, Any, Union + +from PIL import Image + + +class ScreenshotService: + """ + Core screenshot service with cross-platform support. + + Features: + - Singleton pattern for single instance across app + - Fast screen capture using PIL.ImageGrab (Windows) or pyautogui (cross-platform) + - Configurable auto-save with timestamps + - Screenshot history (last 20 in memory) + - PNG by default, JPG quality settings + - Thread-safe operations + """ + + _instance = None + _lock = threading.Lock() + + def __new__(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + + self._initialized = True + self._lock = threading.Lock() + + # Configuration + self._auto_save: bool = True + self._save_path: Path = self._get_default_save_path() + self._format: str = "PNG" + self._quality: int = 95 # For JPEG + self._history_size: int = 20 + + # Screenshot history (thread-safe deque) + self._history: deque = deque(maxlen=self._history_size) + self._last_screenshot: Optional[Image.Image] = None + + # Platform detection + self._platform = platform.system().lower() + self._use_pil = self._platform == "windows" + + # Lazy init for capture backends + self._pil_available: Optional[bool] = None + self._pyautogui_available: Optional[bool] = None + + # Ensure save directory exists + self._ensure_save_directory() + + print(f"[Screenshot] Service initialized (auto_save={self._auto_save}, format={self._format})") + + def _get_default_save_path(self) -> Path: + """Get default save path for screenshots.""" + # Use Documents/Entropia Universe/Screenshots/ as default + if self._platform == "windows": + documents = Path.home() / "Documents" + else: + documents = Path.home() / "Documents" + + return documents / "Entropia Universe" / "Screenshots" + + def _ensure_save_directory(self) -> None: + """Ensure the save directory exists.""" + try: + self._save_path.mkdir(parents=True, exist_ok=True) + except Exception as e: + print(f"[Screenshot] Warning: Could not create save directory: {e}") + + def _check_pil_grab(self) -> bool: + """Check if PIL.ImageGrab is available.""" + if self._pil_available is not None: + return self._pil_available + + try: + from PIL import ImageGrab + self._pil_available = True + return True + except ImportError: + self._pil_available = False + return False + + def _check_pyautogui(self) -> bool: + """Check if pyautogui is available.""" + if self._pyautogui_available is not None: + return self._pyautogui_available + + try: + import pyautogui + self._pyautogui_available = True + return True + except ImportError: + self._pyautogui_available = False + return False + + def capture(self, full_screen: bool = True) -> Image.Image: + """ + Capture screenshot. + + Args: + full_screen: If True, capture entire screen. If False, use default region. + + Returns: + PIL Image object + + Raises: + RuntimeError: If no capture backend is available + """ + with self._lock: + screenshot = self._do_capture(full_screen=full_screen) + + # Store in history + self._last_screenshot = screenshot.copy() + self._history.append({ + 'image': screenshot.copy(), + 'timestamp': datetime.now(), + 'region': None if full_screen else 'custom' + }) + + # Auto-save if enabled + if self._auto_save: + self._auto_save_screenshot(screenshot) + + return screenshot + + def capture_region(self, x: int, y: int, width: int, height: int) -> Image.Image: + """ + Capture specific screen region. + + Args: + x: Left coordinate + y: Top coordinate + width: Region width + height: Region height + + Returns: + PIL Image object + """ + with self._lock: + screenshot = self._do_capture(region=(x, y, x + width, y + height)) + + # Store in history + self._last_screenshot = screenshot.copy() + self._history.append({ + 'image': screenshot.copy(), + 'timestamp': datetime.now(), + 'region': (x, y, width, height) + }) + + # Auto-save if enabled + if self._auto_save: + self._auto_save_screenshot(screenshot) + + return screenshot + + def capture_window(self, window_handle: int) -> Optional[Image.Image]: + """ + Capture specific window by handle (Windows only). + + Args: + window_handle: Window handle (HWND on Windows) + + Returns: + PIL Image object or None if capture failed + """ + if self._platform != "windows": + print("[Screenshot] capture_window is Windows-only") + return None + + try: + import win32gui + import win32ui + import win32con + from ctypes import windll + + # Get window dimensions + left, top, right, bottom = win32gui.GetWindowRect(window_handle) + width = right - left + height = bottom - top + + # Create device context + hwndDC = win32gui.GetWindowDC(window_handle) + mfcDC = win32ui.CreateDCFromHandle(hwndDC) + saveDC = mfcDC.CreateCompatibleDC() + + # Create bitmap + saveBitMap = win32ui.CreateBitmap() + saveBitMap.CreateCompatibleBitmap(mfcDC, width, height) + saveDC.SelectObject(saveBitMap) + + # Copy screen into bitmap + result = windll.user32.PrintWindow(window_handle, saveDC.GetSafeHdc(), 3) + + # Convert to PIL Image + bmpinfo = saveBitMap.GetInfo() + bmpstr = saveBitMap.GetBitmapBits(True) + screenshot = Image.frombuffer( + 'RGB', + (bmpinfo['bmWidth'], bmpinfo['bmHeight']), + bmpstr, 'raw', 'BGRX', 0, 1 + ) + + # Cleanup + win32gui.DeleteObject(saveBitMap.GetHandle()) + saveDC.DeleteDC() + mfcDC.DeleteDC() + win32gui.ReleaseDC(window_handle, hwndDC) + + if result != 1: + return None + + with self._lock: + self._last_screenshot = screenshot.copy() + self._history.append({ + 'image': screenshot.copy(), + 'timestamp': datetime.now(), + 'region': 'window', + 'window_handle': window_handle + }) + + if self._auto_save: + self._auto_save_screenshot(screenshot) + + return screenshot + + except Exception as e: + print(f"[Screenshot] Window capture failed: {e}") + return None + + def _do_capture(self, full_screen: bool = True, region: Optional[Tuple[int, int, int, int]] = None) -> Image.Image: + """Internal capture method.""" + # Try PIL.ImageGrab first (Windows, faster) + if self._use_pil and self._check_pil_grab(): + from PIL import ImageGrab + if region: + return ImageGrab.grab(bbox=region) + else: + return ImageGrab.grab() + + # Fall back to pyautogui (cross-platform) + if self._check_pyautogui(): + import pyautogui + if region: + x1, y1, x2, y2 = region + return pyautogui.screenshot(region=(x1, y1, x2 - x1, y2 - y1)) + else: + return pyautogui.screenshot() + + raise RuntimeError( + "No screenshot backend available. " + "Install pillow (Windows) or pyautogui (cross-platform)." + ) + + def _auto_save_screenshot(self, image: Image.Image) -> Optional[Path]: + """Automatically save screenshot with timestamp.""" + try: + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")[:-3] + filename = f"screenshot_{timestamp}.{self._format.lower()}" + return self.save_screenshot(image, filename) + except Exception as e: + print(f"[Screenshot] Auto-save failed: {e}") + return None + + def save_screenshot(self, image: Image.Image, filename: Optional[str] = None) -> Path: + """ + Save screenshot to file. + + Args: + image: PIL Image to save + filename: Optional filename (auto-generated if None) + + Returns: + Path to saved file + """ + if filename is None: + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")[:-3] + filename = f"screenshot_{timestamp}.{self._format.lower()}" + + # Ensure correct extension + if not filename.lower().endswith(('.png', '.jpg', '.jpeg')): + filename += f".{self._format.lower()}" + + filepath = self._save_path / filename + + # Save with appropriate settings + if filename.lower().endswith('.jpg') or filename.lower().endswith('.jpeg'): + image = image.convert('RGB') # JPEG doesn't support alpha + image.save(filepath, 'JPEG', quality=self._quality, optimize=True) + else: + image.save(filepath, 'PNG', optimize=True) + + return filepath + + def get_last_screenshot(self) -> Optional[Image.Image]: + """ + Get the most recent screenshot. + + Returns: + PIL Image or None if no screenshots taken yet + """ + with self._lock: + return self._last_screenshot.copy() if self._last_screenshot else None + + def get_history(self, limit: Optional[int] = None) -> List[Dict[str, Any]]: + """ + Get screenshot history. + + Args: + limit: Maximum number of entries (default: all) + + Returns: + List of dicts with 'timestamp', 'region', 'image' keys + """ + with self._lock: + history = list(self._history) + if limit: + history = history[-limit:] + return [ + { + 'timestamp': entry['timestamp'], + 'region': entry['region'], + 'image': entry['image'].copy() + } + for entry in history + ] + + def clear_history(self) -> None: + """Clear screenshot history from memory.""" + with self._lock: + self._history.clear() + self._last_screenshot = None + + # ========== Configuration ========== + + @property + def auto_save(self) -> bool: + """Get auto-save setting.""" + return self._auto_save + + @auto_save.setter + def auto_save(self, value: bool) -> None: + """Set auto-save setting.""" + self._auto_save = bool(value) + + @property + def save_path(self) -> Path: + """Get current save path.""" + return self._save_path + + @save_path.setter + def save_path(self, path: Union[str, Path]) -> None: + """Set save path.""" + self._save_path = Path(path) + self._ensure_save_directory() + + @property + def format(self) -> str: + """Get image format (PNG or JPEG).""" + return self._format + + @format.setter + def format(self, fmt: str) -> None: + """Set image format.""" + fmt = fmt.upper() + if fmt in ('PNG', 'JPG', 'JPEG'): + self._format = 'PNG' if fmt == 'PNG' else 'JPEG' + else: + raise ValueError(f"Unsupported format: {fmt}") + + @property + def quality(self) -> int: + """Get JPEG quality (1-100).""" + return self._quality + + @quality.setter + def quality(self, value: int) -> None: + """Set JPEG quality (1-100).""" + self._quality = max(1, min(100, int(value))) + + def configure(self, + auto_save: Optional[bool] = None, + save_path: Optional[Union[str, Path]] = None, + format: Optional[str] = None, + quality: Optional[int] = None) -> Dict[str, Any]: + """ + Configure screenshot service settings. + + Args: + auto_save: Enable/disable auto-save + save_path: Directory to save screenshots + format: Image format (PNG or JPEG) + quality: JPEG quality (1-100) + + Returns: + Current configuration as dict + """ + if auto_save is not None: + self.auto_save = auto_save + if save_path is not None: + self.save_path = save_path + if format is not None: + self.format = format + if quality is not None: + self.quality = quality + + return self.get_config() + + def get_config(self) -> Dict[str, Any]: + """Get current configuration.""" + return { + 'auto_save': self._auto_save, + 'save_path': str(self._save_path), + 'format': self._format, + 'quality': self._quality, + 'history_size': self._history_size, + 'platform': self._platform, + 'backend': 'PIL' if self._use_pil else 'pyautogui' + } + + # ========== Utility Methods ========== + + def image_to_bytes(self, image: Image.Image, format: Optional[str] = None) -> bytes: + """ + Convert PIL Image to bytes. + + Args: + image: PIL Image + format: Output format (default: current format setting) + + Returns: + Image as bytes + """ + fmt = (format or self._format).upper() + buffer = io.BytesIO() + + if fmt == 'JPEG': + image = image.convert('RGB') + image.save(buffer, 'JPEG', quality=self._quality) + else: + image.save(buffer, 'PNG') + + return buffer.getvalue() + + def get_available_backends(self) -> List[str]: + """Get list of available capture backends.""" + backends = [] + if self._check_pil_grab(): + backends.append('PIL.ImageGrab') + if self._check_pyautogui(): + backends.append('pyautogui') + return backends + + def is_available(self) -> bool: + """Check if screenshot service is available (has working backend).""" + return self._check_pil_grab() or self._check_pyautogui() + + +# Singleton instance +_screenshot_service = None + +def get_screenshot_service() -> ScreenshotService: + """Get the global ScreenshotService instance.""" + global _screenshot_service + if _screenshot_service is None: + _screenshot_service = ScreenshotService() + return _screenshot_service + + +# Convenience functions for quick screenshots +def quick_capture() -> Image.Image: + """Quick full-screen capture.""" + return get_screenshot_service().capture(full_screen=True) + +def quick_capture_region(x: int, y: int, width: int, height: int) -> Image.Image: + """Quick region capture.""" + return get_screenshot_service().capture_region(x, y, width, height) + +def quick_save(filename: Optional[str] = None) -> Path: + """Quick capture and save.""" + service = get_screenshot_service() + image = service.capture() + return service.save_screenshot(image, filename) diff --git a/projects/EU-Utility/core/tasks.py b/projects/EU-Utility/core/tasks.py new file mode 100644 index 0000000..f45ba6f --- /dev/null +++ b/projects/EU-Utility/core/tasks.py @@ -0,0 +1,299 @@ +""" +EU-Utility - Task Manager + +Thread pool and background task execution for plugins. +Part of core - plugins access via PluginAPI. +""" + +import uuid +import time +import threading +from concurrent.futures import ThreadPoolExecutor, Future +from typing import Callable, Any, Dict, Optional, List +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum + +from PyQt6.QtCore import QObject, pyqtSignal, QTimer + + +class TaskPriority(Enum): + """Task priority levels.""" + HIGH = 1 + NORMAL = 2 + LOW = 3 + + +class TaskStatus(Enum): + """Task execution status.""" + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +@dataclass +class Task: + """Represents a background task.""" + id: str + func: Callable + args: tuple + kwargs: dict + priority: TaskPriority + callback: Optional[Callable] = None + error_callback: Optional[Callable] = None + status: TaskStatus = TaskStatus.PENDING + result: Any = None + error: Optional[Exception] = None + created_at: datetime = field(default_factory=datetime.now) + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + + +class TaskSignals(QObject): + """Qt signals for task updates.""" + task_completed = pyqtSignal(str, object) # task_id, result + task_failed = pyqtSignal(str, object) # task_id, error + task_started = pyqtSignal(str) # task_id + + +class TaskManager: + """ + Core task management service with thread pool. + + Usage: + manager = get_task_manager() + + # Run in background + task_id = manager.run_in_thread(my_function, arg1, arg2) + + # Run later + manager.run_later(1000, my_function) # After 1000ms + + # Run periodic + manager.run_periodic(5000, my_function) # Every 5 seconds + """ + + _instance = None + _lock = threading.Lock() + + def __new__(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self, max_workers: int = 4): + if self._initialized: + return + + self._initialized = True + self._executor = ThreadPoolExecutor(max_workers=max_workers) + self._tasks: Dict[str, Task] = {} + self._futures: Dict[str, Future] = {} + self._timers: Dict[str, QTimer] = {} + self._signals = TaskSignals() + self._shutdown = False + + # Connect signals + self._signals.task_completed.connect(self._on_task_completed) + self._signals.task_failed.connect(self._on_task_failed) + + def run_in_thread(self, func: Callable, *args, + priority: TaskPriority = TaskPriority.NORMAL, + callback: Callable = None, + error_callback: Callable = None, + **kwargs) -> str: + """Run a function in a background thread. + + Args: + func: Function to run + *args: Arguments for function + priority: Task priority + callback: Called with result on success (in main thread) + error_callback: Called with error on failure (in main thread) + **kwargs: Keyword arguments for function + + Returns: + Task ID + """ + task_id = str(uuid.uuid4())[:8] + + task = Task( + id=task_id, + func=func, + args=args, + kwargs=kwargs, + priority=priority, + callback=callback, + error_callback=error_callback + ) + + self._tasks[task_id] = task + + # Submit to thread pool + future = self._executor.submit(self._execute_task, task_id) + self._futures[task_id] = future + + return task_id + + def _execute_task(self, task_id: str): + """Execute task in thread.""" + task = self._tasks.get(task_id) + if not task: + return + + try: + task.status = TaskStatus.RUNNING + task.started_at = datetime.now() + self._signals.task_started.emit(task_id) + + # Execute function + result = task.func(*task.args, **task.kwargs) + + task.result = result + task.status = TaskStatus.COMPLETED + task.completed_at = datetime.now() + + # Emit completion signal + self._signals.task_completed.emit(task_id, result) + + except Exception as e: + task.error = e + task.status = TaskStatus.FAILED + task.completed_at = datetime.now() + + # Emit failure signal + self._signals.task_failed.emit(task_id, e) + + def _on_task_completed(self, task_id: str, result: Any): + """Handle task completion (in main thread).""" + task = self._tasks.get(task_id) + if task and task.callback: + try: + task.callback(result) + except Exception as e: + print(f"[TaskManager] Callback error: {e}") + + def _on_task_failed(self, task_id: str, error: Exception): + """Handle task failure (in main thread).""" + task = self._tasks.get(task_id) + if task and task.error_callback: + try: + task.error_callback(error) + except Exception as e: + print(f"[TaskManager] Error callback error: {e}") + + def run_later(self, delay_ms: int, func: Callable, *args, **kwargs) -> str: + """Run a function after a delay. + + Args: + delay_ms: Delay in milliseconds + func: Function to run + *args, **kwargs: Arguments for function + + Returns: + Timer ID + """ + timer_id = str(uuid.uuid4())[:8] + + def timeout(): + func(*args, **kwargs) + self._timers.pop(timer_id, None) + + timer = QTimer() + timer.setSingleShot(True) + timer.timeout.connect(timeout) + timer.start(delay_ms) + + self._timers[timer_id] = timer + return timer_id + + def run_periodic(self, interval_ms: int, func: Callable, *args, **kwargs) -> str: + """Run a function periodically. + + Args: + interval_ms: Interval in milliseconds + func: Function to run + *args, **kwargs: Arguments for function + + Returns: + Timer ID (use cancel_task to stop) + """ + timer_id = str(uuid.uuid4())[:8] + + timer = QTimer() + timer.timeout.connect(lambda: func(*args, **kwargs)) + timer.start(interval_ms) + + self._timers[timer_id] = timer + return timer_id + + def cancel_task(self, task_id: str) -> bool: + """Cancel a task or timer. + + Args: + task_id: Task or timer ID + + Returns: + True if cancelled + """ + # Cancel thread task + if task_id in self._futures: + future = self._futures[task_id] + cancelled = future.cancel() + if cancelled: + task = self._tasks.get(task_id) + if task: + task.status = TaskStatus.CANCELLED + return cancelled + + # Cancel timer + if task_id in self._timers: + self._timers[task_id].stop() + del self._timers[task_id] + return True + + return False + + def get_task_status(self, task_id: str) -> Optional[TaskStatus]: + """Get task status.""" + task = self._tasks.get(task_id) + return task.status if task else None + + def get_task_result(self, task_id: str) -> Any: + """Get task result (if completed).""" + task = self._tasks.get(task_id) + return task.result if task else None + + def get_active_tasks(self) -> List[Task]: + """Get list of active tasks.""" + return [t for t in self._tasks.values() + if t.status in (TaskStatus.PENDING, TaskStatus.RUNNING)] + + def shutdown(self, wait: bool = True): + """Shutdown task manager. + + Args: + wait: Wait for tasks to complete + """ + self._shutdown = True + + # Stop all timers + for timer in self._timers.values(): + timer.stop() + self._timers.clear() + + # Shutdown executor + if wait: + self._executor.shutdown(wait=True) + else: + self._executor.shutdown(wait=False) + + +def get_task_manager() -> TaskManager: + """Get global TaskManager instance.""" + return TaskManager() diff --git a/projects/EU-Utility/docs/TASK_SERVICE.md b/projects/EU-Utility/docs/TASK_SERVICE.md new file mode 100644 index 0000000..1630452 --- /dev/null +++ b/projects/EU-Utility/docs/TASK_SERVICE.md @@ -0,0 +1,151 @@ +# Task Management Service - Implementation Summary + +## Overview +Implemented a thread pool-based background task service for EU-Utility that replaces the need for plugins to create their own QThreads. + +## Files Created + +### 1. `core/tasks.py` +The core TaskManager implementation with: + +- **Singleton TaskManager class** - Shared across the application +- **ThreadPoolExecutor** - Configurable max workers (default: 4) +- **Task priority levels** - HIGH, NORMAL, LOW +- **Task scheduling**: + - `run_in_thread()` - Immediate background execution + - `run_later()` - Delayed execution (ms) + - `run_periodic()` - Repeating execution at interval +- **Callback support** - `on_complete`, `on_error` +- **Task ID tracking** - UUID-based unique IDs +- **Graceful shutdown** - Wait for tasks or cancel with timeout +- **Qt signal integration** - Thread-safe UI updates + +Key signals: +- `task_completed` - (task_id, result) +- `task_failed` - (task_id, error_message) +- `task_started` - (task_id) +- `task_cancelled` - (task_id) +- `task_periodic` - (task_id, iteration, result) + +## Files Modified + +### 2. `core/plugin_api.py` +Added to PluginAPI: +- `register_task_service()` - Register TaskManager instance +- `run_in_background()` - Run function in thread pool +- `schedule_task()` - Delayed/periodic task scheduling +- `cancel_task()` - Cancel pending/running task +- `get_task_status()` - Query task status +- `wait_for_task()` - Wait for completion +- `connect_task_signal()` - Connect to Qt signals + +### 3. `plugins/base_plugin.py` +Added to BasePlugin: +- `run_in_background()` - Background task execution +- `schedule_task()` - Delayed/periodic scheduling +- `cancel_task()` - Task cancellation +- `connect_task_signals()` - Batch signal connection + +### 4. `core/main.py` +Changes: +- Import `get_task_manager` from tasks module +- Initialize TaskManager in `_setup_api_services()` +- Register task service with API +- Graceful shutdown in `quit()` - waits for tasks (30s timeout) + +## Usage Examples + +### Simple Background Task +```python +def heavy_calculation(data): + return process(data) + +task_id = self.run_in_background( + heavy_calculation, + large_dataset, + priority='high', + on_complete=lambda result: print(f"Done: {result}"), + on_error=lambda e: print(f"Error: {e}") +) +``` + +### Delayed Task +```python +task_id = self.schedule_task( + delay_ms=5000, # 5 seconds + func=lambda: print("Hello!"), + on_complete=lambda _: print("Complete") +) +``` + +### Periodic Task +```python +task_id = self.schedule_task( + delay_ms=0, + func=fetch_data, + periodic=True, + interval_ms=30000, # Every 30 seconds + on_complete=lambda data: update_ui(data) +) + +# Cancel later +self.cancel_task(task_id) +``` + +### UI Updates (Thread-Safe) +```python +def initialize(self): + # Connect signals once + self.connect_task_signals( + on_completed=self._on_done, + on_failed=self._on_error + ) + +def start_work(self): + # Run in background + self.run_in_background( + self.process_data, + priority='normal' + ) + +def _on_done(self, task_id, result): + # Runs in main thread - safe to update UI! + self.status_label.setText(f"Complete: {result}") + +def _on_error(self, task_id, error): + self.status_label.setText(f"Error: {error}") +``` + +## Benefits + +1. **No more QThreads** - Plugins use shared thread pool +2. **Qt signal integration** - Thread-safe UI updates +3. **Priority levels** - HIGH tasks run before LOW +4. **Task tracking** - Monitor and cancel tasks +5. **Graceful shutdown** - Wait for tasks on exit +6. **Error handling** - Centralized error callbacks +7. **Less code** - Simple API vs managing QThreads + +## Migration Guide + +**Before (with QThread):** +```python +class Worker(QThread): + finished = pyqtSignal(object) + + def run(self): + result = heavy_work() + self.finished.emit(result) + +worker = Worker() +worker.finished.connect(self.handle_result) +worker.start() +``` + +**After (with TaskManager):** +```python +task_id = self.run_in_background( + heavy_work, + on_complete=self.handle_result +) +``` diff --git a/projects/EU-Utility/docs/task_example_plugin.py b/projects/EU-Utility/docs/task_example_plugin.py new file mode 100644 index 0000000..91f5022 --- /dev/null +++ b/projects/EU-Utility/docs/task_example_plugin.py @@ -0,0 +1,219 @@ +""" +Example plugin demonstrating background task usage. + +This shows how to use the Task Manager instead of creating QThreads. +""" + +from plugins.base_plugin import BasePlugin +from typing import Any + + +class ExampleTaskPlugin(BasePlugin): + """Example plugin showing background task patterns.""" + + name = "Task Example" + version = "1.0.0" + author = "EU-Utility" + description = "Example of background task usage" + + def __init__(self, overlay_window, config): + super().__init__(overlay_window, config) + self._active_tasks = [] + self._periodic_task_id = None + + def initialize(self) -> None: + """Set up task signal connections.""" + # Connect to task signals for UI updates + self.connect_task_signals( + on_completed=self._on_task_completed, + on_failed=self._on_task_failed, + on_started=self._on_task_started + ) + + def get_ui(self) -> Any: + """Return the plugin's UI widget.""" + # This would normally return a QWidget + # For this example, we just return None + return None + + # ========== Example: Simple Background Task ========== + + def run_heavy_calculation(self, data): + """Run a heavy calculation in the background.""" + + def heavy_calc(): + # This runs in a background thread + import time + time.sleep(2) # Simulate heavy work + return sum(x ** 2 for x in data) + + def on_done(result): + # This runs in the main thread via Qt signals + print(f"Calculation complete: {result}") + self.notify_success("Task Complete", f"Result: {result}") + + def on_error(exc): + print(f"Calculation failed: {exc}") + self.notify_error("Task Failed", str(exc)) + + # Submit the task + task_id = self.run_in_background( + heavy_calc, + priority='high', + on_complete=on_done, + on_error=on_error + ) + + self._active_tasks.append(task_id) + print(f"Started task: {task_id}") + return task_id + + # ========== Example: Delayed Task ========== + + def schedule_delayed_update(self): + """Schedule a task to run after a delay.""" + + task_id = self.schedule_task( + delay_ms=5000, # 5 seconds + func=lambda: "Delayed update executed!", + on_complete=lambda msg: print(msg), + priority='normal' + ) + + print(f"Scheduled delayed task: {task_id}") + return task_id + + # ========== Example: Periodic Task ========== + + def start_periodic_refresh(self): + """Start a periodic background refresh.""" + + def fetch_data(): + # Simulate data fetching + import random + return { + 'timestamp': __import__('time').time(), + 'value': random.randint(1, 100) + } + + def on_data(data): + print(f"Periodic update: {data}") + + self._periodic_task_id = self.schedule_task( + delay_ms=0, # Start immediately + func=fetch_data, + periodic=True, + interval_ms=10000, # Every 10 seconds + on_complete=on_data, + priority='low' + ) + + print(f"Started periodic task: {self._periodic_task_id}") + + def stop_periodic_refresh(self): + """Stop the periodic task.""" + if self._periodic_task_id: + self.cancel_task(self._periodic_task_id) + print("Periodic task stopped") + self._periodic_task_id = None + + # ========== Example: Method as Background Task ========== + + def background_method_example(self, data): + """Example of running an instance method in background.""" + + # Note: When running instance methods, pass 'self' explicitly + task_id = self.run_in_background( + self._process_data, + data, + priority='normal', + on_complete=self._handle_result + ) + return task_id + + def _process_data(self, data): + """Method that runs in background.""" + import time + time.sleep(1) + return f"Processed: {len(data)} items" + + def _handle_result(self, result): + """Handle the result in main thread.""" + print(f"Got result: {result}") + + # ========== Task Signal Handlers ========== + + def _on_task_started(self, task_id): + """Called when any task starts.""" + print(f"Task {task_id} started") + + def _on_task_completed(self, task_id, result): + """Called when any task completes.""" + print(f"Task {task_id} completed with result: {result}") + if task_id in self._active_tasks: + self._active_tasks.remove(task_id) + + def _on_task_failed(self, task_id, error): + """Called when any task fails.""" + print(f"Task {task_id} failed: {error}") + if task_id in self._active_tasks: + self._active_tasks.remove(task_id) + + # ========== Cleanup ========== + + def shutdown(self) -> None: + """Clean up tasks when plugin shuts down.""" + # Stop periodic task + self.stop_periodic_refresh() + + # Cancel any active tasks + for task_id in self._active_tasks[:]: + self.cancel_task(task_id) + + self._active_tasks.clear() + super().shutdown() + + +# ========== Quick Usage Examples ========== + +""" +In a plugin, use the task service like this: + +# 1. Simple background execution +task_id = self.run_in_background( + some_function, + arg1, arg2, + priority='high', + on_complete=lambda result: print(f"Done: {result}"), + on_error=lambda e: print(f"Error: {e}") +) + +# 2. Delayed execution +task_id = self.schedule_task( + delay_ms=5000, + func=some_function, + on_complete=lambda result: print("Delayed task done") +) + +# 3. Periodic execution +task_id = self.schedule_task( + delay_ms=0, # Start now + func=fetch_data, + periodic=True, + interval_ms=30000, # Every 30 seconds + on_complete=lambda data: update_ui(data) +) + +# 4. Cancel a task +self.cancel_task(task_id) + +# 5. Connect to task signals for UI updates +self.connect_task_signals( + on_completed=self._on_task_done, + on_failed=self._on_task_error +) + +def _on_task_done(self, task_id, result): + # This runs in main thread - safe to update UI + self.status_label.setText(f"Task complete: {result}") +""" diff --git a/projects/EU-Utility/plugins/base_plugin.py b/projects/EU-Utility/plugins/base_plugin.py index 967d462..456e24e 100644 --- a/projects/EU-Utility/plugins/base_plugin.py +++ b/projects/EU-Utility/plugins/base_plugin.py @@ -6,11 +6,12 @@ Includes PluginAPI integration for cross-plugin communication. """ from abc import ABC, abstractmethod -from typing import Optional, Dict, Any, TYPE_CHECKING, Callable +from typing import Optional, Dict, Any, TYPE_CHECKING, Callable, List, Type if TYPE_CHECKING: from core.overlay_window import OverlayWindow from core.plugin_api import PluginAPI, APIEndpoint, APIType + from core.event_bus import BaseEvent, EventCategory class BasePlugin(ABC): @@ -34,6 +35,9 @@ class BasePlugin(ABC): self._api_registered = False self._plugin_id = f"{self.__class__.__module__}.{self.__class__.__name__}" + # Track event subscriptions for cleanup + self._event_subscriptions: List[str] = [] + # Get API instance try: from core.plugin_api import get_api @@ -49,7 +53,7 @@ class BasePlugin(ABC): @abstractmethod def get_ui(self) -> Any: """Return the plugin's UI widget (QWidget).""" - pass + return None def on_show(self) -> None: """Called when overlay becomes visible.""" @@ -68,6 +72,9 @@ class BasePlugin(ABC): # Unregister APIs if self.api and self._api_registered: self.api.unregister_api(self._plugin_id) + + # Unsubscribe from all typed events + self.unsubscribe_all_typed() # ========== Config Methods ========== @@ -139,50 +146,6 @@ class BasePlugin(ABC): return self.api.find_apis(api_type) - # ========== Window Service Methods ========== - - def get_eu_window(self) -> Optional[Dict[str, Any]]: - """Get information about the Entropia Universe window. - - Returns: - Dict with window info or None if not available: - { - 'handle': int, - 'title': str, - 'rect': (left, top, right, bottom), - 'width': int, - 'height': int, - 'is_visible': bool, - 'is_focused': bool - } - """ - if not self.api: - return None - - return self.api.get_eu_window() - - def is_eu_focused(self) -> bool: - """Check if EU window is currently focused/active. - - Returns: - True if EU is the active window, False otherwise - """ - if not self.api: - return False - - return self.api.is_eu_focused() - - def bring_eu_to_front(self) -> bool: - """Bring EU window to front and focus it. - - Returns: - True if successful, False otherwise - """ - if not self.api: - return False - - return self.api.bring_eu_to_front() - # ========== Shared Services ========== def ocr_capture(self, region: tuple = None) -> Dict[str, Any]: @@ -196,6 +159,61 @@ class BasePlugin(ABC): return self.api.ocr_capture(region) + # ========== Screenshot Service Methods ========== + + def capture_screen(self, full_screen: bool = True): + """Capture screenshot. + + Args: + full_screen: If True, capture entire screen + + Returns: + PIL Image object + + Example: + # Capture full screen + screenshot = self.capture_screen() + + # Capture specific region + region = self.capture_region(100, 100, 800, 600) + """ + if not self.api: + raise RuntimeError("API not available") + + return self.api.capture_screen(full_screen) + + def capture_region(self, x: int, y: int, width: int, height: int): + """Capture specific screen region. + + Args: + x: Left coordinate + y: Top coordinate + width: Region width + height: Region height + + Returns: + PIL Image object + + Example: + # Capture a 400x200 region starting at (100, 100) + image = self.capture_region(100, 100, 400, 200) + """ + if not self.api: + raise RuntimeError("API not available") + + return self.api.capture_region(x, y, width, height) + + def get_last_screenshot(self): + """Get the most recent screenshot. + + Returns: + PIL Image or None if no screenshots taken yet + """ + if not self.api: + return None + + return self.api.get_last_screenshot() + def read_log(self, lines: int = 50, filter_text: str = None) -> list: """Read recent game log lines.""" if not self.api: @@ -215,16 +233,159 @@ class BasePlugin(ABC): if self.api: self.api.set_data(key, value) + # ========== Legacy Event System ========== + def publish_event(self, event_type: str, data: Dict[str, Any]): - """Publish an event for other plugins to consume.""" + """Publish an event for other plugins to consume (legacy).""" if self.api: self.api.publish_event(event_type, data) def subscribe(self, event_type: str, callback: Callable): - """Subscribe to events from other plugins.""" + """Subscribe to events from other plugins (legacy).""" if self.api: self.api.subscribe(event_type, callback) + # ========== Enhanced Typed Event System ========== + + def publish_typed(self, event: 'BaseEvent') -> None: + """ + Publish a typed event to the Event Bus. + + Args: + event: A typed event instance (SkillGainEvent, LootEvent, etc.) + + Example: + from core.event_bus import LootEvent + + self.publish_typed(LootEvent( + mob_name="Daikiba", + items=[{"name": "Animal Oil", "value": 0.05}], + total_tt_value=0.05 + )) + """ + if self.api: + self.api.publish_typed(event) + + def subscribe_typed( + self, + event_class: Type['BaseEvent'], + callback: Callable, + **filter_kwargs + ) -> str: + """ + Subscribe to a specific event type with optional filtering. + + Args: + event_class: The event class to subscribe to + callback: Function to call when matching events occur + **filter_kwargs: Additional filter criteria + - min_damage: Minimum damage threshold + - max_damage: Maximum damage threshold + - mob_types: List of mob names to filter + - skill_names: List of skill names to filter + - sources: List of event sources to filter + - replay_last: Number of recent events to replay + - predicate: Custom filter function + + Returns: + Subscription ID (store this to unsubscribe later) + + Example: + from core.event_bus import DamageEvent + + # Subscribe to all damage events + self.sub_id = self.subscribe_typed(DamageEvent, self.on_damage) + + # Subscribe to high damage events only + self.sub_id = self.subscribe_typed( + DamageEvent, + self.on_big_hit, + min_damage=100 + ) + + # Subscribe with replay + self.sub_id = self.subscribe_typed( + SkillGainEvent, + self.on_skill_gain, + replay_last=10 + ) + """ + if not self.api: + print(f"[{self.name}] API not available for event subscription") + return "" + + sub_id = self.api.subscribe_typed(event_class, callback, **filter_kwargs) + if sub_id: + self._event_subscriptions.append(sub_id) + return sub_id + + def unsubscribe_typed(self, subscription_id: str) -> bool: + """ + Unsubscribe from a specific typed event subscription. + + Args: + subscription_id: The subscription ID returned by subscribe_typed + + Returns: + True if subscription was found and removed + """ + if not self.api: + return False + + result = self.api.unsubscribe_typed(subscription_id) + if result and subscription_id in self._event_subscriptions: + self._event_subscriptions.remove(subscription_id) + return result + + def unsubscribe_all_typed(self) -> None: + """Unsubscribe from all typed event subscriptions.""" + if not self.api: + return + + for sub_id in self._event_subscriptions[:]: # Copy list to avoid modification during iteration + self.api.unsubscribe_typed(sub_id) + self._event_subscriptions.clear() + + def get_recent_events( + self, + event_type: Type['BaseEvent'] = None, + count: int = 100, + category: 'EventCategory' = None + ) -> List['BaseEvent']: + """ + Get recent events from history. + + Args: + event_type: Filter by event class + count: Maximum number of events to return + category: Filter by event category + + Returns: + List of matching events + + Example: + from core.event_bus import LootEvent + + # Get last 20 loot events + recent_loot = self.get_recent_events(LootEvent, 20) + """ + if not self.api: + return [] + + return self.api.get_recent_events(event_type, count, category) + + def get_event_stats(self) -> Dict[str, Any]: + """ + Get Event Bus statistics. + + Returns: + Dict with event bus statistics + """ + if not self.api: + return {} + + return self.api.get_event_stats() + # ========== Utility Methods ========== def format_ped(self, value: float) -> str: @@ -261,3 +422,264 @@ class BasePlugin(ABC): if tt <= 0: return 0.0 return (price / tt) * 100 + + # ========== Audio Service Methods ========== + + def play_sound(self, filename_or_key: str, blocking: bool = False) -> bool: + """Play a sound by key or filename. + + Args: + filename_or_key: Sound key ('global', 'hof', 'skill_gain', 'alert', 'error') + or path to file + blocking: If True, wait for sound to complete (default: False) + + Returns: + True if sound was queued/played, False on error or if muted + + Examples: + # Play predefined sounds + self.play_sound('hof') + self.play_sound('skill_gain') + self.play_sound('alert') + + # Play custom sound file + self.play_sound('/path/to/custom.wav') + """ + if not self.api: + return False + + return self.api.play_sound(filename_or_key, blocking) + + def set_volume(self, volume: float) -> None: + """Set global audio volume. + + Args: + volume: Volume level from 0.0 (mute) to 1.0 (max) + """ + if self.api: + self.api.set_volume(volume) + + def get_volume(self) -> float: + """Get current audio volume. + + Returns: + Current volume level (0.0 to 1.0) + """ + if not self.api: + return 0.0 + + return self.api.get_volume() + + def mute(self) -> None: + """Mute all audio.""" + if self.api: + self.api.mute_audio() + + def unmute(self) -> None: + """Unmute audio.""" + if self.api: + self.api.unmute_audio() + + def toggle_mute(self) -> bool: + """Toggle audio mute state. + + Returns: + New muted state (True if now muted) + """ + if not self.api: + return False + + return self.api.toggle_mute_audio() + + def is_muted(self) -> bool: + """Check if audio is muted. + + Returns: + True if audio is muted + """ + if not self.api: + return False + + return self.api.is_audio_muted() + + def is_audio_available(self) -> bool: + """Check if audio service is available. + + Returns: + True if audio backend is initialized and working + """ + if not self.api: + return False + + return self.api.is_audio_available() + + # ========== Background Task Methods ========== + + def run_in_background(self, func: Callable, *args, + priority: str = 'normal', + on_complete: Callable = None, + on_error: Callable = None, + **kwargs) -> str: + """Run a function in a background thread. + + Use this instead of creating your own QThreads. + + Args: + func: Function to execute in background + *args: Positional arguments for the function + priority: 'high', 'normal', or 'low' (default: 'normal') + on_complete: Called with result when task completes successfully + on_error: Called with exception when task fails + **kwargs: Keyword arguments for the function + + Returns: + Task ID for tracking/cancellation + + Example: + def heavy_calculation(data): + return process(data) + + def on_done(result): + self.update_ui(result) + + def on_fail(error): + self.show_error(str(error)) + + task_id = self.run_in_background( + heavy_calculation, + large_dataset, + priority='high', + on_complete=on_done, + on_error=on_fail + ) + + # Or with decorator style: + @self.run_in_background + def fetch_remote_data(): + return requests.get(url).json() + """ + if not self.api: + raise RuntimeError("API not available") + + return self.api.run_in_background( + func, *args, + priority=priority, + on_complete=on_complete, + on_error=on_error, + **kwargs + ) + + def schedule_task(self, delay_ms: int, func: Callable, *args, + priority: str = 'normal', + on_complete: Callable = None, + on_error: Callable = None, + periodic: bool = False, + interval_ms: int = None, + **kwargs) -> str: + """Schedule a task for delayed or periodic execution. + + Args: + delay_ms: Milliseconds to wait before first execution + func: Function to execute + *args: Positional arguments + priority: 'high', 'normal', or 'low' + on_complete: Called with result after each execution + on_error: Called with exception if execution fails + periodic: If True, repeat execution at interval_ms + interval_ms: Milliseconds between periodic executions + **kwargs: Keyword arguments + + Returns: + Task ID for tracking/cancellation + + Example: + # One-time delayed execution + task_id = self.schedule_task( + 5000, # 5 seconds + lambda: print("Hello after delay!") + ) + + # Periodic data refresh (every 30 seconds) + self.schedule_task( + 0, # Start immediately + self.refresh_data, + periodic=True, + interval_ms=30000, + on_complete=lambda data: self.update_display(data) + ) + """ + if not self.api: + raise RuntimeError("API not available") + + return self.api.schedule_task( + delay_ms, func, *args, + priority=priority, + on_complete=on_complete, + on_error=on_error, + periodic=periodic, + interval_ms=interval_ms, + **kwargs + ) + + def cancel_task(self, task_id: str) -> bool: + """Cancel a pending or running task. + + Args: + task_id: Task ID returned by run_in_background or schedule_task + + Returns: + True if task was cancelled, False if not found or already done + """ + if not self.api: + return False + + return self.api.cancel_task(task_id) + + def connect_task_signals(self, + on_completed: Callable = None, + on_failed: Callable = None, + on_started: Callable = None, + on_cancelled: Callable = None) -> bool: + """Connect to task status signals for UI updates. + + Connects Qt signals so UI updates from background threads are thread-safe. + + Args: + on_completed: Called with (task_id, result) when tasks complete + on_failed: Called with (task_id, error_message) when tasks fail + on_started: Called with (task_id) when tasks start + on_cancelled: Called with (task_id) when tasks are cancelled + + Returns: + True if signals were connected + + Example: + class MyPlugin(BasePlugin): + def initialize(self): + # Connect task signals for UI updates + self.connect_task_signals( + on_completed=self._on_task_done, + on_failed=self._on_task_error + ) + + def _on_task_done(self, task_id, result): + self.status_label.setText(f"Task {task_id}: Done!") + + def _on_task_error(self, task_id, error): + self.status_label.setText(f"Task {task_id} failed: {error}") + """ + if not self.api: + return False + + connected = False + + if on_completed: + connected = self.api.connect_task_signal('completed', on_completed) or connected + if on_failed: + connected = self.api.connect_task_signal('failed', on_failed) or connected + if on_started: + connected = self.api.connect_task_signal('started', on_started) or connected + if on_cancelled: + connected = self.api.connect_task_signal('cancelled', on_cancelled) or connected + + return connected diff --git a/projects/EU-Utility/plugins/event_bus_example/__init__.py b/projects/EU-Utility/plugins/event_bus_example/__init__.py new file mode 100644 index 0000000..887ba92 --- /dev/null +++ b/projects/EU-Utility/plugins/event_bus_example/__init__.py @@ -0,0 +1,4 @@ +"""Event Bus Example Plugin.""" +from .plugin import EventBusExamplePlugin + +__all__ = ['EventBusExamplePlugin'] diff --git a/projects/EU-Utility/plugins/event_bus_example/plugin.py b/projects/EU-Utility/plugins/event_bus_example/plugin.py new file mode 100644 index 0000000..1b3081e --- /dev/null +++ b/projects/EU-Utility/plugins/event_bus_example/plugin.py @@ -0,0 +1,211 @@ +""" +Example plugin demonstrating Enhanced Event Bus usage. + +This plugin shows how to use: +- publish_typed() - Publish typed events +- subscribe_typed() - Subscribe with filtering +- get_recent_events() - Retrieve event history +- Event filtering (min_damage, mob_types, etc.) +- Event replay for new subscribers +""" + +from plugins.base_plugin import BasePlugin +from core.event_bus import ( + SkillGainEvent, LootEvent, DamageEvent, GlobalEvent, + EventCategory +) + + +class EventBusExamplePlugin(BasePlugin): + """Example plugin showing Enhanced Event Bus usage.""" + + name = "Event Bus Example" + version = "1.0.0" + author = "EU-Utility" + description = "Demonstrates Enhanced Event Bus features" + + def __init__(self, overlay_window, config): + super().__init__(overlay_window, config) + self.big_hits = [] + self.skill_gains = [] + self.dragon_loot = [] + self._subscriptions = [] + + def initialize(self) -> None: + """Setup event subscriptions.""" + print(f"[{self.name}] Initializing...") + + # 1. Subscribe to ALL damage events + sub_id = self.subscribe_typed( + DamageEvent, + self.on_any_damage, + replay_last=5 # Replay last 5 damage events on subscribe + ) + self._subscriptions.append(sub_id) + print(f"[{self.name}] Subscribed to all damage events") + + # 2. Subscribe to HIGH damage events only (filtering) + sub_id = self.subscribe_typed( + DamageEvent, + self.on_big_damage, + min_damage=100, # Only events with damage >= 100 + replay_last=3 + ) + self._subscriptions.append(sub_id) + print(f"[{self.name}] Subscribed to high damage events (≥100)") + + # 3. Subscribe to skill gains for specific skills + sub_id = self.subscribe_typed( + SkillGainEvent, + self.on_combat_skill_gain, + skill_names=["Rifle", "Handgun", "Sword", "Knife"], + replay_last=10 + ) + self._subscriptions.append(sub_id) + print(f"[{self.name}] Subscribed to combat skill gains") + + # 4. Subscribe to loot from specific mobs + sub_id = self.subscribe_typed( + LootEvent, + self.on_dragon_loot, + mob_types=["Dragon", "Drake", "Dragon Old"], + replay_last=5 + ) + self._subscriptions.append(sub_id) + print(f"[{self.name}] Subscribed to Dragon/Drake loot") + + # 5. Subscribe to ALL globals + sub_id = self.subscribe_typed( + GlobalEvent, + self.on_global_announcement + ) + self._subscriptions.append(sub_id) + print(f"[{self.name}] Subscribed to global announcements") + + # 6. Demonstrate publishing events + self._publish_example_events() + + # 7. Show event stats + stats = self.get_event_stats() + print(f"[{self.name}] Event Bus Stats:") + print(f" - Total published: {stats.get('total_published', 0)}") + print(f" - Active subs: {stats.get('active_subscriptions', 0)}") + + def _publish_example_events(self): + """Publish some example events to demonstrate.""" + # Publish a skill gain + self.publish_typed(SkillGainEvent( + skill_name="Rifle", + skill_value=25.5, + gain_amount=0.01, + source="example_plugin" + )) + + # Publish some damage events + self.publish_typed(DamageEvent( + damage_amount=50.5, + damage_type="impact", + is_outgoing=True, + target_name="Berycled Young", + source="example_plugin" + )) + + self.publish_typed(DamageEvent( + damage_amount=150.0, + damage_type="penetration", + is_critical=True, + is_outgoing=True, + target_name="Daikiba", + source="example_plugin" + )) + + # Publish loot + self.publish_typed(LootEvent( + mob_name="Dragon", + items=[ + {"name": "Dragon Scale", "value": 15.0}, + {"name": "Animal Oil", "value": 0.05} + ], + total_tt_value=15.05, + source="example_plugin" + )) + + print(f"[{self.name}] Published example events") + + # ========== Event Handlers ========== + + def on_any_damage(self, event: DamageEvent): + """Handle all damage events.""" + direction = "dealt" if event.is_outgoing else "received" + crit = " CRITICAL" if event.is_critical else "" + print(f"[{self.name}] Damage {direction}: {event.damage_amount:.1f}{crit} to {event.target_name}") + + def on_big_damage(self, event: DamageEvent): + """Handle only high damage events (filtered).""" + self.big_hits.append(event) + print(f"[{self.name}] 💥 BIG HIT! {event.damage_amount:.1f} damage to {event.target_name}") + print(f"[{self.name}] Total big hits recorded: {len(self.big_hits)}") + + def on_combat_skill_gain(self, event: SkillGainEvent): + """Handle combat skill gains.""" + self.skill_gains.append(event) + print(f"[{self.name}] ⚔️ Skill up: {event.skill_name} +{event.gain_amount:.4f} = {event.skill_value:.4f}") + + def on_dragon_loot(self, event: LootEvent): + """Handle Dragon/Drake loot.""" + self.dragon_loot.append(event) + items_str = ", ".join(event.get_item_names()) + print(f"[{self.name}] 🐉 Dragon loot from {event.mob_name}: {items_str} (TT: {event.total_tt_value:.2f} PED)") + + def on_global_announcement(self, event: GlobalEvent): + """Handle global announcements.""" + item_str = f" with {event.item_name}" if event.item_name else "" + print(f"[{self.name}] 🌍 GLOBAL: {event.player_name} - {event.achievement_type.upper()}{item_str} ({event.value:.2f} PED)") + + def get_ui(self): + """Return simple info panel.""" + from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit + + widget = QWidget() + layout = QVBoxLayout() + + # Title + title = QLabel(f"

{self.name}

") + layout.addWidget(title) + + # Stats + stats_text = f""" + Recorded Events:
+ • Big Hits (≥100 dmg): {len(self.big_hits)}
+ • Combat Skill Gains: {len(self.skill_gains)}
+ • Dragon Loot: {len(self.dragon_loot)}
+
+ Active Subscriptions: {len(self._subscriptions)} + """ + stats_label = QLabel(stats_text) + stats_label.setWordWrap(True) + layout.addWidget(stats_label) + + # Recent events + layout.addWidget(QLabel("Recent Combat Events:")) + text_area = QTextEdit() + text_area.setReadOnly(True) + text_area.setMaximumHeight(200) + + # Get recent damage events from event bus + recent = self.get_recent_events(DamageEvent, count=10) + events_text = "\\n".join([ + f"• {e.damage_amount:.1f} dmg to {e.target_name}" + for e in reversed(recent) + ]) or "No recent damage events" + text_area.setText(events_text) + layout.addWidget(text_area) + + widget.setLayout(layout) + return widget + + def shutdown(self) -> None: + """Cleanup.""" + print(f"[{self.name}] Shutting down...") + # Unsubscribe from all typed events (handled by base class) + super().shutdown() diff --git a/projects/EU-Utility/plugins/nexus_search/plugin.py b/projects/EU-Utility/plugins/nexus_search/plugin.py index 7d979d5..6dea9f3 100644 --- a/projects/EU-Utility/plugins/nexus_search/plugin.py +++ b/projects/EU-Utility/plugins/nexus_search/plugin.py @@ -5,12 +5,10 @@ Built-in plugin for searching EntropiaNexus via API. Uses official Nexus API endpoints. """ -import urllib.request -import urllib.parse import json import webbrowser from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, + QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton, QLabel, QComboBox, QListWidget, QListWidgetItem, QTabWidget, QTableWidget, QTableWidgetItem, QHeaderView @@ -22,80 +20,105 @@ from plugins.base_plugin import BasePlugin class NexusAPIClient: """Client for EntropiaNexus API.""" - + BASE_URL = "https://www.entropianexus.com" - + @classmethod - def fetch_exchange_items(cls, search_query=None): + def fetch_exchange_items(cls, search_query=None, http_get_func=None): """Fetch exchange items from Nexus API.""" try: url = f"{cls.BASE_URL}/api/market/exchange" - - req = urllib.request.Request( - url, - headers={ - 'Accept': 'application/json', - 'Accept-Encoding': 'gzip', - } - ) - - with urllib.request.urlopen(req, timeout=10) as response: - data = json.loads(response.read().decode('utf-8')) - - # Filter by search query if provided - if search_query and data: - search_lower = search_query.lower() - filtered = [] - for category in data: - if 'items' in category: - for item in category['items']: - if search_lower in item.get('name', '').lower(): - filtered.append(item) - return filtered - - return data - + + if http_get_func: + response = http_get_func( + url, + cache_ttl=60, # 1 minute cache for market data + headers={'Accept': 'application/json', 'Accept-Encoding': 'gzip'} + ) + data = response.get('json') if response else None + else: + # Fallback for standalone usage + import urllib.request + req = urllib.request.Request( + url, + headers={'Accept': 'application/json', 'Accept-Encoding': 'gzip'} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode('utf-8')) + + # Filter by search query if provided + if search_query and data: + search_lower = search_query.lower() + filtered = [] + for category in data: + if 'items' in category: + for item in category['items']: + if search_lower in item.get('name', '').lower(): + filtered.append(item) + return filtered + + return data + except Exception as e: print(f"API Error: {e}") return None - + @classmethod - def fetch_item_prices(cls, item_ids): + def fetch_item_prices(cls, item_ids, http_get_func=None): """Fetch latest prices for items.""" try: if not item_ids: return {} - + ids_str = ','.join(str(id) for id in item_ids[:100]) # Max 100 url = f"{cls.BASE_URL}/api/market/prices/latest?items={ids_str}" - - req = urllib.request.Request( - url, - headers={'Accept': 'application/json'} - ) - - with urllib.request.urlopen(req, timeout=10) as response: - return json.loads(response.read().decode('utf-8')) - + + if http_get_func: + response = http_get_func( + url, + cache_ttl=60, # 1 minute cache + headers={'Accept': 'application/json'} + ) + return response.get('json') if response else {} + else: + # Fallback for standalone usage + import urllib.request + req = urllib.request.Request( + url, + headers={'Accept': 'application/json'} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode('utf-8')) + except Exception as e: print(f"Price API Error: {e}") return {} - + @classmethod - def search_users(cls, query): + def search_users(cls, query, http_get_func=None): """Search for verified users.""" try: - params = urllib.parse.urlencode({'q': query, 'limit': 10}) - url = f"{cls.BASE_URL}/api/users/search?{params}" - - req = urllib.request.Request( - url, - headers={'Accept': 'application/json'} - ) - - with urllib.request.urlopen(req, timeout=10) as response: - return json.loads(response.read().decode('utf-8')) - + params = {'q': query, 'limit': 10} + query_string = '&'.join(f"{k}={v}" for k, v in params.items()) + url = f"{cls.BASE_URL}/api/users/search?{query_string}" + + if http_get_func: + response = http_get_func( + url, + cache_ttl=300, # 5 minute cache for user search + headers={'Accept': 'application/json'} + ) + return response.get('json') if response else None + else: + # Fallback for standalone usage + import urllib.request + req = urllib.request.Request( + url, + headers={'Accept': 'application/json'} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode('utf-8')) + except Exception as e: print(f"User Search Error: {e}") return None @@ -105,20 +128,21 @@ class NexusSearchThread(QThread): """Background thread for API searches.""" results_ready = pyqtSignal(list, str) # results, search_type error_occurred = pyqtSignal(str) - - def __init__(self, query, search_type): + + def __init__(self, query, search_type, http_get_func=None): super().__init__() self.query = query self.search_type = search_type - + self.http_get_func = http_get_func + def run(self): """Perform API search.""" try: results = [] - + if self.search_type == "Items": # Search exchange items - data = NexusAPIClient.fetch_exchange_items(self.query) + data = NexusAPIClient.fetch_exchange_items(self.query, http_get_func=self.http_get_func) if data: if isinstance(data, list) and len(data) > 0 and 'name' in data[0]: # Already filtered items @@ -134,48 +158,48 @@ class NexusSearchThread(QThread): break if len(results) >= 20: break - + elif self.search_type == "Users": # Search users - data = NexusAPIClient.search_users(self.query) + data = NexusAPIClient.search_users(self.query, http_get_func=self.http_get_func) if data: results = data[:10] - + self.results_ready.emit(results, self.search_type) - + except Exception as e: self.error_occurred.emit(str(e)) class NexusSearchPlugin(BasePlugin): """Search EntropiaNexus via API.""" - + name = "EntropiaNexus" version = "1.1.0" author = "ImpulsiveFPS" description = "Search items, users, and market data via Nexus API" hotkey = "ctrl+shift+n" - + def initialize(self): """Setup the plugin.""" self.base_url = "https://www.entropianexus.com" self.search_thread = None self.current_results = [] - + def get_ui(self): """Create plugin UI.""" widget = QWidget() layout = QVBoxLayout(widget) - + # Title title = QLabel("EntropiaNexus") title.setStyleSheet("color: #4a9eff; font-size: 18px; font-weight: bold;") layout.addWidget(title) - + # Search type type_layout = QHBoxLayout() type_layout.addWidget(QLabel("Search:")) - + self.search_type = QComboBox() self.search_type.addItems([ "Items", @@ -191,7 +215,7 @@ class NexusSearchPlugin(BasePlugin): } """) type_layout.addWidget(self.search_type) - + # Search input self.search_input = QLineEdit() self.search_input.setPlaceholderText("Enter search term...") @@ -210,7 +234,7 @@ class NexusSearchPlugin(BasePlugin): """) self.search_input.returnPressed.connect(self._do_search) type_layout.addWidget(self.search_input, 1) - + # Search button search_btn = QPushButton("🔍") search_btn.setFixedWidth(40) @@ -228,14 +252,14 @@ class NexusSearchPlugin(BasePlugin): """) search_btn.clicked.connect(self._do_search) type_layout.addWidget(search_btn) - + layout.addLayout(type_layout) - + # Status self.status_label = QLabel("Ready") self.status_label.setStyleSheet("color: #666; font-size: 11px;") layout.addWidget(self.status_label) - + # Results table self.results_table = QTableWidget() self.results_table.setColumnCount(3) @@ -268,10 +292,10 @@ class NexusSearchPlugin(BasePlugin): self.results_table.cellClicked.connect(self._on_item_clicked) self.results_table.setMaximumHeight(300) layout.addWidget(self.results_table) - + # Action buttons btn_layout = QHBoxLayout() - + open_btn = QPushButton("Open on Nexus") open_btn.setStyleSheet(""" QPushButton { @@ -287,14 +311,14 @@ class NexusSearchPlugin(BasePlugin): """) open_btn.clicked.connect(self._open_selected) btn_layout.addWidget(open_btn) - + btn_layout.addStretch() - + # Quick links links_label = QLabel("Quick:") links_label.setStyleSheet("color: #666;") btn_layout.addWidget(links_label) - + for name, url in [ ("Market", "/market/exchange"), ("Items", "/items"), @@ -315,102 +339,102 @@ class NexusSearchPlugin(BasePlugin): """) btn.clicked.connect(lambda checked, u=self.base_url + url: webbrowser.open(u)) btn_layout.addWidget(btn) - + layout.addLayout(btn_layout) layout.addStretch() - + return widget - + def _do_search(self): """Perform API search.""" query = self.search_input.text().strip() if not query or len(query) < 2: self.status_label.setText("Enter at least 2 characters") return - + search_type = self.search_type.currentText() - + # Clear previous results self.results_table.setRowCount(0) self.current_results = [] self.status_label.setText("Searching...") - - # Start search thread - self.search_thread = NexusSearchThread(query, search_type) + + # Start search thread with http_get function + self.search_thread = NexusSearchThread(query, search_type, http_get_func=self.http_get) self.search_thread.results_ready.connect(self._on_results) self.search_thread.error_occurred.connect(self._on_error) self.search_thread.start() - + def _on_results(self, results, search_type): """Handle search results.""" self.current_results = results - + if not results: self.status_label.setText("No results found") return - + # Populate table self.results_table.setRowCount(len(results)) - + for row, item in enumerate(results): if search_type == "Items": name = item.get('name', 'Unknown') item_type = item.get('type', 'Item') - + # Price info buy_price = item.get('buy', []) sell_price = item.get('sell', []) - + if buy_price: price_text = f"Buy: {buy_price[0].get('price', 'N/A')}" elif sell_price: price_text = f"Sell: {sell_price[0].get('price', 'N/A')}" else: price_text = "No orders" - + self.results_table.setItem(row, 0, QTableWidgetItem(name)) self.results_table.setItem(row, 1, QTableWidgetItem(item_type)) self.results_table.setItem(row, 2, QTableWidgetItem(price_text)) - + elif search_type == "Users": name = item.get('name', 'Unknown') eu_name = item.get('euName', '') - + self.results_table.setItem(row, 0, QTableWidgetItem(name)) self.results_table.setItem(row, 1, QTableWidgetItem("User")) self.results_table.setItem(row, 2, QTableWidgetItem(eu_name or '')) - + self.status_label.setText(f"Found {len(results)} results") - + def _on_error(self, error): """Handle search error.""" self.status_label.setText(f"Error: {error}") - + def _on_item_clicked(self, row, column): """Handle item click.""" if row < len(self.current_results): item = self.current_results[row] search_type = self.search_type.currentText() - + if search_type == "Items": item_id = item.get('id') if item_id: url = f"{self.base_url}/items/{item_id}" webbrowser.open(url) - + elif search_type == "Users": user_id = item.get('id') if user_id: url = f"{self.base_url}/users/{user_id}" webbrowser.open(url) - + def _open_selected(self): """Open selected item in browser.""" selected = self.results_table.selectedItems() if selected: row = selected[0].row() self._on_item_clicked(row, 0) - + def on_hotkey(self): """Focus search when hotkey pressed.""" if hasattr(self, 'search_input'): diff --git a/projects/EU-Utility/plugins/universal_search/plugin.py b/projects/EU-Utility/plugins/universal_search/plugin.py index 9dd9445..e7644af 100644 --- a/projects/EU-Utility/plugins/universal_search/plugin.py +++ b/projects/EU-Utility/plugins/universal_search/plugin.py @@ -4,12 +4,10 @@ EU-Utility - Universal Search Plugin Search across all Entropia Nexus entities - items, mobs, locations, blueprints, skills, etc. """ -import urllib.request -import urllib.parse import json import webbrowser from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, + QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton, QLabel, QComboBox, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, QStackedWidget, QFrame @@ -21,9 +19,9 @@ from plugins.base_plugin import BasePlugin class NexusEntityAPI: """Client for Entropia Nexus Entity API.""" - + BASE_URL = "https://api.entropianexus.com" - + # Entity type to API endpoint mapping ENDPOINTS = { "Items": "/items", @@ -51,69 +49,77 @@ class NexusEntityAPI: "Planets": "/planets", "Areas": "/areas", } - + @classmethod - def search_entities(cls, entity_type, query, limit=50): + def search_entities(cls, entity_type, query, limit=50, http_get_func=None): """Search for entities of a specific type.""" try: endpoint = cls.ENDPOINTS.get(entity_type, "/items") - - # Build URL with query - params = urllib.parse.urlencode({ - 'q': query, - 'limit': limit, - 'fuzzy': 'true' - }) - url = f"{cls.BASE_URL}{endpoint}?{params}" - - req = urllib.request.Request( - url, - headers={ - 'Accept': 'application/json', - 'User-Agent': 'EU-Utility/1.0' - } - ) - - with urllib.request.urlopen(req, timeout=15) as response: - data = json.loads(response.read().decode('utf-8')) - return data if isinstance(data, list) else [] - + + # Build URL with query params + params = {'q': query, 'limit': limit, 'fuzzy': 'true'} + query_string = '&'.join(f"{k}={v}" for k, v in params.items()) + url = f"{cls.BASE_URL}{endpoint}?{query_string}" + + if http_get_func: + response = http_get_func( + url, + cache_ttl=300, # 5 minute cache + headers={'Accept': 'application/json', 'User-Agent': 'EU-Utility/1.0'} + ) + else: + # Fallback for standalone usage + import urllib.request + req = urllib.request.Request( + url, + headers={'Accept': 'application/json', 'User-Agent': 'EU-Utility/1.0'} + ) + with urllib.request.urlopen(req, timeout=15) as resp: + response = {'json': json.loads(resp.read().decode('utf-8'))} + + data = response.get('json') if response else None + return data if isinstance(data, list) else [] + except Exception as e: print(f"API Error ({entity_type}): {e}") return [] - + @classmethod - def universal_search(cls, query, limit=30): + def universal_search(cls, query, limit=30, http_get_func=None): """Universal search across all entity types.""" try: - params = urllib.parse.urlencode({ - 'query': query, - 'limit': limit, - 'fuzzy': 'true' - }) - url = f"{cls.BASE_URL}/search?{params}" - - req = urllib.request.Request( - url, - headers={ - 'Accept': 'application/json', - 'User-Agent': 'EU-Utility/1.0' - } - ) - - with urllib.request.urlopen(req, timeout=15) as response: - data = json.loads(response.read().decode('utf-8')) - return data if isinstance(data, list) else [] - + params = {'query': query, 'limit': limit, 'fuzzy': 'true'} + query_string = '&'.join(f"{k}={v}" for k, v in params.items()) + url = f"{cls.BASE_URL}/search?{query_string}" + + if http_get_func: + response = http_get_func( + url, + cache_ttl=300, # 5 minute cache + headers={'Accept': 'application/json', 'User-Agent': 'EU-Utility/1.0'} + ) + else: + # Fallback for standalone usage + import urllib.request + req = urllib.request.Request( + url, + headers={'Accept': 'application/json', 'User-Agent': 'EU-Utility/1.0'} + ) + with urllib.request.urlopen(req, timeout=15) as resp: + response = {'json': json.loads(resp.read().decode('utf-8'))} + + data = response.get('json') if response else None + return data if isinstance(data, list) else [] + except Exception as e: print(f"Universal Search Error: {e}") return [] - + @classmethod def get_entity_url(cls, entity_type, entity_id_or_name): """Get the web URL for an entity.""" web_base = "https://www.entropianexus.com" - + # Map to web paths web_paths = { "Items": "items", @@ -141,7 +147,7 @@ class NexusEntityAPI: "Planets": "locations", "Areas": "locations", } - + path = web_paths.get(entity_type, "items") return f"{web_base}/{path}/{entity_id_or_name}" @@ -150,61 +156,62 @@ class UniversalSearchThread(QThread): """Background thread for API searches.""" results_ready = pyqtSignal(list, str) error_occurred = pyqtSignal(str) - - def __init__(self, query, entity_type, universal=False): + + def __init__(self, query, entity_type, universal=False, http_get_func=None): super().__init__() self.query = query self.entity_type = entity_type self.universal = universal - + self.http_get_func = http_get_func + def run(self): """Perform API search.""" try: if self.universal: - results = NexusEntityAPI.universal_search(self.query) + results = NexusEntityAPI.universal_search(self.query, http_get_func=self.http_get_func) else: - results = NexusEntityAPI.search_entities(self.entity_type, self.query) - + results = NexusEntityAPI.search_entities(self.entity_type, self.query, http_get_func=self.http_get_func) + self.results_ready.emit(results, self.entity_type) - + except Exception as e: self.error_occurred.emit(str(e)) class UniversalSearchPlugin(BasePlugin): """Universal search across all Nexus entities.""" - + name = "Universal Search" version = "2.0.0" author = "ImpulsiveFPS" description = "Search items, mobs, locations, blueprints, skills, and more" hotkey = "ctrl+shift+f" # F for Find - + def initialize(self): """Setup the plugin.""" self.search_thread = None self.current_results = [] self.current_entity_type = "Universal" - + def get_ui(self): """Create plugin UI.""" widget = QWidget() layout = QVBoxLayout(widget) layout.setSpacing(10) - + # Title - NO EMOJI title = QLabel("Universal Search") title.setStyleSheet("color: #4a9eff; font-size: 18px; font-weight: bold;") layout.addWidget(title) - + # Search mode selector mode_layout = QHBoxLayout() mode_layout.addWidget(QLabel("Mode:")) - + self.search_mode = QComboBox() self.search_mode.addItem("Universal (All Types)", "Universal") self.search_mode.addItem("──────────────────", "separator") - + # Add all entity types entity_types = [ "Items", @@ -232,10 +239,10 @@ class UniversalSearchPlugin(BasePlugin): "Planets", "Areas", ] - + for etype in entity_types: self.search_mode.addItem(f" {etype}", etype) - + self.search_mode.setStyleSheet(""" QComboBox { background-color: #444; @@ -251,12 +258,12 @@ class UniversalSearchPlugin(BasePlugin): self.search_mode.currentIndexChanged.connect(self._on_mode_changed) mode_layout.addWidget(self.search_mode) mode_layout.addStretch() - + layout.addLayout(mode_layout) - + # Search bar search_layout = QHBoxLayout() - + self.search_input = QLineEdit() self.search_input.setPlaceholderText("Search for anything... (e.g., 'ArMatrix', 'Argonaut', 'Calypso')") self.search_input.setStyleSheet(""" @@ -274,7 +281,7 @@ class UniversalSearchPlugin(BasePlugin): """) self.search_input.returnPressed.connect(self._do_search) search_layout.addWidget(self.search_input, 1) - + search_btn = QPushButton("Search") search_btn.setStyleSheet(""" QPushButton { @@ -292,14 +299,14 @@ class UniversalSearchPlugin(BasePlugin): """) search_btn.clicked.connect(self._do_search) search_layout.addWidget(search_btn) - + layout.addLayout(search_layout) - + # Status self.status_label = QLabel("Ready to search") self.status_label.setStyleSheet("color: #666; font-size: 11px;") layout.addWidget(self.status_label) - + # Results table self.results_table = QTableWidget() self.results_table.setColumnCount(4) @@ -343,10 +350,10 @@ class UniversalSearchPlugin(BasePlugin): self.results_table.setMaximumHeight(350) self.results_table.setMinimumHeight(200) layout.addWidget(self.results_table) - + # Action buttons action_layout = QHBoxLayout() - + self.open_btn = QPushButton("Open Selected") self.open_btn.setEnabled(False) self.open_btn.setStyleSheet(""" @@ -367,14 +374,14 @@ class UniversalSearchPlugin(BasePlugin): """) self.open_btn.clicked.connect(self._open_selected) action_layout.addWidget(self.open_btn) - + action_layout.addStretch() - + # Quick category buttons quick_label = QLabel("Quick:") quick_label.setStyleSheet("color: #666;") action_layout.addWidget(quick_label) - + for category in ["Items", "Mobs", "Blueprints", "Locations"]: btn = QPushButton(category) btn.setStyleSheet(""" @@ -392,81 +399,84 @@ class UniversalSearchPlugin(BasePlugin): """) btn.clicked.connect(lambda checked, c=category: self._quick_search(c)) action_layout.addWidget(btn) - + layout.addLayout(action_layout) - + # Tips tips = QLabel("Tip: Double-click result to open on Nexus website") tips.setStyleSheet("color: #555; font-size: 10px;") layout.addWidget(tips) - + layout.addStretch() - + return widget - + def _on_mode_changed(self): """Handle search mode change.""" data = self.search_mode.currentData() if data == "separator": # Reset to previous valid selection self.search_mode.setCurrentIndex(0) - + def _do_search(self): """Perform search.""" query = self.search_input.text().strip() if len(query) < 2: self.status_label.setText("⚠️ Enter at least 2 characters") return - + entity_type = self.search_mode.currentData() if entity_type == "separator": entity_type = "Universal" - + self.current_entity_type = entity_type universal = (entity_type == "Universal") - + # Clear previous results self.results_table.setRowCount(0) self.current_results = [] self.open_btn.setEnabled(False) self.status_label.setText(f"Searching for '{query}'...") - - # Start search thread - self.search_thread = UniversalSearchThread(query, entity_type, universal) + + # Start search thread with http_get function + self.search_thread = UniversalSearchThread( + query, entity_type, universal, + http_get_func=self.http_get + ) self.search_thread.results_ready.connect(self._on_results) self.search_thread.error_occurred.connect(self._on_error) self.search_thread.start() - + def _quick_search(self, category): """Quick search for a specific category.""" # Set the category index = self.search_mode.findData(category) if index >= 0: self.search_mode.setCurrentIndex(index) - + # If there's text in the search box, search immediately if self.search_input.text().strip(): self._do_search() else: self.search_input.setFocus() self.status_label.setText(f"Selected: {category} - Enter search term") - + def _on_results(self, results, entity_type): """Handle search results.""" self.current_results = results - + if not results: self.status_label.setText("❌ No results found") return - + # Populate table self.results_table.setRowCount(len(results)) - + for row, item in enumerate(results): # Extract data based on available fields name = item.get('name', item.get('Name', 'Unknown')) item_id = str(item.get('id', item.get('Id', ''))) - + # Determine type if 'type' in item: item_type = item['type'] @@ -475,19 +485,19 @@ class UniversalSearchPlugin(BasePlugin): else: # Try to infer from other fields item_type = self._infer_type(item) - + # Build details string details = self._build_details(item, item_type) - + # Set table items self.results_table.setItem(row, 0, QTableWidgetItem(name)) self.results_table.setItem(row, 1, QTableWidgetItem(item_type)) self.results_table.setItem(row, 2, QTableWidgetItem(details)) self.results_table.setItem(row, 3, QTableWidgetItem(item_id)) - + self.open_btn.setEnabled(True) self.status_label.setText(f"✅ Found {len(results)} results") - + def _infer_type(self, item): """Infer entity type from item fields.""" if 'damage' in item or 'range' in item: @@ -504,11 +514,11 @@ class UniversalSearchPlugin(BasePlugin): return item['category'] else: return "Item" - + def _build_details(self, item, item_type): """Build details string based on item type.""" details = [] - + if item_type in ["Weapon", "Weapons"]: if 'damage' in item: details.append(f"Dmg: {item['damage']}") @@ -516,13 +526,13 @@ class UniversalSearchPlugin(BasePlugin): details.append(f"Range: {item['range']}m") if 'attacks' in item: details.append(f"{item['attacks']} attacks") - + elif item_type in ["Armor", "Armors"]: if 'protection' in item: details.append(f"Prot: {item['protection']}") if 'durability' in item: details.append(f"Dur: {item['durability']}") - + elif item_type in ["Mob", "Mobs"]: if 'hitpoints' in item: details.append(f"HP: {item['hitpoints']}") @@ -530,59 +540,59 @@ class UniversalSearchPlugin(BasePlugin): details.append(f"Dmg: {item['damage']}") if 'threat' in item: details.append(f"Threat: {item['threat']}") - + elif item_type in ["Blueprint", "Blueprints"]: if 'qr' in item: details.append(f"QR: {item['qr']}") if 'click' in item: details.append(f"Clicks: {item['click']}") - + elif item_type in ["Location", "Locations", "Teleporter", "Shop"]: if 'planet' in item: details.append(item['planet']) if 'x' in item and 'y' in item: details.append(f"[{item['x']}, {item['y']}]") - + elif item_type in ["Skill", "Skills"]: if 'category' in item: details.append(item['category']) - + # Add any other interesting fields if 'level' in item: details.append(f"Lvl: {item['level']}") if 'weight' in item: details.append(f"{item['weight']}kg") - + return " | ".join(details) if details else "" - + def _on_error(self, error): """Handle search error.""" self.status_label.setText(f"❌ Error: {error}") - + def _on_item_double_clicked(self, row, column): """Handle item double-click.""" self._open_result(row) - + def _open_selected(self): """Open selected result.""" selected = self.results_table.selectedItems() if selected: row = selected[0].row() self._open_result(row) - + def _open_result(self, row): """Open result in browser.""" if row < len(self.current_results): item = self.current_results[row] entity_id = item.get('id', item.get('Id', '')) entity_name = item.get('name', item.get('Name', '')) - + # Use name for URL if available, otherwise ID url_param = entity_name if entity_name else str(entity_id) url = NexusEntityAPI.get_entity_url(self.current_entity_type, url_param) - + webbrowser.open(url) - + def on_hotkey(self): """Focus search when hotkey pressed.""" if hasattr(self, 'search_input'): diff --git a/projects/EU-Utility/tests/test_nexus_api.py b/projects/EU-Utility/tests/test_nexus_api.py new file mode 100644 index 0000000..26c67b9 --- /dev/null +++ b/projects/EU-Utility/tests/test_nexus_api.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +""" +Test script for Nexus API Client integration. + +Tests: +1. NexusAPI singleton creation +2. Search methods (items, mobs, all) +3. PluginAPI integration +4. Plugin access via self.api.nexus_search() +""" + +import sys +from pathlib import Path + +# Add project root to path +project_root = Path(__file__).parent.parent +if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + +from core.nexus_api import get_nexus_api, NexusAPI, search_items, search_mobs +from core.plugin_api import get_api, PluginAPI + + +def test_nexus_singleton(): + """Test that NexusAPI is a singleton.""" + print("\n[Test 1] NexusAPI Singleton...") + api1 = get_nexus_api() + api2 = get_nexus_api() + + assert api1 is api2, "NexusAPI should be singleton" + assert isinstance(api1, NexusAPI), "Should return NexusAPI instance" + print(" ✓ NexusAPI singleton works correctly") + return True + + +def test_plugin_api_nexus_methods(): + """Test PluginAPI has Nexus methods.""" + print("\n[Test 2] PluginAPI Nexus Methods...") + plugin_api = get_api() + + # Check methods exist + assert hasattr(plugin_api, 'register_nexus_service'), "Missing register_nexus_service" + assert hasattr(plugin_api, 'nexus_search'), "Missing nexus_search" + assert hasattr(plugin_api, 'nexus_get_item_details'), "Missing nexus_get_item_details" + assert hasattr(plugin_api, 'nexus_get_market_data'), "Missing nexus_get_market_data" + + print(" ✓ PluginAPI has all Nexus methods") + return True + + +def test_nexus_service_registration(): + """Test Nexus service registration.""" + print("\n[Test 3] Nexus Service Registration...") + plugin_api = get_api() + nexus = get_nexus_api() + + # Register service + plugin_api.register_nexus_service(nexus) + + # Verify it's in services + assert 'nexus' in plugin_api.services, "Nexus service not registered" + assert plugin_api.services['nexus'] is nexus, "Wrong service instance" + + print(" ✓ Nexus service registered correctly") + return True + + +def test_nexus_search_via_plugin_api(): + """Test nexus_search through PluginAPI.""" + print("\n[Test 4] Nexus Search via PluginAPI...") + plugin_api = get_api() + + # This will return [] because we don't have actual API access in test + # but it shouldn't raise an exception + try: + results = plugin_api.nexus_search("ArMatrix", "items") + assert isinstance(results, list), "Should return list" + print(f" ✓ nexus_search returns list (got {len(results)} results)") + return True + except Exception as e: + print(f" ✗ nexus_search failed: {e}") + return False + + +def test_plugin_base_nexus_methods(): + """Test that BasePlugin would have nexus_search method.""" + print("\n[Test 5] BasePlugin Nexus Access...") + + # Import base plugin + from plugins.base_plugin import BasePlugin + + # Check method exists + assert hasattr(BasePlugin, 'nexus_search'), "BasePlugin missing nexus_search" + assert hasattr(BasePlugin, 'nexus_get_item_details'), "BasePlugin missing nexus_get_item_details" + assert hasattr(BasePlugin, 'nexus_get_market_data'), "BasePlugin missing nexus_get_market_data" + + print(" ✓ BasePlugin has all Nexus convenience methods") + return True + + +def test_nexus_api_configuration(): + """Test NexusAPI configuration.""" + print("\n[Test 6] NexusAPI Configuration...") + nexus = get_nexus_api() + + # Check configuration constants + assert nexus.BASE_URL == "https://api.entropianexus.com", "Wrong base URL" + assert nexus.API_VERSION == "v1", "Wrong API version" + assert nexus.MAX_REQUESTS_PER_SECOND == 5, "Wrong rate limit" + assert nexus.MAX_RETRIES == 3, "Wrong retry count" + + print(f" ✓ Configuration correct:") + print(f" - Base URL: {nexus.BASE_URL}") + print(f" - Version: {nexus.API_VERSION}") + print(f" - Rate limit: {nexus.MAX_REQUESTS_PER_SECOND} req/sec") + print(f" - Max retries: {nexus.MAX_RETRIES}") + return True + + +def test_nexus_api_methods_exist(): + """Test all NexusAPI methods exist.""" + print("\n[Test 7] NexusAPI Methods...") + nexus = get_nexus_api() + + required_methods = [ + 'search_items', + 'search_mobs', + 'search_all', + 'get_item_details', + 'get_market_data', + 'clear_cache', + 'is_available' + ] + + for method in required_methods: + assert hasattr(nexus, method), f"Missing method: {method}" + assert callable(getattr(nexus, method)), f"Not callable: {method}" + + print(f" ✓ All {len(required_methods)} required methods present") + return True + + +def simulate_plugin_usage(): + """Simulate how a plugin would use the Nexus API.""" + print("\n[Test 8] Simulating Plugin Usage...") + + # Create a mock plugin class + class MockPlugin: + def __init__(self): + self.api = get_api() + + def search_items(self, query): + """Plugin calls nexus_search via API.""" + return self.api.nexus_search(query, "items") + + def get_item_info(self, item_id): + """Plugin gets item details.""" + return self.api.nexus_get_item_details(item_id) + + # Create plugin and test + plugin = MockPlugin() + + # These should work without errors (returning empty/None since no API key) + try: + results = plugin.search_items("ArMatrix") + print(f" ✓ Plugin can call nexus_search (returned {len(results)} results)") + + details = plugin.get_item_info("test_item") + print(f" ✓ Plugin can call nexus_get_item_details (returned {details})") + + # Test the exact call from requirements + results2 = plugin.api.nexus_search("ArMatrix", "items") + print(f" ✓ Direct API call works: self.api.nexus_search('ArMatrix', 'items')") + + return True + except Exception as e: + print(f" ✗ Plugin usage failed: {e}") + return False + + +def main(): + """Run all tests.""" + print("=" * 60) + print("Nexus API Client Integration Tests") + print("=" * 60) + + tests = [ + test_nexus_singleton, + test_plugin_api_nexus_methods, + test_nexus_service_registration, + test_nexus_search_via_plugin_api, + test_plugin_base_nexus_methods, + test_nexus_api_configuration, + test_nexus_api_methods_exist, + simulate_plugin_usage + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + if test(): + passed += 1 + else: + failed += 1 + except Exception as e: + print(f" ✗ Test failed with exception: {e}") + import traceback + traceback.print_exc() + failed += 1 + + print("\n" + "=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return failed == 0 + + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7c37124 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyperclip>=1.8.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_clipboard.py b/tests/test_clipboard.py new file mode 100644 index 0000000..eccc2e6 --- /dev/null +++ b/tests/test_clipboard.py @@ -0,0 +1,274 @@ +""" +Test suite for Clipboard Manager + +Run with: python -m pytest tests/test_clipboard.py -v +Or: python tests/test_clipboard.py +""" + +import json +import os +import sys +import tempfile +import threading +import time +from pathlib import Path + +# Add parent to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from core.clipboard import ClipboardManager, ClipboardEntry, get_clipboard_manager + + +class TestClipboardManager: + """Tests for ClipboardManager functionality.""" + + def setup_method(self): + """Setup for each test - use temp file for history.""" + # Reset singleton for clean tests + ClipboardManager._instance = None + self.temp_dir = tempfile.mkdtemp() + self.history_file = os.path.join(self.temp_dir, "test_history.json") + self.manager = ClipboardManager(history_file=self.history_file) + + def teardown_method(self): + """Cleanup after each test.""" + self.manager.stop_monitoring() + ClipboardManager._instance = None + # Clean up temp file + if os.path.exists(self.history_file): + os.remove(self.history_file) + os.rmdir(self.temp_dir) + + def test_singleton(self): + """Test that ClipboardManager is a singleton.""" + manager1 = get_clipboard_manager() + manager2 = get_clipboard_manager() + assert manager1 is manager2 + + def test_copy_and_paste(self): + """Test basic copy and paste functionality.""" + test_text = "Hello, Clipboard!" + + # Copy + result = self.manager.copy(test_text) + assert result is True + + # Paste + pasted = self.manager.paste() + assert pasted == test_text + + def test_history_tracking(self): + """Test that copies are tracked in history.""" + texts = ["First", "Second", "Third"] + + for text in texts: + self.manager.copy(text, source="test") + time.sleep(0.05) # Small delay for timestamp differences + + history = self.manager.get_history() + + # Should have 3 entries (most recent first) + assert len(history) == 3 + assert history[0].content == "Third" + assert history[1].content == "Second" + assert history[2].content == "First" + + def test_history_limit(self): + """Test that history is limited to MAX_HISTORY_SIZE.""" + # Copy more than max entries + for i in range(110): + self.manager.copy(f"Entry {i}") + + history = self.manager.get_history() + assert len(history) == 100 # MAX_HISTORY_SIZE + + def test_no_duplicate_consecutive_entries(self): + """Test that duplicate consecutive entries aren't added.""" + self.manager.copy("Same text") + self.manager.copy("Same text") + self.manager.copy("Same text") + + history = self.manager.get_history() + assert len(history) == 1 + + def test_history_persistence(self): + """Test that history is saved and loaded correctly.""" + # Add some entries + self.manager.copy("Persistent", source="test") + time.sleep(0.1) # Let async save complete + + # Force save + self.manager._save_history() + + # Verify file exists and has content + assert os.path.exists(self.history_file) + + with open(self.history_file, 'r') as f: + data = json.load(f) + assert 'history' in data + assert len(data['history']) >= 1 + + def test_clear_history(self): + """Test clearing history.""" + self.manager.copy("To be cleared") + assert len(self.manager.get_history()) == 1 + + self.manager.clear_history() + assert len(self.manager.get_history()) == 0 + + def test_thread_safety(self): + """Test thread-safe operations.""" + errors = [] + + def copy_worker(text): + try: + for i in range(10): + self.manager.copy(f"{text}_{i}") + except Exception as e: + errors.append(e) + + # Run multiple threads + threads = [] + for i in range(5): + t = threading.Thread(target=copy_worker, args=(f"Thread{i}",)) + threads.append(t) + t.start() + + for t in threads: + t.join() + + assert len(errors) == 0, f"Thread errors: {errors}" + + def test_has_changed(self): + """Test clipboard change detection.""" + # Initial state + self.manager.copy("Initial") + assert self.manager.has_changed() is False # Same content + + # Manually simulate external change (mock by copying new content) + self.manager.copy("Changed") + # Note: has_changed() compares to _last_content which is updated on copy + # So we need to simulate external change differently + old_content = self.manager._last_content + self.manager._last_content = "Different" + assert self.manager.has_changed() is True + + def test_get_history_limit(self): + """Test getting limited history.""" + for i in range(10): + self.manager.copy(f"Entry {i}") + + limited = self.manager.get_history(limit=5) + assert len(limited) == 5 + + def test_entry_metadata(self): + """Test that entries have correct metadata.""" + self.manager.copy("Test content", source="my_plugin") + + history = self.manager.get_history() + entry = history[0] + + assert entry.content == "Test content" + assert entry.source == "my_plugin" + assert entry.timestamp is not None + + def test_monitoring(self): + """Test clipboard monitoring.""" + changes = [] + + def on_change(content): + changes.append(content) + + self.manager.start_monitoring(callback=on_change) + assert self.manager.is_monitoring() is True + + time.sleep(0.1) + + self.manager.stop_monitoring() + assert self.manager.is_monitoring() is False + + def test_stats(self): + """Test getting stats.""" + self.manager.copy("Stat test") + stats = self.manager.get_stats() + + assert 'history_count' in stats + assert 'max_history' in stats + assert 'is_available' in stats + assert stats['max_history'] == 100 + + +class TestClipboardEntry: + """Tests for ClipboardEntry dataclass.""" + + def test_to_dict(self): + """Test conversion to dictionary.""" + entry = ClipboardEntry( + content="Test", + timestamp="2024-01-01T00:00:00", + source="test_plugin" + ) + + d = entry.to_dict() + assert d['content'] == "Test" + assert d['timestamp'] == "2024-01-01T00:00:00" + assert d['source'] == "test_plugin" + + def test_from_dict(self): + """Test creation from dictionary.""" + data = { + 'content': 'Test', + 'timestamp': '2024-01-01T00:00:00', + 'source': 'test_plugin' + } + + entry = ClipboardEntry.from_dict(data) + assert entry.content == "Test" + assert entry.timestamp == "2024-01-01T00:00:00" + assert entry.source == "test_plugin" + + +def run_manual_test(): + """Run a manual demonstration of clipboard features.""" + print("\n" + "="*60) + print("MANUAL CLIPBOARD MANAGER TEST") + print("="*60) + + # Reset singleton + ClipboardManager._instance = None + manager = get_clipboard_manager() + + print(f"\n1. Clipboard available: {manager.is_available()}") + + # Copy test + print("\n2. Copying text to clipboard...") + manager.copy("Hello from EU-Utility!") + print(" Copied: 'Hello from EU-Utility!'") + + # Paste test + print("\n3. Reading from clipboard...") + pasted = manager.paste() + print(f" Pasted: '{pasted}'") + + # History test + print("\n4. Adding more entries...") + manager.copy("Coordinates: 100, 200", source="test") + manager.copy("GPS: 45.5231, -122.6765", source="test") + manager.copy("Position: x=50, y=100", source="test") + + print("\n5. History (last 5):") + for i, entry in enumerate(manager.get_history(limit=5), 1): + print(f" {i}. [{entry.source}] {entry.content[:40]}") + + print("\n6. Stats:") + stats = manager.get_stats() + for key, value in stats.items(): + print(f" {key}: {value}") + + print("\n" + "="*60) + print("TEST COMPLETE") + print("="*60) + + +if __name__ == "__main__": + run_manual_test() diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..b15d110 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,116 @@ +""" +Test for EU-Utility main application + +Run with: python tests/test_main.py +""" + +import sys +import time +import threading +from pathlib import Path + +# Add parent to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from main import EUUtility +from core.clipboard import ClipboardManager + + +def test_initialization(): + """Test that EU-Utility initializes correctly.""" + print("\n" + "="*60) + print("TESTING EU-Utility INITIALIZATION") + print("="*60) + + # Reset singleton + ClipboardManager._instance = None + + # Create app + print("\n1. Creating EU-Utility instance...") + app = EUUtility() + print(" ✓ Instance created") + + # Initialize (without monitoring to avoid thread issues) + print("\n2. Initializing (without monitoring)...") + app.initialize(auto_start_clipboard_monitor=False) + print(" ✓ Initialized") + + # Check clipboard manager + print("\n3. Checking clipboard manager...") + clipboard = app.get_clipboard_manager() + if clipboard: + print(f" ✓ Clipboard manager ready") + print(f" - Available: {clipboard.is_available()}") + print(f" - History: {len(clipboard.get_history())} entries") + else: + print(" ✗ Clipboard manager not found") + + # Check plugins + print("\n4. Checking plugins...") + plugins = app.plugin_api.get_all_plugins() + print(f" - Loaded plugins: {len(plugins)}") + for plugin in plugins: + print(f" - {plugin.name} v{plugin.version}") + + # Stop + print("\n5. Stopping...") + app.stop() + print(" ✓ Stopped cleanly") + + print("\n" + "="*60) + print("INITIALIZATION TEST PASSED") + print("="*60) + + +def test_clipboard_in_main(): + """Test clipboard functionality through main app.""" + print("\n" + "="*60) + print("TESTING CLIPBOARD THROUGH MAIN APP") + print("="*60) + + # Reset singleton + ClipboardManager._instance = None + + app = EUUtility() + app.initialize(auto_start_clipboard_monitor=False) + + clipboard = app.get_clipboard_manager() + + if clipboard and clipboard.is_available(): + print("\n1. Testing copy/paste...") + clipboard.copy("Test from main", source="main_test") + pasted = clipboard.paste() + assert pasted == "Test from main", f"Expected 'Test from main', got '{pasted}'" + print(" ✓ Copy/paste works") + + print("\n2. Testing history...") + history = clipboard.get_history() + assert len(history) > 0, "History should have entries" + assert history[0].source == "main_test", "Source should be tracked" + print(f" ✓ History tracking works ({len(history)} entries)") + + print("\n3. Testing stats...") + stats = clipboard.get_stats() + assert 'history_count' in stats + print(f" ✓ Stats available: {stats}") + else: + print(" ⚠ Clipboard not available (pyperclip may not be installed)") + + app.stop() + + print("\n" + "="*60) + print("CLIPBOARD TEST COMPLETE") + print("="*60) + + +if __name__ == "__main__": + try: + test_initialization() + test_clipboard_in_main() + print("\n✅ ALL TESTS PASSED") + except AssertionError as e: + print(f"\n❌ TEST FAILED: {e}") + except Exception as e: + print(f"\n❌ ERROR: {e}") + import traceback + traceback.print_exc() diff --git a/tests/test_plugin_api.py b/tests/test_plugin_api.py new file mode 100644 index 0000000..e7836d9 --- /dev/null +++ b/tests/test_plugin_api.py @@ -0,0 +1,240 @@ +""" +Test suite for PluginAPI and BasePlugin clipboard integration + +Run with: python tests/test_plugin_api.py +""" + +import sys +import tempfile +import os +from pathlib import Path + +# Add parent to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from core.plugin_api import PluginAPI +from core.base_plugin import BasePlugin +from core.clipboard import ClipboardManager, get_clipboard_manager + + +class MockPlugin(BasePlugin): + """Mock plugin for testing.""" + name = "mock_plugin" + description = "Mock plugin for testing" + version = "1.0.0" + + def __init__(self): + super().__init__() + self.started = False + self.stopped = False + + def on_start(self): + self.started = True + + def on_stop(self): + self.stopped = True + + +class TestPluginAPI: + """Tests for PluginAPI functionality.""" + + def setup_method(self): + """Setup for each test.""" + ClipboardManager._instance = None + self.api = PluginAPI() + + def teardown_method(self): + """Cleanup after each test.""" + ClipboardManager._instance = None + + def test_register_clipboard_service(self): + """Test registering clipboard service.""" + manager = self.api.register_clipboard_service() + + assert manager is not None + assert self.api.get_clipboard_manager() is manager + + def test_load_plugin_injects_clipboard(self): + """Test that loading a plugin injects clipboard manager.""" + # Register clipboard first + self.api.register_clipboard_service() + + # Load plugin + plugin = self.api.load_plugin(MockPlugin) + + # Plugin should have clipboard manager + assert plugin._clipboard_manager is not None + assert plugin._clipboard_manager is self.api.get_clipboard_manager() + + def test_copy_to_clipboard_via_plugin(self): + """Test copying via plugin method.""" + self.api.register_clipboard_service() + plugin = self.api.load_plugin(MockPlugin) + + # Copy via plugin + result = plugin.copy_to_clipboard("Test via plugin") + assert result is True + + # Verify via manager + assert self.api.get_clipboard_manager().paste() == "Test via plugin" + + def test_paste_via_plugin(self): + """Test pasting via plugin method.""" + self.api.register_clipboard_service() + plugin = self.api.load_plugin(MockPlugin) + + # Copy via manager + self.api.get_clipboard_manager().copy("Original text") + + # Paste via plugin + result = plugin.paste_from_clipboard() + assert result == "Original text" + + def test_get_history_via_plugin(self): + """Test getting history via plugin method.""" + self.api.register_clipboard_service() + plugin = self.api.load_plugin(MockPlugin) + + # Add entries + plugin.copy_to_clipboard("Entry 1") + plugin.copy_to_clipboard("Entry 2") + + # Get history via plugin + history = plugin.get_clipboard_history() + + assert len(history) == 2 + assert history[0]['content'] == "Entry 2" + assert history[1]['content'] == "Entry 1" + assert history[0]['source'] == "mock_plugin" + + def test_start_stop_all(self): + """Test starting and stopping all plugins.""" + self.api.register_clipboard_service() + plugin = self.api.load_plugin(MockPlugin) + + # Start all + self.api.start_all() + assert plugin.started is True + assert plugin.is_initialized() is True + + # Stop all + self.api.stop_all() + assert plugin.stopped is True + assert plugin.is_initialized() is False + + def test_get_plugin(self): + """Test getting a plugin by name.""" + self.api.register_clipboard_service() + self.api.load_plugin(MockPlugin) + + found = self.api.get_plugin("mock_plugin") + assert found is not None + assert found.name == "mock_plugin" + + not_found = self.api.get_plugin("nonexistent") + assert not_found is None + + def test_get_plugin_info(self): + """Test getting plugin information.""" + self.api.register_clipboard_service() + self.api.load_plugin(MockPlugin) + + info = self.api.get_plugin_info() + + assert len(info) == 1 + assert info[0]['name'] == "mock_plugin" + assert info[0]['version'] == "1.0.0" + + +def run_integration_test(): + """Run an integration test demonstrating all features.""" + print("\n" + "="*60) + print("PLUGIN API CLIPBOARD INTEGRATION TEST") + print("="*60) + + # Reset singleton + ClipboardManager._instance = None + + # Create API + api = PluginAPI() + + # Register clipboard service + print("\n1. Registering clipboard service...") + clipboard = api.register_clipboard_service() + print(f" ✓ Clipboard service registered") + print(f" - Available: {clipboard.is_available()}") + + # Create a test plugin + print("\n2. Creating and loading test plugin...") + + class TestPlugin(BasePlugin): + name = "integration_test_plugin" + description = "Integration test" + version = "1.0" + + def on_start(self): + print(f" [{self.name}] Started") + + def on_stop(self): + print(f" [{self.name}] Stopped") + + def copy_coordinates(self, x, y): + """Copy coordinates to clipboard.""" + coords = f"{x}, {y}" + success = self.copy_to_clipboard(coords) + print(f" [{self.name}] Copied coordinates: {coords}") + return success + + def read_user_paste(self): + """Read pasted value from user.""" + value = self.paste_from_clipboard() + print(f" [{self.name}] Read clipboard: '{value}'") + return value + + def show_history(self): + """Show clipboard history.""" + history = self.get_clipboard_history(limit=5) + print(f" [{self.name}] Recent clipboard entries:") + for i, entry in enumerate(history, 1): + content = entry['content'][:30] + if len(entry['content']) > 30: + content += "..." + print(f" {i}. [{entry.get('source', '?')}] {content}") + + plugin = api.load_plugin(TestPlugin) + print(f" ✓ Plugin loaded: {plugin.name}") + + # Start plugins + print("\n3. Starting plugins...") + api.start_all() + + # Test: Copy coordinates + print("\n4. Testing: Copy coordinates to clipboard...") + plugin.copy_coordinates(100, 200) + plugin.copy_coordinates(45.5231, -122.6765) + + # Test: Read pasted values + print("\n5. Testing: Read pasted values...") + plugin.read_user_paste() + + # Test: Access clipboard history + print("\n6. Testing: Access clipboard history...") + plugin.show_history() + + # Check stats + print("\n7. Clipboard stats:") + stats = clipboard.get_stats() + for key, value in stats.items(): + print(f" {key}: {value}") + + # Stop plugins + print("\n8. Stopping plugins...") + api.stop_all() + + print("\n" + "="*60) + print("INTEGRATION TEST COMPLETE") + print("="*60) + + +if __name__ == "__main__": + run_integration_test()