From b98690b7d81e73ddb802dd9f1b57cde38042ca19 Mon Sep 17 00:00:00 2001 From: scootz Date: Fri, 1 May 2026 14:45:16 +0100 Subject: [PATCH] Release 0.7.6 queue watcher --- README.md | 18 ++++++- pyproject.toml | 2 +- reforger_queue/cli.py | 80 ++++++++++++++++----------- reforger_queue/portal.py | 10 +++- reforger_queue/reader.py | 57 ++++++++++++++++++++ reforger_queue/watcher.py | 110 ++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 242 insertions(+), 37 deletions(-) create mode 100644 reforger_queue/reader.py create mode 100644 reforger_queue/watcher.py diff --git a/README.md b/README.md index ea5d8e0..65eddc5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Anti Prestige Tool v0.7.5 +# Anti Prestige Tool v0.7.6 Queue-position reader for Arma Reforger on Linux Wayland. @@ -62,6 +62,22 @@ Save the captured input image while debugging: uv run reforger_queue_read.py --portal-window --save-input /tmp/apt-portal-window.png --show-crop --debug ``` +Watch the selected window and send a desktop notification once for each newly detected queue position under 5: + +```bash +uv run reforger_queue_read.py --watch --portal-window +``` + +Watch mode polls every 15 seconds by default. It prints each poll result with a timestamp, the detected number, and whether an alert fired. Queue position `1` is sent as a critical notification; positions `2`, `3`, and `4` use normal urgency. + +Useful watcher overrides: + +```bash +uv run reforger_queue_read.py --watch --portal-window --watch-interval 5 +uv run reforger_queue_read.py --watch --portal-window --alert-threshold 10 +uv run reforger_queue_read.py --watch --image datasets/regression-test-set/3.png --watch-interval 1 --notify-command true +``` + ## Validation Run the regression set: diff --git a/pyproject.toml b/pyproject.toml index 5ece28f..1587de1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "anti-prestige-tool" -version = "0.7.5" +version = "0.7.6" description = "Arma Reforger queue-position reader for Linux Wayland" readme = "README.md" requires-python = ">=3.10" diff --git a/reforger_queue/cli.py b/reforger_queue/cli.py index b036319..a17eeaa 100644 --- a/reforger_queue/cli.py +++ b/reforger_queue/cli.py @@ -1,6 +1,6 @@ import argparse import os -import tempfile +import sys from .config import ( DEFAULT_POINTSIZE, @@ -9,14 +9,15 @@ from .config import ( DEFAULT_REFERENCE_SIZE, DEFAULT_TEMPLATE_SET, ) -from .magick import run_bytes from .ocr import ( read_queue_number, resolve_crop, templates_from_dataset, write_template_set, ) -from .portal import capture_portal_window +from .portal import PortalCaptureError +from .reader import read_image_once, read_portal_window_once +from .watcher import run_watch def parse_crop(value): @@ -104,52 +105,39 @@ def build_template_set(args): def read_single_image(args): - crop = resolve_crop(args.image, args.crop, args.reference_crop, args.reference_size, args.scale_mode, args.cropped) - if args.show_crop and crop: - print(f"crop={crop[0]},{crop[1]},{crop[2]},{crop[3]}") - number, details = read_queue_number(args.image, crop, args.template_set, args.font, args.pointsize, args.cropped) - if not number: + result = read_image_once(args, args.image) + if args.show_crop and result.crop: + print(f"crop={result.crop[0]},{result.crop[1]},{result.crop[2]},{result.crop[3]}") + if not result.number: print("no digits found") return 2 - print(number) + print(result.number) if args.debug: - for digit, bbox, area, hamming, iou in details: + for digit, bbox, area, hamming, iou in result.details: print(f"digit={digit} bbox={bbox} area={area} hamming={hamming:.3f} iou={iou:.3f}") return 0 def read_portal_window(args): - fd, image_path = tempfile.mkstemp(prefix="reforger-queue-", suffix=".png") - os.close(fd) try: - portal_info = capture_portal_window( - image_path, - args.portal_restore_token, - args.portal_reselect, - args.portal_timeout, - ) - if args.save_input: - run_bytes(["magick", image_path, "+repage", args.save_input]) - crop = resolve_crop(image_path, args.crop, args.reference_crop, args.reference_size, args.scale_mode, args.cropped) - if args.show_crop and crop: - print(f"crop={crop[0]},{crop[1]},{crop[2]},{crop[3]}") - number, details = read_queue_number(image_path, crop, args.template_set, args.font, args.pointsize, args.cropped) - finally: - try: - os.unlink(image_path) - except OSError: - pass + result = read_portal_window_once(args) + except PortalCaptureError as exc: + print(str(exc), file=sys.stderr) + return 1 - if not number: + if args.show_crop and result.crop: + print(f"crop={result.crop[0]},{result.crop[1]},{result.crop[2]},{result.crop[3]}") + if not result.number: print("no digits found") return 2 - print(number) + print(result.number) if args.debug: + portal_info = result.portal_info or {} print(f"portal-window node_id={portal_info.get('node_id', '')} streams={len(portal_info.get('streams', []))}") if portal_info.get("restore_token") and args.portal_restore_token: print(f"portal-restore-token {args.portal_restore_token}") - for digit, bbox, area, hamming, iou in details: + for digit, bbox, area, hamming, iou in result.details: print(f"digit={digit} bbox={bbox} area={area} hamming={hamming:.3f} iou={iou:.3f}") return 0 @@ -159,6 +147,24 @@ def main(): parser.add_argument("--image", help="Read from an existing image.") parser.add_argument("--dataset", help="Read every image in a dataset directory.") parser.add_argument("--portal-window", action="store_true", help="Capture a user-selected Wayland window.") + parser.add_argument("--watch", action="store_true", help="Keep reading and notify when the queue position is low.") + parser.add_argument( + "--watch-interval", + type=float, + default=15.0, + help="Seconds between watch-mode polls.", + ) + parser.add_argument( + "--alert-threshold", + type=int, + default=5, + help="In watch mode, notify for detected queue positions strictly below this number.", + ) + parser.add_argument( + "--notify-command", + default="notify-send", + help="Notification command used by watch mode.", + ) parser.add_argument( "--portal-restore-token", default=DEFAULT_PORTAL_RESTORE_TOKEN, @@ -220,15 +226,25 @@ def main(): parser.error("--save-input can only be used with --portal-window") if args.portal_timeout <= 0: parser.error("--portal-timeout must be positive") + if args.watch_interval <= 0: + parser.error("--watch-interval must be positive") + if args.alert_threshold <= 1: + parser.error("--alert-threshold must be greater than 1") + if args.watch and not args.notify_command.strip(): + parser.error("--notify-command must not be empty") selected_modes = [args.image, args.dataset, args.build_template_set, args.portal_window] if sum(bool(value) for value in selected_modes) != 1: parser.error("choose exactly one of --image, --dataset, --portal-window, or --build-template-set") + if args.watch and not (args.image or args.portal_window): + parser.error("--watch can only be used with --image or --portal-window") if args.build_template_set: return build_template_set(args) if args.dataset: return run_dataset(args) + if args.watch: + return run_watch(args) if args.image: return read_single_image(args) return read_portal_window(args) diff --git a/reforger_queue/portal.py b/reforger_queue/portal.py index c4c3f06..ed78ac5 100644 --- a/reforger_queue/portal.py +++ b/reforger_queue/portal.py @@ -6,6 +6,12 @@ import sys from .config import PROJECT_DIR +class PortalCaptureError(RuntimeError): + def __init__(self, message, used_restore_token=False): + super().__init__(message) + self.used_restore_token = used_restore_token + + def portal_helper_path(): return os.path.join(PROJECT_DIR, "portal_capture_frame") @@ -59,14 +65,14 @@ def capture_portal_window(output, restore_token_path, reselect, timeout): result = subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) except subprocess.CalledProcessError as exc: message = exc.stderr.strip() if exc.stderr else str(exc) - sys.exit(message) + raise PortalCaptureError(message, used_restore_token=bool(restore_token)) from exc capture_info = {} if result.stdout.strip(): try: capture_info = json.loads(result.stdout) except json.JSONDecodeError as exc: - sys.exit(f"failed to parse portal capture metadata: {exc}: {result.stdout!r}") + raise PortalCaptureError(f"failed to parse portal capture metadata: {exc}: {result.stdout!r}") from exc new_restore_token = str(capture_info.get("restore_token", "") or "") if restore_token_path and new_restore_token: diff --git a/reforger_queue/reader.py b/reforger_queue/reader.py new file mode 100644 index 0000000..f3bac79 --- /dev/null +++ b/reforger_queue/reader.py @@ -0,0 +1,57 @@ +import os +import tempfile +from dataclasses import dataclass + +from .magick import run_bytes +from .ocr import read_queue_number, resolve_crop +from .portal import capture_portal_window + + +@dataclass +class QueueReadResult: + number: str + details: list + crop: tuple | None = None + portal_info: dict | None = None + + +def read_image_once(args, image_path): + crop = resolve_crop( + image_path, + args.crop, + args.reference_crop, + args.reference_size, + args.scale_mode, + args.cropped, + ) + number, details = read_queue_number( + image_path, + crop, + args.template_set, + args.font, + args.pointsize, + args.cropped, + ) + return QueueReadResult(number=number, details=details, crop=crop) + + +def read_portal_window_once(args): + fd, image_path = tempfile.mkstemp(prefix="reforger-queue-", suffix=".png") + os.close(fd) + try: + portal_info = capture_portal_window( + image_path, + args.portal_restore_token, + args.portal_reselect, + args.portal_timeout, + ) + if args.save_input: + run_bytes(["magick", image_path, "+repage", args.save_input]) + result = read_image_once(args, image_path) + result.portal_info = portal_info + return result + finally: + try: + os.unlink(image_path) + except OSError: + pass diff --git a/reforger_queue/watcher.py b/reforger_queue/watcher.py new file mode 100644 index 0000000..2e8749d --- /dev/null +++ b/reforger_queue/watcher.py @@ -0,0 +1,110 @@ +import shlex +import subprocess +import sys +import time +from datetime import datetime + +from .portal import PortalCaptureError +from .reader import read_image_once, read_portal_window_once + + +APP_NAME = "Anti Prestige Tool" +NOTIFY_TITLE = "Arma Reforger Queue" + + +def notify_queue_position(notify_command, position): + urgency = "critical" if position == 1 else "normal" + command = [ + *shlex.split(notify_command), + "-a", + APP_NAME, + "-u", + urgency, + NOTIFY_TITLE, + f"Queue position is {position}", + ] + try: + result = subprocess.run(command, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + except FileNotFoundError: + return False, f"missing notification command: {command[0]}" + + if result.returncode != 0: + message = result.stderr.strip() or result.stdout.strip() or f"exit status {result.returncode}" + return False, f"notification command failed: {message}" + return True, "" + + +def queue_position(number): + try: + position = int(number) + except ValueError: + return None + if position < 1: + return None + return position + + +def should_alert(number, threshold, notified_positions): + position = queue_position(number) + if position is None or position >= threshold or position in notified_positions: + return None + return position + + +def timestamp(): + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def print_poll_status(number, alert_state, message=""): + if number: + line = f"{timestamp()} number={number} alert={alert_state}" + else: + line = f"{timestamp()} no digits found alert={alert_state}" + if message: + line = f"{line} {message}" + print(line, flush=True) + + +def read_once(args): + if args.image: + return read_image_once(args, args.image) + return read_portal_window_once(args) + + +def run_watch(args): + notified_positions = set() + + try: + while True: + try: + result = read_once(args) + except PortalCaptureError as exc: + print(str(exc), file=sys.stderr) + if exc.used_restore_token: + print( + "Portal restore token appears stale; rerun with --portal-reselect.", + file=sys.stderr, + ) + return 1 + + if args.portal_reselect: + args.portal_reselect = False + + if not result.number: + print_poll_status("", "no") + else: + position = should_alert(result.number, args.alert_threshold, notified_positions) + if position is None: + print_poll_status(result.number, "no") + else: + fired, error = notify_queue_position(args.notify_command, position) + if fired: + notified_positions.add(position) + print_poll_status(result.number, "yes") + else: + print_poll_status(result.number, "failed", error) + + time.sleep(args.watch_interval) + except KeyboardInterrupt: + print(f"{timestamp()} watch stopped", flush=True) + return 130 diff --git a/uv.lock b/uv.lock index 7acb917..d4e6017 100644 --- a/uv.lock +++ b/uv.lock @@ -4,5 +4,5 @@ requires-python = ">=3.10" [[package]] name = "anti-prestige-tool" -version = "0.7.5" +version = "0.7.6" source = { virtual = "." }