From d015689a6fe3485c6e94738fd26e62d0b67ae231 Mon Sep 17 00:00:00 2001
From: anima
Date: Tue, 21 Apr 2026 18:01:04 +0200
Subject: [PATCH] inital working version
---
source/__init__.py | 3 +
source/command.py | 66 +++++++++++++++
source/window.py | 197 +++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 266 insertions(+)
create mode 100644 source/__init__.py
create mode 100644 source/command.py
create mode 100644 source/window.py
diff --git a/source/__init__.py b/source/__init__.py
new file mode 100644
index 0000000..ad1f6a5
--- /dev/null
+++ b/source/__init__.py
@@ -0,0 +1,3 @@
+#!/usr/bin/env python3
+from .window import Window
+from .command import Command
\ No newline at end of file
diff --git a/source/command.py b/source/command.py
new file mode 100644
index 0000000..08efa3c
--- /dev/null
+++ b/source/command.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+from pathlib import Path
+import logging
+
+logging.basicConfig(format='[%(asctime)s] %(process)s %(levelname)s {%(filename)s:%(lineno)d} - %(message)s', level=logging.DEBUG)
+
+try:
+ import cv2
+except ImportError as e:
+ logging.error(f"Missing dependency: {e}")
+ logging.error("Install with: pip install opencv-python")
+ sys.exit(1)
+
+
+
+class Command:
+ """Dataclass for commands"""
+ __AUTHOR__ = 'anima'
+ __VERSION__ = '1.0.0'
+
+ image_dir: Path = Path('images/')
+ supported_extensions = {".png", ".jpg", ".jpeg", ".bmp"}
+
+ def __init__(self, image_name: str, commands: list, counter: int = None, threshold: float = 0.95, position: tuple = None):
+ self.valid = False
+ self.name = image_name
+ self.commands = commands
+ self.threshold = threshold
+ self.position = position
+ self.pos_dif = 10
+ self.counter = counter
+ self.count = 0
+ self.load_image()
+
+ def load_image(self) -> bool:
+ """search, load and save image convertet as matchable object
+
+ Returns:
+ bool: Return True if image load successfull
+ """
+ images = list(self.image_dir.rglob('*' + self.name + '*'))
+ if len(images) > 0:
+ for image in images:
+ logging.debug(f'found image: {image.name} (pattern: {self.name})')
+ if len(images) > 1:
+ logging.warning(f'more than one images found for pattern {self.name}, can not load command')
+ return False
+ elif len(images) == 1:
+ logging.info(f'load image for command {self.name}')
+ else:
+ logging.warning('no images found for pattern {self.name}')
+ return False
+
+ if image.suffix.lower() not in self.supported_extensions:
+ logging.warning(f'image type of {image} is not supported')
+ return False
+
+ self.image = cv2.imread(str(image), cv2.IMREAD_COLOR)
+ if image is None:
+ logging.warning(f'could not load {image}')
+ else:
+ self.valid = True
+
+ return self.valid
+
+ def check_position(self, )
\ No newline at end of file
diff --git a/source/window.py b/source/window.py
new file mode 100644
index 0000000..b38ef12
--- /dev/null
+++ b/source/window.py
@@ -0,0 +1,197 @@
+#!/usr/bin/env python3
+import subprocess
+import sys
+import time
+import logging
+
+logging.basicConfig(format='[%(asctime)s] %(process)s %(levelname)s {%(filename)s:%(lineno)d} - %(message)s', level=logging.DEBUG)
+
+try:
+ import cv2
+ import mss
+ import numpy
+ from PIL import Image
+except ImportError as e:
+ logging.error(f"Missing dependency: {e}")
+ logging.error("Install with: pip install mss Pillow opencv-python numpy")
+ sys.exit(1)
+
+from .command import Command
+
+
+class Window:
+ __AUTHOR__ = 'anima'
+ __VERSION__ = '1.0.0'
+
+ key_press_duration = 0.1
+ supported_keys = ['return', 'Up', 'Down', 'Right', 'Left']
+
+ @staticmethod
+ def check_requrements() -> None:
+ ## xdotool
+ try:
+ subprocess.run(["xdotool"], capture_output=True)
+ except FileNotFoundError:
+ logging.error("xdotool not found. Install it first.")
+ sys.exit(1)
+
+ def __init__(self, window_name: str, monitor_id: int = 1, poll_intervall: float = 1):
+ self.check_requrements()
+ self.window_name = window_name
+ self.monitor_id = monitor_id
+ self.window_id = None
+ self.poll_intervall = poll_intervall
+ self.commands = list()
+ self.dry_run = False
+
+
+ def run(self):
+ if not len(self.commands) > 0:
+ logging.error(f'no commands to run found')
+ sys.exit(1)
+ with mss.mss() as self.sct:
+ while True:
+ command = self.match_screen()
+ # print(self.match_screen())
+ if command:
+ self.activate_window()
+ for key in command.commands:
+ if isinstance(key, str):
+ self.send_key(key)
+ elif isinstance(key, int) or isinstance(key, float):
+ time.sleep(key)
+ time.sleep(self.poll_intervall)
+
+ ## Screen Managament
+ def grab_screen(self) -> numpy.ndarray:
+ """Capture screen and return as normalized BGR numpy array.
+
+ Returns:
+ numpy.ndarray: screenshot converted as matchable
+ """
+ screenshot = self.sct.grab(self.sct.monitors[self.monitor_id])
+ screen = numpy.array(screenshot)
+ screen = cv2.cvtColor(screen, cv2.COLOR_BGRA2BGR)
+ return screen
+
+ def match_screen(self) -> Command | None:
+ """match screenshot with all known commands
+
+ Returns:
+ Command | None: command with hightest screen/image match
+ """
+ best_match = None
+ best_pos = None
+ best_value = 0.0
+ screen = self.grab_screen()
+
+ screen_gray = cv2.cvtColor(screen, cv2.COLOR_BGR2GRAY)
+ for command in self.commands:
+ img_h, img_w = command.image.shape[:2]
+ screen_h, screen_w = screen.shape[:2]
+
+ if img_h > screen_h or img_w > screen_w:
+ continue
+
+ img_gray = cv2.cvtColor(command.image, cv2.COLOR_BGR2GRAY)
+ result = cv2.matchTemplate(screen_gray, img_gray, cv2.TM_CCOEFF_NORMED)
+ _, match_value, _, match_pos = cv2.minMaxLoc(result)
+
+ if match_value >= command.threshold and match_value > best_value:
+ logging.debug(f'new best match: {command.name} ({match_value=}%, {match_pos=})')
+ best_match = command
+ best_value = match_value
+ best_pos = match_pos
+
+ if best_match:
+ return best_match
+ return None
+
+ ## Window Management
+ def _get_window_id(self) -> bool:
+ """Find window ID by name using xdotool.
+
+ Returns:
+ bool: True if window found and id saved
+ """
+ result = subprocess.run(
+ ["xdotool", "search", "--name", self.window_name],
+ capture_output=True, text=True
+ )
+ ids = result.stdout.strip().splitlines()
+ logging.debug(f'found ids with string {self.window_name=}: {ids=}')
+ if ids:
+ # last match is usually the main window
+ self.window_id = ids[-1]
+ logging.debug(f'used id: {self.window_id=}')
+ return True
+ logging.debug(f'no matching ids found for {self.window_name=}')
+ return False
+
+ def activate_window(self) -> bool:
+ """activate given window
+
+ Returns:
+ bool: True if window found and activate
+ """
+ # TODO: replace search with id ?
+ if self.dry_run:
+ logging.info(f'dry-run: activate window {self.window_name=}')
+ return True
+
+ # TODO: window_id insteat?
+ result = subprocess.run(
+ ['xdotool', 'search', '--name', self.window_name, 'windowactivate'],
+ capture_output=True, text=True
+ )
+
+ if result.returncode: return True
+ return False
+
+ ## Key Management
+ def send_key(self, key_name: str = None) -> bool:
+ """Send a key via xdotool, optionally targeting a specific window.
+
+ Args:
+ key_name (str, optional): xdotool name of key to press. Defaults to None.
+
+ Returns:
+ bool: True if key send successfully
+ """
+ if not isinstance(key_name, str):
+ logging.error(f'given key is not a string: {key_name=} ({type(key_name)})')
+ return False
+ #=> disabled because no need atm
+ # if not key_name in self.supported_keys:
+ # logging.error(f'given key is not a xdotool {key_name=}, supoprtet keys a: {self.supportet_keys}')
+ # return False
+ if self.dry_run:
+ logging.info(f'dry-run: send {key_name=}')
+ return True
+
+ if self.window_id:
+ logging.debug(f'press {key_name=} for window {self.window_id=} ({self.window_name=})')
+ key_down = subprocess.run(["xdotool", "keydown", "--window", self.window_id, key_name])
+ time.sleep(self.key_press_duration)
+ key_up = subprocess.run(["xdotool", "keyup", "--window", self.window_id, key_name])
+ if key_down.returncode and key_up.returncode:
+ return True
+ else: # TODO: only if window_id known ?
+ logging.debug(f'press {key_name=}')
+ key_down = subprocess.run(["xdotool", "keydown", key_name])
+ time.sleep(self.key_press_duration)
+ key_up = subprocess.run(["xdotool", "keyup", key_name])
+ if key_down.returncode and key_up.returncode:
+ return True
+ return False
+
+def main():
+ check_requrements()
+ if 1 <= args.verbose <= 3:
+ logging.getLogger().setLevel(40 - (args.verbose * 10))
+ game = Saki('Final Fantasy X')
+
+
+
+if __name__ == "__main__":
+ main()