From 518b68452b592201e0b7afcc464adc38db440210 Mon Sep 17 00:00:00 2001 From: Timofey Ivanenko Date: Thu, 22 Oct 2026 03:25:47 +0800 Subject: [PATCH 1/5] Move device input to explicit WebSocket control sessions --- .github/workflows/publish.yml | 11 +- README.md | 25 +-- examples/notes.py | 36 ++--- examples/upload_media.py | 16 +- pyproject.toml | 6 +- src/nomix_clicker/__init__.py | 9 +- src/nomix_clicker/actions.py | 12 +- src/nomix_clicker/api_helper.py | 125 --------------- src/nomix_clicker/clicker.py | 142 +++++++---------- src/nomix_clicker/control.py | 187 +++++++++++++++++++++++ src/nomix_clicker/recognition.py | 2 +- tests/conftest.py | 6 +- tests/test_api_helper.py | 42 ----- tests/test_clicker.py | 111 +++++++------- tests/test_control.py | 164 ++++++++++++++++++++ tools/airplane-mode-iphone-12.py | 16 +- tools/restart_all_devices.py | 6 +- tools/switch-screen-viewing-iphone-12.py | 46 +++--- tools/wake-up-phone.py | 4 +- 19 files changed, 566 insertions(+), 400 deletions(-) create mode 100644 src/nomix_clicker/control.py delete mode 100644 tests/test_api_helper.py create mode 100644 tests/test_control.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f3e38db..84c4c22 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,6 +7,8 @@ name: Publish to PyPI # Add "[skip ci]" to a commit message to skip a release. on: + pull_request: + branches: [main] push: branches: [main] @@ -38,6 +40,7 @@ jobs: twine check dist/* release: + if: github.event_name == 'push' needs: test runs-on: ubuntu-latest permissions: @@ -68,18 +71,20 @@ jobs: # PyPI's JSON can be CDN-stale right after a release; the last tag is not LATEST_TAG=$(git tag -l 'v*' | sed 's/^v//' | sort -V | tail -n 1) NEXT=$(python3 - "$LATEST" "$LATEST_TAG" <<'EOF' - import re, sys + import re, sys, tomllib def parse(v): m = re.match(r"(\d+)\.(\d+)\.(\d+)", v or "") return tuple(int(x) for x in m.groups()) if m else None versions = [p for p in (parse(a) for a in sys.argv[1:3]) if p] + declared = parse(tomllib.load(open('pyproject.toml', 'rb'))['project']['version']) if versions: major, minor, patch = max(versions) - print(f"{major}.{minor}.{patch + 1}") + version = max(declared, (major, minor, patch + 1)) else: - print("0.1.0") # first release ever + version = declared + print('.'.join(map(str, version))) EOF ) echo "next=$NEXT" >> "$GITHUB_OUTPUT" diff --git a/README.md b/README.md index 642b01d..5061317 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ This library is its Python API. ```python from nomix_clicker import Clicker, Agent, open_app, DEVICE_ID -clicker = Clicker(DEVICE_ID) -open_app(clicker, "Notes") -clicker.type("Hello from Python") +with Clicker(DEVICE_ID) as clicker: + open_app(clicker, "Notes") + clicker.type("Hello from Python") # or hand the whole task to the AI agent Agent(DEVICE_ID).run("Open Settings and enable Dark Mode") @@ -62,14 +62,21 @@ export NOMIX_DEVICE_ID="your-device-id" ```python from nomix_clicker import Clicker, parse_screen, open_app, DEVICE_ID -clicker = Clicker(DEVICE_ID) -open_app(clicker, "Calculator") - -screen = parse_screen(clicker) # returns None on network/timeout errors -if screen: - print(screen.description) # what the AI recognizer sees on screen +with Clicker(DEVICE_ID) as clicker: + open_app(clicker, "Calculator") + screen = parse_screen(clicker) + if screen: + print(screen.description) ``` +### Control sessions + +Version 0.2 uses the unified WebSocket control API and requires the matching server release. The old HTTP command helpers are removed. Use `with Clicker(device_id)` or explicitly call `connect()` and `close()`. Opening a session acquires control without taking it from another user; `connect(takeover=True)` explicitly requests takeover. + +Commands return `{"status": "sent", "device_acknowledged": false}`. This confirms dispatch, not execution on the phone. Failures raise `ControlError` with `code` and `outcome` (`not_sent`, `partial`, or `unknown`). An interrupted session never reconnects or replays input automatically; create a new session only after checking the phone. Closing releases control and pending input. + +The examples below assume an open `clicker` context. For direct protocol access use `clicker.control.request(method, params)`. Discover the endpoint with `GET /clicker/v1/{device_id}/control`; method schemas are at `GET /clicker/v1/control-schema`. Screenshots, recognition, media and agent task management remain HTTP. + ### Double tap Use `double_tap` when an app requires two taps close enough together to like, diff --git a/examples/notes.py b/examples/notes.py index d1c68a2..8b3d1be 100644 --- a/examples/notes.py +++ b/examples/notes.py @@ -14,24 +14,24 @@ def main(): text = " ".join(sys.argv[1:]) or DEFAULT_NOTE - clicker = Clicker(DEVICE_ID) - - screen = open_app(clicker, "Notes") - if not screen: - print("Could not open the Notes app") - return - random_sleep(0.8, 1.5) - - # Start a new note. The compose control is an icon, so try a few labels. - if not screen.find_and_click(clicker, "new note", "compose", "create note"): - print("Could not find the New Note button") - return - random_sleep(0.8, 1.5) - - if not clicker.type(text).get("success"): - print("Could not type the note") - return - print(f"Note written:\n {text}") + with Clicker(DEVICE_ID) as clicker: + + screen = open_app(clicker, "Notes") + if not screen: + print("Could not open the Notes app") + return + random_sleep(0.8, 1.5) + + # Start a new note. The compose control is an icon, so try a few labels. + if not screen.find_and_click(clicker, "new note", "compose", "create note"): + print("Could not find the New Note button") + return + random_sleep(0.8, 1.5) + + if not clicker.type(text).get("status") == "sent": + print("Could not type the note") + return + print(f"Note written:\n {text}") if __name__ == "__main__": diff --git a/examples/upload_media.py b/examples/upload_media.py index cf29945..b12e3cc 100644 --- a/examples/upload_media.py +++ b/examples/upload_media.py @@ -15,16 +15,16 @@ def main(): if len(sys.argv) != 2: sys.exit("Usage: python3 examples/upload_media.py ") - clicker = Clicker(DEVICE_ID) + with Clicker(DEVICE_ID) as clicker: - item = upload_media(clicker, sys.argv[1]) - if not item: - print("Upload failed") - return - print(f"Saved {item['kind']} '{item['filename']}' ({item['size']} bytes)") + item = upload_media(clicker, sys.argv[1]) + if not item: + print("Upload failed") + return + print(f"Saved {item['kind']} '{item['filename']}' ({item['size']} bytes)") - # The new file is now the newest item in the library - open_app(clicker, "Photos") + # The new file is now the newest item in the library + open_app(clicker, "Photos") if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index ed2c63f..92ae5e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "nomix-clicker" -version = "0.1.0" +version = "0.2.0" description = "Python automation for real iPhones — direct control, AI screen recognition, and autonomous agents." readme = "README.md" requires-python = ">=3.10" license = "MIT" authors = [{ name = "Nixon - FZCO" }] keywords = ["automation", "ios", "clicker", "nomix", "ui-automation", "device-automation"] -dependencies = ["requests>=2.32"] +dependencies = ["requests>=2.32", "websocket-client==1.9.2"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -24,7 +24,7 @@ classifiers = [ ] [project.optional-dependencies] -dev = ["pytest>=8.0", "build>=1.0"] +dev = ["pytest>=8.0", "build>=1.0", "websockets==16.1"] [project.urls] Homepage = "https://nomixclicker.com" diff --git a/src/nomix_clicker/__init__.py b/src/nomix_clicker/__init__.py index fbad2a4..43a9f2d 100644 --- a/src/nomix_clicker/__init__.py +++ b/src/nomix_clicker/__init__.py @@ -4,9 +4,9 @@ from nomix_clicker import Clicker, parse_screen, open_app - clicker = Clicker("your-device-id") - open_app(clicker, "Calculator") - screen = parse_screen(clicker) + with Clicker("your-device-id") as clicker: + open_app(clicker, "Calculator") + screen = parse_screen(clicker) """ from importlib.metadata import PackageNotFoundError, version as _dist_version @@ -25,6 +25,7 @@ ) from .agent import Agent from .clicker import Clicker +from .control import ControlError, ControlSession from .environment import API_KEY, API_URL, DEVICE_ID from .recognition import Element, Screen, parse_screen @@ -36,6 +37,8 @@ __all__ = [ # core "Clicker", + "ControlSession", + "ControlError", "Agent", "Screen", "Element", diff --git a/src/nomix_clicker/actions.py b/src/nomix_clicker/actions.py index 1c3685e..fd3cb4e 100644 --- a/src/nomix_clicker/actions.py +++ b/src/nomix_clicker/actions.py @@ -143,12 +143,12 @@ def post_comment( submit_keyword = [submit_keyword] if cached_coords and "comment_input" in cached_coords and "comment_submit" in cached_coords: - if not clicker.click(cached_coords["comment_input"]).get("success"): + if not clicker.click(cached_coords["comment_input"]).get("status") == "sent": return False - if not clicker.type(text).get("success"): + if not clicker.type(text).get("status") == "sent": return False sleep(0.5) - return bool(clicker.click(cached_coords["comment_submit"]).get("success")) + return bool(clicker.click(cached_coords["comment_submit"]).get("status") == "sent") screen = parse_screen(clicker) if not screen: @@ -159,9 +159,9 @@ def post_comment( print("WARNING: Comment input not found") return False - if not clicker.click(input_coords).get("success"): + if not clicker.click(input_coords).get("status") == "sent": return False - if not clicker.type(text).get("success"): + if not clicker.type(text).get("status") == "sent": return False sleep(1) @@ -179,7 +179,7 @@ def post_comment( cached_coords["comment_submit"] = submit_coords print(f"Cached comment_input at {input_coords}, comment_submit at {submit_coords}") - return bool(clicker.click(submit_coords).get("success")) + return bool(clicker.click(submit_coords).get("status") == "sent") def _error_detail(e: requests.RequestException) -> str: diff --git a/src/nomix_clicker/api_helper.py b/src/nomix_clicker/api_helper.py index 232c670..f91e665 100644 --- a/src/nomix_clicker/api_helper.py +++ b/src/nomix_clicker/api_helper.py @@ -19,17 +19,6 @@ def _auth(request): session.auth = _auth -def _post_action(url, payload=None, timeout=30): - """POST to an action endpoint: never raises, network errors map to the fallback dict.""" - try: - response = session.post(url, json=payload, timeout=timeout) - result = _parse_action_response(response) - except requests.RequestException as e: - result = {"success": False, "message": str(e)} - print(result) - return result - - def get_devices(): """Get list of connected devices. @@ -43,74 +32,6 @@ def get_devices(): return result -def restart(device_id): - """Restart device. - - Args: - device_id: Device ID - """ - return _post_action(f"{get_api_url()}/{device_id}/restart", timeout=60) - - -def click(device_id, duration=300): - """Click at the current cursor position. - - Args: - device_id: Device ID - duration: Click duration in milliseconds - """ - return _post_action(f"{get_api_url()}/{device_id}/click", {"duration": duration}) - - -def tap(device_id, coords, duration=100): - """Move to coordinates and click in one call (with a device-settle delay). - - Args: - device_id: Device ID - coords: Tuple of (x, y) coordinates - duration: Hold duration in milliseconds - """ - x, y = coords - return _post_action(f"{get_api_url()}/{device_id}/tap", {"left": x, "top": y, "duration": duration}) - - -def double_tap(device_id, coords, duration=50, gap=100): - """Move to coordinates and double-tap with timing handled by the device. - - Args: - device_id: Device ID - coords: Tuple of (x, y) coordinates - duration: Hold duration of each tap in milliseconds - gap: Pause between taps in milliseconds - """ - x, y = coords - payload = {"left": x, "top": y, "duration": duration, "gap": gap} - return _post_action(f"{get_api_url()}/{device_id}/double-tap", payload) - - -def move(device_id, start, end, is_pressed=False, duration=300): - """Move mouse from start to end coordinates. Mouse released at end if pressed. - - Args: - device_id: Device ID - start: Tuple of (x, y) start coordinates - end: Tuple of (x, y) end coordinates - is_pressed: Whether mouse button is pressed during movement - duration: Movement duration in milliseconds (0-5000) - """ - start_x, start_y = start - end_x, end_y = end - payload = { - "start_left": start_x, - "start_top": start_y, - "end_left": end_x, - "end_top": end_y, - "is_pressed": is_pressed, - "duration": duration - } - return _post_action(f"{get_api_url()}/{device_id}/move", payload) - - def get_screen_state(device_id): """Get current screen state with all UI elements. @@ -123,19 +44,6 @@ def get_screen_state(device_id): return response.json() -def scroll(device_id, direction, distance=300, duration=500): - """Scroll at the current cursor position. - - Args: - device_id: Device ID - direction: "up" or "down" - distance: Scroll distance (1-1000) - duration: Scroll duration in milliseconds - """ - payload = {"direction": direction, "distance": distance, "duration": duration} - return _post_action(f"{get_api_url()}/{device_id}/scroll", payload) - - def get_status(device_id): """Check device connection status. @@ -151,26 +59,6 @@ def get_status(device_id): return result -def type_text(device_id, text): - """Type text on the device. - - Args: - device_id: Device ID - text: Text string to type (1-10000 characters) - """ - return _post_action(f"{get_api_url()}/{device_id}/keyboard/type", {"text": text}) - - -def key_combo(device_id, codes): - """Press keys in order and release them in reverse (e.g. Cmd+Space). - - Args: - device_id: Device ID - codes: List of key codes, e.g. ["MetaLeft", "Space"] - """ - return _post_action(f"{get_api_url()}/{device_id}/keyboard/combo", {"codes": codes}) - - def get_screenshot(device_id): """Fetch the latest JPEG frame from the device stream. @@ -285,16 +173,3 @@ def cancel_agent_task(device_id, task_id): ) response.raise_for_status() return response.json() - - -def _parse_action_response(response): - """Safely parse a response from an action endpoint. - - Action endpoints don't raise on failure and may return an empty body. - Falls back to a minimal dict so callers always get a consistent value. - """ - try: - return response.json() - except Exception: - # a 2xx with an unparseable body is still an accepted action - return {"success": response.ok, "message": f"Empty or invalid response (HTTP {response.status_code})"} diff --git a/src/nomix_clicker/clicker.py b/src/nomix_clicker/clicker.py index 0136435..ae6f958 100644 --- a/src/nomix_clicker/clicker.py +++ b/src/nomix_clicker/clicker.py @@ -1,93 +1,63 @@ -from .api_helper import double_tap, get_screenshot, key_combo, move, tap, type_text +from types import TracebackType + +from .api_helper import get_screenshot +from .control import ControlSession class Clicker: - """Convenient tap/swipe/type wrapper around the low-level API.""" + """One explicit control session; commands are never replayed after disconnect.""" - def __init__(self, device_id: str): - """Initialize mouse controller. + device_id: str + control: ControlSession - Args: - device_id: Device ID - """ + def __init__(self, device_id: str, *, timeout: float = 10): self.device_id = device_id - - def swipe( - self, coords: tuple[int, int], - up: int = 0, down: int = 0, left: int = 0, right: int = 0, - duration: int = 300, - ): - """Swipe from coords in the specified direction. - - Args: - coords: Start coordinates tuple (x, y) - up: Swipe up distance in HID units (0-32767) - down: Swipe down distance in HID units - left: Swipe left distance in HID units - right: Swipe right distance in HID units - duration: Swipe duration in milliseconds - """ - # Calculate end position and perform swipe - start_x, start_y = coords - end_x = start_x - left + right - end_y = start_y - up + down - end_coords = (end_x, end_y) - - return move( - self.device_id, - coords, - end_coords, - is_pressed=True, - duration=duration - ) - - def click(self, coords: tuple[int, int], duration: int = 100): - """Tap at coordinates (combined move+click in one call). - - Args: - coords: Tap coordinates tuple (x, y) - duration: Hold duration in milliseconds - """ - return tap(self.device_id, coords, duration=duration) - - def double_tap( - self, - coords: tuple[int, int], - duration: int = 50, - gap: int = 100, - ): - """Double-tap at coordinates with timing handled by the device. - - Args: - coords: Tap coordinates tuple (x, y) - duration: Hold duration of each tap in milliseconds - gap: Pause between taps in milliseconds - """ - return double_tap(self.device_id, coords, duration=duration, gap=gap) - - def type(self, text: str): - """Type text on the device. - - Args: - text: Text string to type - """ - result = type_text(self.device_id, text) - return result - - def key_combo(self, codes: list[str]): - """Press a key combination (e.g. ["MetaLeft", "Space"] to open Spotlight). - - Args: - codes: List of key codes, pressed in order and released in reverse - """ - return key_combo(self.device_id, codes) - - def get_screenshot(self): - """Fetch the latest JPEG frame from the device stream. - - Returns: - Raw JPEG bytes, or None if the stream has no frame available. - Raises: - requests.HTTPError: on 401/403 (auth problem, not a missing frame). - """ + self.control = ControlSession(device_id, timeout=timeout) + + def __enter__(self) -> "Clicker": + self.connect() + return self + + def __exit__(self, kind: type | None, value: BaseException | None, traceback: TracebackType | None) -> None: + self.close() + + def connect(self, *, takeover: bool = False) -> None: + self.control.connect() + try: + self.control.request("control.acquire", {"takeover": takeover}) + except BaseException: + self.close() + raise + + def close(self) -> None: + self.control.close() + + def swipe(self, coords: tuple[int, int], up: int = 0, down: int = 0, + left: int = 0, right: int = 0, duration: int = 300) -> dict: + x, y = coords + return self.control.request("input.move", { + "start_left": x, "start_top": y, "end_left": x - left + right, + "end_top": y - up + down, "pressed": True, "duration": duration, + }, timeout=self.control.timeout + duration / 1000 + 1) + + def click(self, coords: tuple[int, int], duration: int = 100) -> dict: + x, y = coords + return self.control.request("input.tap", {"left": x, "top": y, "duration": duration}, + timeout=self.control.timeout + duration / 1000) + + def double_tap(self, coords: tuple[int, int], duration: int = 50, gap: int = 100) -> dict: + x, y = coords + return self.control.request("input.doubleTap", {"left": x, "top": y, "duration": duration, "gap": gap}, + timeout=self.control.timeout + (2 * duration + gap) / 1000) + + def type(self, text: str, delay: int = 20) -> dict: + return self.control.request("input.text", {"text": text, "delay": delay}, timeout=self.control.timeout + 132) + + def key_combo(self, codes: list[str]) -> dict: + return self.control.request("input.keyCombo", {"codes": codes}) + + def restart(self) -> dict: + return self.control.request("device.restart") + + def get_screenshot(self) -> bytes | None: return get_screenshot(self.device_id) diff --git a/src/nomix_clicker/control.py b/src/nomix_clicker/control.py new file mode 100644 index 0000000..de89897 --- /dev/null +++ b/src/nomix_clicker/control.py @@ -0,0 +1,187 @@ +import json +import math +from concurrent.futures import Future, TimeoutError as FutureTimeout +from threading import Lock, Thread, current_thread +from typing import Any +from urllib.parse import quote, urlsplit + +import requests +import websocket + +from .environment import get_api_key, get_api_url + +MAX_MESSAGE_BYTES = 65536 +MAX_REQUEST_ID = 9007199254740991 +MAX_PENDING = 32 + + +class ControlError(RuntimeError): + code: int + outcome: str + + def __init__(self, code: int, message: str, outcome: str = "not_sent"): + super().__init__(message) + self.code = code + self.outcome = outcome + + +class ControlSession: + device_id: str + api_key: str + api_url: str + timeout: float + socket: websocket.WebSocket | None + reader: Thread | None + lock: Any + send_lock: Any + lifecycle: Any + pending: dict[int, Future] + last_id: int + state: dict + closed: bool + + def __init__(self, device_id: str, *, api_key: str | None = None, api_url: str | None = None, timeout: float = 10): + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError("timeout must be positive") + self.device_id = device_id + self.api_key = get_api_key() if api_key is None else api_key + self.api_url = (get_api_url() if api_url is None else api_url).rstrip("/") + self.timeout = timeout + self.socket = None + self.reader = None + self.lock = Lock() + self.send_lock = Lock() + self.lifecycle = Lock() + self.pending = {} + self.last_id = 0 + self.state = {} + self.closed = False + + def connect(self) -> None: + with self.lifecycle: + if self.closed: + raise ControlError(-32004, "Create a new session after disconnect") + if self.socket is not None: + return + with requests.Session() as session: + response = session.get( + f"{self.api_url}/{quote(self.device_id, safe='')}/control", + headers={"X-API-Key": self.api_key}, timeout=self.timeout, + ) + response.raise_for_status() + endpoint = response.json() + url = endpoint.get("websocket_url") if isinstance(endpoint, dict) else None + if not isinstance(url, str) or endpoint.get("protocol_version") != 1 or urlsplit(url).scheme not in ("ws", "wss"): + raise ControlError(-32600, "Unsupported control endpoint") + try: + socket = websocket.create_connection( + url, header={"X-API-Key": self.api_key}, timeout=self.timeout, + suppress_origin=True, redirect_limit=0, enable_multithread=True, + ) + except (OSError, websocket.WebSocketException): + self.closed = True + raise ControlError(-32004, "Control connection could not be opened") from None + with self.lock: + if self.closed: + socket.shutdown() + raise ControlError(-32004, "Control session closed while connecting") + self.socket = socket + self.reader = Thread(target=self.read, name="nomix-control", daemon=True) + self.reader.start() + + def close(self) -> None: + with self.lock: + self.closed = True + pending, self.pending = self.pending, {} + socket, self.socket = self.socket, None + reader = self.reader + for future in pending.values(): + future.set_exception(ControlError(-32007, "Control connection closed", "unknown")) + if socket is not None: + socket.abort() + socket.shutdown() + if reader is not None and reader is not current_thread(): + reader.join(self.timeout + 1) + + def request(self, method: str, params: dict | None = None, *, timeout: float | None = None) -> dict: + deadline = self.timeout if timeout is None else timeout + if not math.isfinite(deadline) or deadline <= 0: + raise ValueError("timeout must be positive") + with self.send_lock: + with self.lock: + if self.closed or self.socket is None: + raise ControlError(-32004, "Connect and acquire control before sending commands") + capacity = MAX_PENDING + 4 if method.startswith("control.") else MAX_PENDING + if len(self.pending) >= capacity: + raise ControlError(-32003, "Too many pending control requests") + if self.last_id >= MAX_REQUEST_ID: + raise ControlError(-32600, "Control session exhausted its request ids") + self.last_id += 1 + request_id = self.last_id + message = json.dumps({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}}, allow_nan=False) + if len(message.encode("utf-8")) > MAX_MESSAGE_BYTES: + raise ControlError(-32600, "Control request is too large") + future: Future = Future() + self.pending[request_id] = future + socket = self.socket + try: + socket.send(message) + except (OSError, websocket.WebSocketException): + self.close() + raise ControlError(-32007, "Control request outcome is unknown", "unknown") from None + try: + return future.result(timeout=deadline) + except FutureTimeout: + self.close() + raise ControlError(-32007, "Control request timed out; outcome is unknown", "unknown") from None + except KeyboardInterrupt: + self.close() + raise + + def read(self) -> None: + socket = self.socket + if socket is None: + return + try: + while not self.closed: + try: + message = socket.recv() + except websocket.WebSocketTimeoutException: + continue + if not isinstance(message, str) or not message or len(message.encode("utf-8")) > MAX_MESSAGE_BYTES: + break + self.receive(json.loads(message)) + except (OSError, websocket.WebSocketException, ValueError, ControlError): + pass + finally: + self.close() + + def receive(self, payload: Any) -> None: + if not isinstance(payload, dict) or payload.get("jsonrpc") != "2.0": + raise ControlError(-32600, "Invalid control response") + if "method" in payload and "id" not in payload: + if payload["method"] == "control.state" and isinstance(payload.get("params"), dict): + self.state = payload["params"] + return + request_id = payload.get("id") + if type(request_id) is not int or ("result" in payload) == ("error" in payload): + raise ControlError(-32600, "Invalid control response") + with self.lock: + future = self.pending.get(request_id) + if future is None: + raise ControlError(-32600, "Unexpected control response") + if "result" in payload: + if not isinstance(payload["result"], dict): + raise ControlError(-32600, "Invalid control result") + self.pending.pop(request_id) + future.set_result(payload["result"]) + else: + error = payload["error"] + if not isinstance(error, dict) or type(error.get("code")) is not int or not isinstance(error.get("message"), str): + raise ControlError(-32600, "Invalid control error") + data = error.get("data", {}) + outcome = data.get("outcome", "unknown") if isinstance(data, dict) else "unknown" + if outcome not in ("not_sent", "partial", "unknown"): + outcome = "unknown" + self.pending.pop(request_id) + future.set_exception(ControlError(error["code"], error["message"], outcome)) diff --git a/src/nomix_clicker/recognition.py b/src/nomix_clicker/recognition.py index 0f540c5..0649b8e 100644 --- a/src/nomix_clicker/recognition.py +++ b/src/nomix_clicker/recognition.py @@ -78,7 +78,7 @@ def find_and_click(self, clicker: Clicker, *keywords: str, interactive_only: boo coords = self.find(*keywords, interactive_only=interactive_only) if not coords: return False - return bool(clicker.click(coords).get("success")) + return bool(clicker.click(coords).get("status") == "sent") def contains(self, *keywords: str) -> bool: """Check if any keyword appears in description or any element (substring).""" diff --git a/tests/conftest.py b/tests/conftest.py index 0570f1c..729b2ad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,14 +12,14 @@ def __init__(self): def click(self, coords, duration=100): self.clicked.append(coords) - return {"success": True} + return {"status": "sent", "device_acknowledged": False} def type(self, text): self.typed.append(text) - return {"success": True} + return {"status": "sent", "device_acknowledged": False} def swipe(self, *args, **kwargs): - return {"success": True} + return {"status": "sent", "device_acknowledged": False} @pytest.fixture diff --git a/tests/test_api_helper.py b/tests/test_api_helper.py deleted file mode 100644 index 483a206..0000000 --- a/tests/test_api_helper.py +++ /dev/null @@ -1,42 +0,0 @@ -"""_parse_action_response: JSON body vs. safe fallback (no network).""" - -from nomix_clicker.api_helper import _parse_action_response - - -class _Resp: - def __init__(self, data=None, raises=False, status=500): - self._data = data - self._raises = raises - self.status_code = status - self.ok = status < 400 - - def json(self): - if self._raises: - raise ValueError("no json body") - return self._data - - -def test_returns_parsed_json(): - assert _parse_action_response(_Resp(data={"success": True})) == {"success": True} - - -def test_falls_back_on_invalid_json(): - result = _parse_action_response(_Resp(raises=True, status=502)) - assert result["success"] is False - assert "502" in result["message"] - - -def test_empty_2xx_body_counts_as_success(): - result = _parse_action_response(_Resp(raises=True, status=200)) - assert result["success"] is True - - -def test_post_action_swallows_network_errors(monkeypatch): - import requests - from unittest.mock import MagicMock - from nomix_clicker import api_helper - monkeypatch.setattr(api_helper.session, "post", - MagicMock(side_effect=requests.ConnectionError("down"))) - result = api_helper._post_action("http://x/click") - assert result["success"] is False - assert "down" in result["message"] diff --git a/tests/test_clicker.py b/tests/test_clicker.py index 2366eb5..b8418b5 100644 --- a/tests/test_clicker.py +++ b/tests/test_clicker.py @@ -1,58 +1,53 @@ -"""Clicker swipe math and tap dispatch (api_helper mocked).""" - -from nomix_clicker import Clicker -from nomix_clicker import clicker as clicker_mod - - -def _record(monkeypatch): - calls = [] - monkeypatch.setattr(clicker_mod, "move", - lambda *a, **k: calls.append(("move", a, k)) or {"success": True}) - monkeypatch.setattr(clicker_mod, "tap", - lambda *a, **k: calls.append(("tap", a, k)) or {"success": True}) - monkeypatch.setattr(clicker_mod, "double_tap", - lambda *a, **k: calls.append(("double_tap", a, k)) or {"success": True}) - return calls - - -def test_swipe_computes_end_coords(monkeypatch): - calls = _record(monkeypatch) - c = Clicker("dev") - c.swipe((10000, 10000), up=6000, duration=300) - # end_y = start_y - up = 4000; end_x unchanged - _, args, kwargs = calls[0] - device_id, start, end = args - assert device_id == "dev" - assert start == (10000, 10000) - assert end == (10000, 4000) - assert kwargs["is_pressed"] is True - - -def test_swipe_combines_directions(monkeypatch): - calls = _record(monkeypatch) - c = Clicker("dev") - c.swipe((10000, 10000), down=2000, right=3000) - _, args, _ = calls[0] - _, _, end = args - assert end == (13000, 12000) # x + right, y + down - - -def test_click_taps_once(monkeypatch): - calls = _record(monkeypatch) - c = Clicker("dev") - c.click((500, 700)) - assert len(calls) == 1 - kind, args, kwargs = calls[0] - assert kind == "tap" - assert args == ("dev", (500, 700)) - assert kwargs["duration"] == 100 - - -def test_click_returns_action_result(monkeypatch): - monkeypatch.setattr(clicker_mod, "tap", - lambda *a, **k: {"success": False, "message": "boom"}) - assert Clicker("dev").click((1, 2))["success"] is False - - -def test_device_id_stored(): - assert Clicker("abc123").device_id == "abc123" +from unittest.mock import MagicMock + +import pytest + +from nomix_clicker import Clicker, ControlError + + +def client() -> Clicker: + clicker = Clicker("device") + clicker.control = MagicMock(timeout=10) + clicker.control.request.return_value = {"status": "sent", "device_acknowledged": False} + return clicker + + +def test_context_acquires_without_takeover_and_releases_on_error(): + clicker = client() + with pytest.raises(ValueError), clicker: + raise ValueError("stop") + clicker.control.connect.assert_called_once() + clicker.control.request.assert_called_once_with("control.acquire", {"takeover": False}) + clicker.control.close.assert_called_once() + + +def test_busy_session_closes_without_sending_input(): + clicker = client() + clicker.control.request.side_effect = ControlError(-32002, "busy") + with pytest.raises(ControlError): + clicker.connect() + clicker.control.close.assert_called_once() + assert clicker.control.request.call_count == 1 + + +def test_swipe_uses_one_atomic_movement(): + clicker = client() + clicker.swipe((10000, 10000), up=6000, right=3000) + clicker.control.request.assert_called_once_with("input.move", { + "start_left": 10000, "start_top": 10000, "end_left": 13000, "end_top": 4000, + "pressed": True, "duration": 300, + }, timeout=11.3) + + +def test_click_reports_sent_not_phone_execution(): + clicker = client() + assert clicker.click((500, 700)) == {"status": "sent", "device_acknowledged": False} + clicker.control.request.assert_called_once_with("input.tap", {"left": 500, "top": 700, "duration": 100}, timeout=10.1) + + +def test_errors_propagate_and_stop_the_callers_macro(): + clicker = client() + clicker.control.request.side_effect = ControlError(-32007, "lost", "unknown") + with pytest.raises(ControlError) as error: + clicker.click((1, 2)) + assert error.value.outcome == "unknown" diff --git a/tests/test_control.py b/tests/test_control.py new file mode 100644 index 0000000..273ded6 --- /dev/null +++ b/tests/test_control.py @@ -0,0 +1,164 @@ +import json +from concurrent.futures import ThreadPoolExecutor +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Event, Thread + +import pytest +from websockets.exceptions import ConnectionClosed +from websockets.sync.server import serve + +from nomix_clicker import ControlError, ControlSession + + +class Discovery(BaseHTTPRequestHandler): + def do_GET(self): + self.server.paths.append(self.path) + self.server.keys.append(self.headers.get("X-API-Key")) + payload = json.dumps({"protocol_version": 1, "websocket_url": self.server.control_url}).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): + pass + + +class Server: + def __init__(self): + self.connections = 0 + self.messages = [] + self.keys = [] + self.origins = [] + self.mode = "normal" + self.received = Event() + self.release = Event() + + def handler(self, socket): + self.connections += 1 + self.keys.append(socket.request.headers.get("X-API-Key")) + self.origins.append(socket.request.headers.get("Origin")) + try: + for message in socket: + request = json.loads(message) + self.messages.append(request) + self.received.set() + for _ in range(3): + socket.send(json.dumps({"jsonrpc": "2.0", "method": "control.state", "params": {"online": True}})) + if self.mode == "disconnect": + socket.close() + return + if self.mode == "timeout": + self.release.wait(2) + return + if self.mode == "malformed": + socket.send('{"jsonrpc":"2.0","id":true,"result":{}}') + elif self.mode == "error": + socket.send(json.dumps({"jsonrpc": "2.0", "id": request["id"], "error": { + "code": -32002, "message": "busy", "data": {"outcome": "not_sent"}, + }})) + elif self.mode == "reverse" and request["id"] % 2: + continue + else: + targets = self.messages[-2:][::-1] if self.mode == "reverse" else [request] + for target in targets: + socket.send(json.dumps({"jsonrpc": "2.0", "id": target["id"], "result": { + "status": "sent", "device_acknowledged": False, "method": target["method"], + }})) + except ConnectionClosed: + pass + + +@pytest.fixture +def service(): + fixture = Server() + with serve(fixture.handler, "127.0.0.1", 0) as socket_server: + socket_thread = Thread(target=socket_server.serve_forever, daemon=True) + socket_thread.start() + http = ThreadingHTTPServer(("127.0.0.1", 0), Discovery) + http.control_url = f"ws://127.0.0.1:{socket_server.socket.getsockname()[1]}/control/Device" + http.paths, http.keys = [], [] + http_thread = Thread(target=http.serve_forever, daemon=True) + http_thread.start() + client = ControlSession("Device", api_key="test-key", api_url=f"http://127.0.0.1:{http.server_port}/clicker/v1", timeout=.3) + try: + client.connect() + yield client, fixture, http + finally: + fixture.release.set() + client.close() + http.shutdown() + http.server_close() + http_thread.join(2) + socket_server.shutdown() + socket_thread.join(2) + assert not client.reader.is_alive() + + +def test_discovery_auth_notifications_and_monotonic_ids(service): + client, fixture, http = service + result = client.request("control.acquire") + assert result["device_acknowledged"] is False + client.request("input.tap", {"left": 1, "top": 2}) + assert [message["id"] for message in fixture.messages] == [1, 2] + assert http.paths == ["/clicker/v1/Device/control"] + assert http.keys == fixture.keys == ["test-key"] + assert fixture.origins == [None] + assert client.state == {"online": True} + + +@pytest.mark.parametrize("mode", ["disconnect", "timeout", "malformed"]) +def test_unknown_outcome_closes_without_replay_or_reconnect(service, mode): + client, fixture, _ = service + fixture.mode = mode + with pytest.raises(ControlError) as error: + client.request("input.tap", {"left": 1, "top": 2}) + assert error.value.outcome == "unknown" + with pytest.raises(ControlError): + client.request("input.tap", {"left": 1, "top": 2}) + with pytest.raises(ControlError): + client.connect() + assert len(fixture.messages) == fixture.connections == 1 + + +def test_refused_action_preserves_error_and_session(service): + client, fixture, _ = service + fixture.mode = "error" + with pytest.raises(ControlError) as error: + client.request("control.acquire") + assert error.value.code == -32002 and error.value.outcome == "not_sent" + fixture.mode = "normal" + client.request("control.status") + assert not client.closed + + +def test_concurrent_replies_correlate_by_id(service): + client, fixture, _ = service + fixture.mode = "reverse" + with ThreadPoolExecutor(2) as pool: + first = pool.submit(client.request, "control.status") + assert fixture.received.wait(1) + second = pool.submit(client.request, "control.ping") + assert second.result(1)["method"] == "control.ping" + assert first.result(1)["method"] == "control.status" + assert not client.pending + + +def test_oversized_request_never_reaches_socket(service): + client, fixture, _ = service + with pytest.raises(ControlError) as error: + client.request("input.text", {"text": "x" * 65536}) + assert error.value.outcome == "not_sent" + assert not fixture.messages + + +def test_close_settles_in_flight_request(service): + client, fixture, _ = service + fixture.mode = "timeout" + with ThreadPoolExecutor(1) as pool: + future = pool.submit(client.request, "input.tap", {"left": 1, "top": 2}) + assert fixture.received.wait(1) + client.close() + with pytest.raises(ControlError) as error: + future.result(1) + assert error.value.outcome == "unknown" diff --git a/tools/airplane-mode-iphone-12.py b/tools/airplane-mode-iphone-12.py index 6ba008e..a355669 100644 --- a/tools/airplane-mode-iphone-12.py +++ b/tools/airplane-mode-iphone-12.py @@ -11,14 +11,14 @@ def main(): - clicker = Clicker(DEVICE_ID) - - clicker.swipe((28813, 1000), down=10000) - clicker.click((6523, 6689)) - sleep(10) - clicker.click((6523, 6689)) - sleep(10) - clicker.click((16700, 26000)) + with Clicker(DEVICE_ID) as clicker: + + clicker.swipe((28813, 1000), down=10000) + clicker.click((6523, 6689)) + sleep(10) + clicker.click((6523, 6689)) + sleep(10) + clicker.click((16700, 26000)) if __name__ == '__main__': diff --git a/tools/restart_all_devices.py b/tools/restart_all_devices.py index 4d1642c..04ada9c 100755 --- a/tools/restart_all_devices.py +++ b/tools/restart_all_devices.py @@ -4,14 +4,16 @@ python3 tools/restart_all_devices.py """ -from nomix_clicker.api_helper import get_devices, restart +from nomix_clicker import Clicker +from nomix_clicker.api_helper import get_devices def main(): devices = get_devices() for device_id in devices: - restart(device_id) + with Clicker(device_id) as clicker: + clicker.restart() if __name__ == '__main__': diff --git a/tools/switch-screen-viewing-iphone-12.py b/tools/switch-screen-viewing-iphone-12.py index 23ebe07..b42d745 100644 --- a/tools/switch-screen-viewing-iphone-12.py +++ b/tools/switch-screen-viewing-iphone-12.py @@ -10,37 +10,37 @@ def main(): - clicker = Clicker(DEVICE_ID) + with Clicker(DEVICE_ID) as clicker: - # Close "Screen Broadcasting" dialog, may help immediately. Then stop the script - clicker.click((10500, 18500)) - sleep(5) + # Close "Screen Broadcasting" dialog, may help immediately. Then stop the script + clicker.click((10500, 18500)) + sleep(5) - # Close possible opened dialog - clicker.click((16600, 32500)) + # Close possible opened dialog + clicker.click((16600, 32500)) - # Swipe-up to close current app - clicker.swipe((16600, 32500), up=10000) + # Swipe-up to close current app + clicker.swipe((16600, 32500), up=10000) - # Reset in case Recent apps were opened - clicker.click((31000, 30000)) + # Reset in case Recent apps were opened + clicker.click((31000, 30000)) - # Swipe to the end most right of home screens - clicker.swipe((31000, 27500), left=20000) - clicker.swipe((31000, 27500), left=20000) - clicker.swipe((31000, 27500), left=20000) + # Swipe to the end most right of home screens + clicker.swipe((31000, 27500), left=20000) + clicker.swipe((31000, 27500), left=20000) + clicker.swipe((31000, 27500), left=20000) - # App Library - clicker.click((16600, 3500)) + # App Library + clicker.click((16600, 3500)) - # Open NMX Viewer - clicker.type("NMX Viewer") - clicker.click((4200, 6800)) + # Open NMX Viewer + clicker.type("NMX Viewer") + clicker.click((4200, 6800)) - # Switch screen viewing - clicker.click((16600, 24000)) - clicker.click((16600, 19300)) - clicker.click((16600, 24000)) + # Switch screen viewing + clicker.click((16600, 24000)) + clicker.click((16600, 19300)) + clicker.click((16600, 24000)) if __name__ == '__main__': diff --git a/tools/wake-up-phone.py b/tools/wake-up-phone.py index d5ff1af..87401f1 100644 --- a/tools/wake-up-phone.py +++ b/tools/wake-up-phone.py @@ -65,8 +65,8 @@ def wake_up_the_phone(clicker: Clicker) -> None: def main(): - clicker = Clicker(DEVICE_ID) - wake_up_the_phone(clicker) + with Clicker(DEVICE_ID) as clicker: + wake_up_the_phone(clicker) if __name__ == '__main__': From 6757f1b3a435c1cd51ca09c4419b4e76fd6f2348 Mon Sep 17 00:00:00 2001 From: Timofey Ivanenko Date: Thu, 22 Oct 2026 03:29:03 +0800 Subject: [PATCH 2/5] Close control ownership when a socket send is interrupted --- src/nomix_clicker/control.py | 3 +++ tests/test_control.py | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/nomix_clicker/control.py b/src/nomix_clicker/control.py index de89897..b7bdf48 100644 --- a/src/nomix_clicker/control.py +++ b/src/nomix_clicker/control.py @@ -129,6 +129,9 @@ def request(self, method: str, params: dict | None = None, *, timeout: float | N except (OSError, websocket.WebSocketException): self.close() raise ControlError(-32007, "Control request outcome is unknown", "unknown") from None + except KeyboardInterrupt: + self.close() + raise try: return future.result(timeout=deadline) except FutureTimeout: diff --git a/tests/test_control.py b/tests/test_control.py index 273ded6..c2d7327 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -2,6 +2,7 @@ from concurrent.futures import ThreadPoolExecutor from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Event, Thread +from unittest.mock import patch import pytest from websockets.exceptions import ConnectionClosed @@ -162,3 +163,13 @@ def test_close_settles_in_flight_request(service): with pytest.raises(ControlError) as error: future.result(1) assert error.value.outcome == "unknown" + + +def test_interrupted_send_closes_the_session_and_cannot_be_retried(service): + client, fixture, _ = service + with patch.object(client.socket, "send", side_effect=KeyboardInterrupt), pytest.raises(KeyboardInterrupt): + client.request("input.tap", {"left": 1, "top": 2}) + assert client.closed and not client.pending + with pytest.raises(ControlError): + client.request("input.tap", {"left": 1, "top": 2}) + assert not fixture.messages From 814230a9205df642165cde0d494262afb88adf51 Mon Sep 17 00:00:00 2001 From: Timofey Ivanenko Date: Thu, 22 Oct 2026 09:09:59 +0800 Subject: [PATCH 3/5] Keep simple HTTP commands and make exclusive WebSocket control explicit --- README.md | 6 ++- src/nomix_clicker/api_helper.py | 55 +++++++++++++++++++++++ src/nomix_clicker/clicker.py | 54 ++++++++++++++++------- tests/test_clicker.py | 6 +-- tests/test_control.py | 14 +++--- tests/test_http_control.py | 78 +++++++++++++++++++++++++++++++++ 6 files changed, 184 insertions(+), 29 deletions(-) create mode 100644 tests/test_http_control.py diff --git a/README.md b/README.md index 5061317..8c0b259 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,13 @@ with Clicker(DEVICE_ID) as clicker: ### Control sessions -Version 0.2 uses the unified WebSocket control API and requires the matching server release. The old HTTP command helpers are removed. Use `with Clicker(device_id)` or explicitly call `connect()` and `close()`. Opening a session acquires control without taking it from another user; `connect(takeover=True)` explicitly requests takeover. +`Clicker(device_id).click((x, y))` and the existing `api_helper` command functions use HTTP: no connection setup is required. The server claims the device for each command and returns 409 when another controller owns it. Commands use the same names, parameters and execution service as the live panel. + +For a multi-step workflow, `with Clicker(device_id)` (or explicit `connect()` / `close()`) holds exclusive control over WebSocket until exit. This prevents another client from moving the phone between steps. `connect(takeover=True)` is an explicit takeover; a failed session never falls back to HTTP or replays input. Commands return `{"status": "sent", "device_acknowledged": false}`. This confirms dispatch, not execution on the phone. Failures raise `ControlError` with `code` and `outcome` (`not_sent`, `partial`, or `unknown`). An interrupted session never reconnects or replays input automatically; create a new session only after checking the phone. Closing releases control and pending input. -The examples below assume an open `clicker` context. For direct protocol access use `clicker.control.request(method, params)`. Discover the endpoint with `GET /clicker/v1/{device_id}/control`; method schemas are at `GET /clicker/v1/control-schema`. Screenshots, recognition, media and agent task management remain HTTP. +The multi-step examples below use an exclusive `clicker` context. For direct protocol access use `clicker.control.request(method, params)`. Discover the endpoint with `GET /clicker/v1/{device_id}/control`; method schemas are at `GET /clicker/v1/control-schema`. HTTP command endpoints and the WebSocket contract are both available. SDK 0.2 raises errors instead of returning an unchecked failure dictionary, so a failed command stops the caller’s workflow. Typing is bounded to 180 seconds per command. ### Double tap diff --git a/src/nomix_clicker/api_helper.py b/src/nomix_clicker/api_helper.py index f91e665..7becf9f 100644 --- a/src/nomix_clicker/api_helper.py +++ b/src/nomix_clicker/api_helper.py @@ -1,8 +1,10 @@ import mimetypes from pathlib import Path +from urllib.parse import quote import requests +from .control import ControlError from .environment import get_api_key, get_api_url # API reference: https://panel.nomixclicker.com/docs @@ -19,6 +21,59 @@ def _auth(request): session.auth = _auth +def send_command(device_id: str, method: str, params: dict | None = None, *, timeout: float = 240) -> dict: + try: + response = session.post(f"{get_api_url()}/{quote(device_id, safe='')}/{method}", json=params or {}, timeout=timeout) + except requests.RequestException: + raise ControlError(-32007, "Command response lost; do not automatically retry", "unknown") from None + try: + payload = response.json() + except ValueError: + raise ControlError(-32007, "Invalid command response; do not automatically retry", "unknown") from None + detail = payload.get("detail") if isinstance(payload, dict) else None + if response.status_code >= 400 and isinstance(detail, dict) and "code" in detail: + raise ControlError(detail["code"], detail["message"], detail["outcome"]) + response.raise_for_status() + if not isinstance(payload, dict) or payload.get("success") is not True: + raise ControlError(-32007, "Unrecognized command response", "unknown") + return payload + + +def restart(device_id: str) -> dict: + return send_command(device_id, "restart") + + +def click(device_id: str, duration: int = 300) -> dict: + return send_command(device_id, "click", {"duration": duration}) + + +def tap(device_id: str, coords: tuple[int, int], duration: int = 100) -> dict: + return send_command(device_id, "tap", {"left": coords[0], "top": coords[1], "duration": duration}) + + +def double_tap(device_id: str, coords: tuple[int, int], duration: int = 50, gap: int = 100) -> dict: + return send_command(device_id, "double-tap", {"left": coords[0], "top": coords[1], "duration": duration, "gap": gap}) + + +def move(device_id: str, start: tuple[int, int], end: tuple[int, int], is_pressed: bool = False, duration: int = 300) -> dict: + return send_command(device_id, "move", { + "start_left": start[0], "start_top": start[1], "end_left": end[0], "end_top": end[1], + "is_pressed": is_pressed, "duration": duration, + }) + + +def scroll(device_id: str, direction: str, distance: int = 300, duration: int = 500) -> dict: + return send_command(device_id, "scroll", {"direction": direction, "distance": distance, "duration": duration}) + + +def type_text(device_id: str, text: str) -> dict: + return send_command(device_id, "keyboard/type", {"text": text}) + + +def key_combo(device_id: str, codes: list[str]) -> dict: + return send_command(device_id, "keyboard/combo", {"codes": codes}) + + def get_devices(): """Get list of connected devices. diff --git a/src/nomix_clicker/clicker.py b/src/nomix_clicker/clicker.py index ae6f958..cd68689 100644 --- a/src/nomix_clicker/clicker.py +++ b/src/nomix_clicker/clicker.py @@ -1,18 +1,25 @@ +import math from types import TracebackType -from .api_helper import get_screenshot -from .control import ControlSession +from .api_helper import get_screenshot, send_command +from .control import ControlError, ControlSession class Clicker: - """One explicit control session; commands are never replayed after disconnect.""" + """HTTP commands by default; a context holds exclusive control across a workflow.""" device_id: str - control: ControlSession + control: ControlSession | None + timeout: float + closed: bool def __init__(self, device_id: str, *, timeout: float = 10): + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError("timeout must be positive") self.device_id = device_id - self.control = ControlSession(device_id, timeout=timeout) + self.control = None + self.timeout = timeout + self.closed = False def __enter__(self) -> "Clicker": self.connect() @@ -22,6 +29,10 @@ def __exit__(self, kind: type | None, value: BaseException | None, traceback: Tr self.close() def connect(self, *, takeover: bool = False) -> None: + if self.closed: + raise ControlError(-32004, "Create a new Clicker after closing") + if self.control is None: + self.control = ControlSession(self.device_id, timeout=self.timeout) self.control.connect() try: self.control.request("control.acquire", {"takeover": takeover}) @@ -30,34 +41,43 @@ def connect(self, *, takeover: bool = False) -> None: raise def close(self) -> None: - self.control.close() + self.closed = True + if self.control is not None: + self.control.close() + + def request(self, method: str, params: dict | None = None, *, timeout: float | None = None) -> dict: + if self.closed: + raise ControlError(-32004, "Create a new Clicker after closing") + if self.control is not None: + return self.control.request(method, params, timeout=timeout) + return send_command(self.device_id, method, params, timeout=self.timeout if timeout is None else timeout) def swipe(self, coords: tuple[int, int], up: int = 0, down: int = 0, left: int = 0, right: int = 0, duration: int = 300) -> dict: x, y = coords - return self.control.request("input.move", { + return self.request("move", { "start_left": x, "start_top": y, "end_left": x - left + right, - "end_top": y - up + down, "pressed": True, "duration": duration, - }, timeout=self.control.timeout + duration / 1000 + 1) + "end_top": y - up + down, "is_pressed": True, "duration": duration, + }, timeout=self.timeout + duration / 1000 + 1) def click(self, coords: tuple[int, int], duration: int = 100) -> dict: x, y = coords - return self.control.request("input.tap", {"left": x, "top": y, "duration": duration}, - timeout=self.control.timeout + duration / 1000) + return self.request("tap", {"left": x, "top": y, "duration": duration}, + timeout=self.timeout + duration / 1000) def double_tap(self, coords: tuple[int, int], duration: int = 50, gap: int = 100) -> dict: x, y = coords - return self.control.request("input.doubleTap", {"left": x, "top": y, "duration": duration, "gap": gap}, - timeout=self.control.timeout + (2 * duration + gap) / 1000) + return self.request("double-tap", {"left": x, "top": y, "duration": duration, "gap": gap}, + timeout=self.timeout + (2 * duration + gap) / 1000) - def type(self, text: str, delay: int = 20) -> dict: - return self.control.request("input.text", {"text": text, "delay": delay}, timeout=self.control.timeout + 132) + def type(self, text: str, delay: int = 0) -> dict: + return self.request("keyboard/type", {"text": text, "delay": delay}, timeout=self.timeout + 198) def key_combo(self, codes: list[str]) -> dict: - return self.control.request("input.keyCombo", {"codes": codes}) + return self.request("keyboard/combo", {"codes": codes}) def restart(self) -> dict: - return self.control.request("device.restart") + return self.request("restart") def get_screenshot(self) -> bytes | None: return get_screenshot(self.device_id) diff --git a/tests/test_clicker.py b/tests/test_clicker.py index b8418b5..24950a5 100644 --- a/tests/test_clicker.py +++ b/tests/test_clicker.py @@ -33,16 +33,16 @@ def test_busy_session_closes_without_sending_input(): def test_swipe_uses_one_atomic_movement(): clicker = client() clicker.swipe((10000, 10000), up=6000, right=3000) - clicker.control.request.assert_called_once_with("input.move", { + clicker.control.request.assert_called_once_with("move", { "start_left": 10000, "start_top": 10000, "end_left": 13000, "end_top": 4000, - "pressed": True, "duration": 300, + "is_pressed": True, "duration": 300, }, timeout=11.3) def test_click_reports_sent_not_phone_execution(): clicker = client() assert clicker.click((500, 700)) == {"status": "sent", "device_acknowledged": False} - clicker.control.request.assert_called_once_with("input.tap", {"left": 500, "top": 700, "duration": 100}, timeout=10.1) + clicker.control.request.assert_called_once_with("tap", {"left": 500, "top": 700, "duration": 100}, timeout=10.1) def test_errors_propagate_and_stop_the_callers_macro(): diff --git a/tests/test_control.py b/tests/test_control.py index c2d7327..912540e 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -100,7 +100,7 @@ def test_discovery_auth_notifications_and_monotonic_ids(service): client, fixture, http = service result = client.request("control.acquire") assert result["device_acknowledged"] is False - client.request("input.tap", {"left": 1, "top": 2}) + client.request("tap", {"left": 1, "top": 2}) assert [message["id"] for message in fixture.messages] == [1, 2] assert http.paths == ["/clicker/v1/Device/control"] assert http.keys == fixture.keys == ["test-key"] @@ -113,10 +113,10 @@ def test_unknown_outcome_closes_without_replay_or_reconnect(service, mode): client, fixture, _ = service fixture.mode = mode with pytest.raises(ControlError) as error: - client.request("input.tap", {"left": 1, "top": 2}) + client.request("tap", {"left": 1, "top": 2}) assert error.value.outcome == "unknown" with pytest.raises(ControlError): - client.request("input.tap", {"left": 1, "top": 2}) + client.request("tap", {"left": 1, "top": 2}) with pytest.raises(ControlError): client.connect() assert len(fixture.messages) == fixture.connections == 1 @@ -148,7 +148,7 @@ def test_concurrent_replies_correlate_by_id(service): def test_oversized_request_never_reaches_socket(service): client, fixture, _ = service with pytest.raises(ControlError) as error: - client.request("input.text", {"text": "x" * 65536}) + client.request("keyboard/type", {"text": "x" * 65536}) assert error.value.outcome == "not_sent" assert not fixture.messages @@ -157,7 +157,7 @@ def test_close_settles_in_flight_request(service): client, fixture, _ = service fixture.mode = "timeout" with ThreadPoolExecutor(1) as pool: - future = pool.submit(client.request, "input.tap", {"left": 1, "top": 2}) + future = pool.submit(client.request, "tap", {"left": 1, "top": 2}) assert fixture.received.wait(1) client.close() with pytest.raises(ControlError) as error: @@ -168,8 +168,8 @@ def test_close_settles_in_flight_request(service): def test_interrupted_send_closes_the_session_and_cannot_be_retried(service): client, fixture, _ = service with patch.object(client.socket, "send", side_effect=KeyboardInterrupt), pytest.raises(KeyboardInterrupt): - client.request("input.tap", {"left": 1, "top": 2}) + client.request("tap", {"left": 1, "top": 2}) assert client.closed and not client.pending with pytest.raises(ControlError): - client.request("input.tap", {"left": 1, "top": 2}) + client.request("tap", {"left": 1, "top": 2}) assert not fixture.messages diff --git a/tests/test_http_control.py b/tests/test_http_control.py new file mode 100644 index 0000000..220feca --- /dev/null +++ b/tests/test_http_control.py @@ -0,0 +1,78 @@ +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from nomix_clicker import Clicker, ControlError +from nomix_clicker import api_helper + + +@pytest.fixture +def http(): + response = MagicMock(status_code=200) + response.json.return_value = {'success': True, 'status': 'sent', 'device_acknowledged': False} + with patch.object(api_helper.session, 'post', return_value=response) as post, patch.object(api_helper, 'get_api_url', return_value='https://node.invalid/clicker/v1'): + yield post, response + + +def test_default_clicker_uses_http_without_connection_setup(http): + post, _ = http + with patch('nomix_clicker.clicker.ControlSession') as connect: + result = Clicker('Device').click((100, 200)) + connect.assert_not_called() + assert result['success'] and not result['device_acknowledged'] + post.assert_called_once_with('https://node.invalid/clicker/v1/Device/tap', json={'left': 100, 'top': 200, 'duration': 100}, timeout=10.1) + + +def test_classic_helpers_keep_their_names_defaults_and_http_parameters(http): + post, _ = http + api_helper.move('Device', (1, 2), (3, 4)) + assert post.call_args.kwargs['json'] == {'start_left': 1, 'start_top': 2, 'end_left': 3, 'end_top': 4, 'is_pressed': False, 'duration': 300} + api_helper.double_tap('Device', (1, 2)) + assert post.call_args.args[0].endswith('/double-tap') + assert post.call_args.kwargs['json']['duration'] == 50 + api_helper.type_text('Device', 'Hello') + assert post.call_args.args[0].endswith('/keyboard/type') + assert post.call_args.kwargs['timeout'] >= 200 + + +def test_http_busy_stops_macro_without_retry(http): + post, response = http + response.status_code = 409 + response.json.return_value = {'detail': {'code': -32002, 'message': 'busy', 'outcome': 'not_sent'}} + with pytest.raises(ControlError) as error: + Clicker('Device').click((1, 2)) + assert error.value.outcome == 'not_sent' + assert post.call_count == 1 + + +def test_http_lost_response_is_unknown_and_never_retried(http): + post, _ = http + post.side_effect = requests.ReadTimeout('lost') + with pytest.raises(ControlError) as error: + api_helper.tap('Device', (1, 2)) + assert error.value.outcome == 'unknown' + assert post.call_count == 1 + + +def test_failed_exclusive_session_never_falls_back_to_http(http): + post, _ = http + clicker = Clicker('Device') + clicker.control = MagicMock() + clicker.control.request.side_effect = ControlError(-32007, 'connection lost', 'unknown') + with pytest.raises(ControlError): + clicker.click((1, 2)) + post.assert_not_called() + clicker.close() + with pytest.raises(ControlError): + clicker.click((1, 2)) + post.assert_not_called() + + +@pytest.mark.parametrize('payload', [None, [], {'success': False}, {'status': 'sent'}]) +def test_unrecognized_http_result_never_claims_success(http, payload): + post, response = http + response.json.return_value = payload + with pytest.raises(ControlError) as error: + Clicker('Device').click((1, 2)) + assert error.value.outcome == 'unknown' and post.call_count == 1 From 032f652ad39a4abb90dd86750765a6feee3d5467 Mon Sep 17 00:00:00 2001 From: Timofey Ivanenko Date: Fri, 23 Oct 2026 10:03:29 +0800 Subject: [PATCH 4/5] fix(control): use server timing for acknowledged text delivery --- src/nomix_clicker/api_helper.py | 12 ++++++-- src/nomix_clicker/clicker.py | 6 ++-- src/nomix_clicker/control.py | 18 ++++++++++-- tests/test_clicker.py | 11 +++++++- tests/test_control.py | 48 +++++++++++++++++++++++++++++-- tests/test_http_control.py | 50 ++++++++++++++++++++++++++++++++- 6 files changed, 135 insertions(+), 10 deletions(-) diff --git a/src/nomix_clicker/api_helper.py b/src/nomix_clicker/api_helper.py index 7becf9f..465e21d 100644 --- a/src/nomix_clicker/api_helper.py +++ b/src/nomix_clicker/api_helper.py @@ -4,7 +4,7 @@ import requests -from .control import ControlError +from .control import ControlError, text_timeout_seconds from .environment import get_api_key, get_api_url # API reference: https://panel.nomixclicker.com/docs @@ -21,7 +21,15 @@ def _auth(request): session.auth = _auth -def send_command(device_id: str, method: str, params: dict | None = None, *, timeout: float = 240) -> dict: +def get_text_timeout(device_id: str, timeout: float) -> float: + response = session.get(f"{get_api_url()}/{quote(device_id, safe='')}/control", timeout=timeout) + response.raise_for_status() + return timeout + text_timeout_seconds(response.json()) + + +def send_command(device_id: str, method: str, params: dict | None = None, *, timeout: float | None = None) -> dict: + if timeout is None: + timeout = get_text_timeout(device_id, 30) if method == "keyboard/type" else 240 try: response = session.post(f"{get_api_url()}/{quote(device_id, safe='')}/{method}", json=params or {}, timeout=timeout) except requests.RequestException: diff --git a/src/nomix_clicker/clicker.py b/src/nomix_clicker/clicker.py index cd68689..23587a5 100644 --- a/src/nomix_clicker/clicker.py +++ b/src/nomix_clicker/clicker.py @@ -1,7 +1,7 @@ import math from types import TracebackType -from .api_helper import get_screenshot, send_command +from .api_helper import get_screenshot, get_text_timeout, send_command from .control import ControlError, ControlSession @@ -50,6 +50,8 @@ def request(self, method: str, params: dict | None = None, *, timeout: float | N raise ControlError(-32004, "Create a new Clicker after closing") if self.control is not None: return self.control.request(method, params, timeout=timeout) + if method == "keyboard/type" and timeout is None: + timeout = get_text_timeout(self.device_id, self.timeout) return send_command(self.device_id, method, params, timeout=self.timeout if timeout is None else timeout) def swipe(self, coords: tuple[int, int], up: int = 0, down: int = 0, @@ -71,7 +73,7 @@ def double_tap(self, coords: tuple[int, int], duration: int = 50, gap: int = 100 timeout=self.timeout + (2 * duration + gap) / 1000) def type(self, text: str, delay: int = 0) -> dict: - return self.request("keyboard/type", {"text": text, "delay": delay}, timeout=self.timeout + 198) + return self.request("keyboard/type", {"text": text, "delay": delay}) def key_combo(self, codes: list[str]) -> dict: return self.request("keyboard/combo", {"codes": codes}) diff --git a/src/nomix_clicker/control.py b/src/nomix_clicker/control.py index b7bdf48..d8cbf4c 100644 --- a/src/nomix_clicker/control.py +++ b/src/nomix_clicker/control.py @@ -25,6 +25,13 @@ def __init__(self, code: int, message: str, outcome: str = "not_sent"): self.outcome = outcome +def text_timeout_seconds(state: dict) -> float: + budget = state.get("text_timeout_ms") if isinstance(state, dict) else None + if isinstance(budget, bool) or not isinstance(budget, (int, float)) or not math.isfinite(budget) or budget <= 0: + raise ControlError(-32600, "Control timing limits have not been received") + return budget / 1000 + + class ControlSession: device_id: str api_key: str @@ -105,6 +112,8 @@ def close(self) -> None: def request(self, method: str, params: dict | None = None, *, timeout: float | None = None) -> dict: deadline = self.timeout if timeout is None else timeout + if method == "keyboard/type" and timeout is None: + deadline += text_timeout_seconds(self.state) if not math.isfinite(deadline) or deadline <= 0: raise ValueError("timeout must be positive") with self.send_lock: @@ -133,7 +142,11 @@ def request(self, method: str, params: dict | None = None, *, timeout: float | N self.close() raise try: - return future.result(timeout=deadline) + result = future.result(timeout=deadline) + if method == "control.acquire" and "text_timeout_ms" in result: + with self.lock: + self.state["text_timeout_ms"] = result["text_timeout_ms"] + return result except FutureTimeout: self.close() raise ControlError(-32007, "Control request timed out; outcome is unknown", "unknown") from None @@ -164,7 +177,8 @@ def receive(self, payload: Any) -> None: raise ControlError(-32600, "Invalid control response") if "method" in payload and "id" not in payload: if payload["method"] == "control.state" and isinstance(payload.get("params"), dict): - self.state = payload["params"] + with self.lock: + self.state = payload["params"] return request_id = payload.get("id") if type(request_id) is not int or ("result" in payload) == ("error" in payload): diff --git a/tests/test_clicker.py b/tests/test_clicker.py index 24950a5..8a88c87 100644 --- a/tests/test_clicker.py +++ b/tests/test_clicker.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -51,3 +51,12 @@ def test_errors_propagate_and_stop_the_callers_macro(): with pytest.raises(ControlError) as error: clicker.click((1, 2)) assert error.value.outcome == "unknown" + + + +def test_exclusive_typing_uses_the_owned_servers_budget_without_http(): + clicker = client() + with patch('nomix_clicker.clicker.get_text_timeout') as discovery: + clicker.type('a' * 10000) + discovery.assert_not_called() + clicker.control.request.assert_called_once_with('keyboard/type', {'text': 'a' * 10000, 'delay': 0}, timeout=None) diff --git a/tests/test_control.py b/tests/test_control.py index 912540e..8ca3485 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -1,5 +1,5 @@ import json -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import Future, ThreadPoolExecutor from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Event, Thread from unittest.mock import patch @@ -9,6 +9,7 @@ from websockets.sync.server import serve from nomix_clicker import ControlError, ControlSession +from nomix_clicker.control import text_timeout_seconds class Discovery(BaseHTTPRequestHandler): @@ -44,7 +45,7 @@ def handler(self, socket): request = json.loads(message) self.messages.append(request) self.received.set() - for _ in range(3): + for _ in range(0 if self.mode == "timing" else 3): socket.send(json.dumps({"jsonrpc": "2.0", "method": "control.state", "params": {"online": True}})) if self.mode == "disconnect": socket.close() @@ -65,6 +66,7 @@ def handler(self, socket): for target in targets: socket.send(json.dumps({"jsonrpc": "2.0", "id": target["id"], "result": { "status": "sent", "device_acknowledged": False, "method": target["method"], + **({"text_timeout_ms": 4682000} if self.mode == "timing" else {}), }})) except ConnectionClosed: pass @@ -173,3 +175,45 @@ def test_interrupted_send_closes_the_session_and_cannot_be_retried(service): with pytest.raises(ControlError): client.request("tap", {"left": 1, "top": 2}) assert not fixture.messages + + + +def test_text_timing_from_a_control_state_is_used_without_local_estimates(): + assert text_timeout_seconds({'text_timeout_ms': 4682000}) == 4682 + + + +def test_acquire_response_supplies_timing_without_overwriting_newer_state(service): + client, fixture, _ = service + fixture.mode = 'timing' + client.state = {'online': False} + result = client.request('control.acquire') + assert result['text_timeout_ms'] == 4682000 + assert client.state == {'online': False, 'text_timeout_ms': 4682000} + + + +def test_raw_text_request_uses_the_acquired_servers_budget(service): + client, fixture, _ = service + fixture.mode = 'timing' + client.request('control.acquire') + future = Future() + with patch('nomix_clicker.control.Future', return_value=future), patch.object(future, 'result', wraps=future.result) as wait: + client.request('keyboard/type', {'text': 'a' * 10000}) + wait.assert_called_once_with(timeout=4682.3) + assert fixture.messages[-1]['params']['text'] == 'a' * 10000 + + +def test_raw_text_explicit_timeout_is_preserved(service): + client, _, _ = service + future = Future() + with patch('nomix_clicker.control.Future', return_value=future), patch.object(future, 'result', wraps=future.result) as wait: + client.request('keyboard/type', {'text': 'hello'}, timeout=.2) + wait.assert_called_once_with(timeout=.2) + + +def test_raw_text_without_timing_fails_before_sending(service): + client, fixture, _ = service + with pytest.raises(ControlError) as refused: + client.request('keyboard/type', {'text': 'hello'}) + assert refused.value.outcome == 'not_sent' and not fixture.messages diff --git a/tests/test_http_control.py b/tests/test_http_control.py index 220feca..1a40858 100644 --- a/tests/test_http_control.py +++ b/tests/test_http_control.py @@ -11,7 +11,9 @@ def http(): response = MagicMock(status_code=200) response.json.return_value = {'success': True, 'status': 'sent', 'device_acknowledged': False} - with patch.object(api_helper.session, 'post', return_value=response) as post, patch.object(api_helper, 'get_api_url', return_value='https://node.invalid/clicker/v1'): + discovery = MagicMock(status_code=200) + discovery.json.return_value = {'text_timeout_ms': 4682000} + with patch.object(api_helper.session, 'get', return_value=discovery), patch.object(api_helper.session, 'post', return_value=response) as post, patch.object(api_helper, 'get_api_url', return_value='https://node.invalid/clicker/v1'): yield post, response @@ -76,3 +78,49 @@ def test_unrecognized_http_result_never_claims_success(http, payload): with pytest.raises(ControlError) as error: Clicker('Device').click((1, 2)) assert error.value.outcome == 'unknown' and post.call_count == 1 + + +def test_http_typing_uses_server_timing_before_sending_the_text(http): + post, _ = http + Clicker('Device').type('a' * 10000) + assert post.call_count == 1 + assert post.call_args.kwargs['timeout'] == 4692 + assert post.call_args.kwargs['timeout'] > 150 + 625 * .15 + api_helper.type_text('Device', 'a' * 10000) + assert post.call_args.kwargs['timeout'] == 4712 + + +@pytest.mark.parametrize('budget', [None, True, '4682000', 0, -1, float('nan'), float('inf')]) +def test_invalid_text_timing_never_sends_input(http, budget): + post, _ = http + response = MagicMock(status_code=200) + response.json.return_value = {'text_timeout_ms': budget} + with patch.object(api_helper.session, 'get', return_value=response), pytest.raises(ControlError) as refused: + Clicker('Device').type('hello') + assert refused.value.outcome == 'not_sent' + post.assert_not_called() + + +def test_explicit_text_timeout_is_preserved_without_discovery(http): + post, _ = http + with patch.object(api_helper.session, 'get') as discovery: + Clicker('Device').request('keyboard/type', {'text': 'hello'}, timeout=5) + discovery.assert_not_called() + assert post.call_args.kwargs['timeout'] == 5 + + +def test_closed_clicker_never_discovers_or_sends_typing(http): + post, _ = http + clicker = Clicker('Device') + clicker.close() + with patch.object(api_helper.session, 'get') as discovery, pytest.raises(ControlError): + clicker.type('hello') + discovery.assert_not_called() + post.assert_not_called() + + + +def test_raw_typing_without_an_explicit_deadline_also_uses_server_timing(http): + post, _ = http + api_helper.send_command('Device', 'keyboard/type', {'text': 'hello'}) + assert post.call_args.kwargs['timeout'] == 4712 From 9c459e7c61c982f3a47b61edb43bc7cf0b570871 Mon Sep 17 00:00:00 2001 From: Timofey Ivanenko Date: Fri, 23 Oct 2026 10:52:25 +0800 Subject: [PATCH 5/5] Clarify best-effort device input and current server timing --- README.md | 2 +- tests/test_control.py | 10 +++++----- tests/test_http_control.py | 11 +++++------ 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8c0b259..c79ee1a 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ with Clicker(DEVICE_ID) as clicker: For a multi-step workflow, `with Clicker(device_id)` (or explicit `connect()` / `close()`) holds exclusive control over WebSocket until exit. This prevents another client from moving the phone between steps. `connect(takeover=True)` is an explicit takeover; a failed session never falls back to HTTP or replays input. -Commands return `{"status": "sent", "device_acknowledged": false}`. This confirms dispatch, not execution on the phone. Failures raise `ControlError` with `code` and `outcome` (`not_sent`, `partial`, or `unknown`). An interrupted session never reconnects or replays input automatically; create a new session only after checking the phone. Closing releases control and pending input. +Commands return `{"status": "sent", "device_acknowledged": false}`. This confirms dispatch, not execution on the phone. Failures raise `ControlError` with `code` and `outcome` (`not_sent`, `partial`, or `unknown`). An interrupted session never reconnects or replays input automatically; create a new session only after checking the phone. Closing releases control and cancels unsent input. Device input is best effort without execution acknowledgments; input already transmitted may continue or overlap later actions on the phone. The multi-step examples below use an exclusive `clicker` context. For direct protocol access use `clicker.control.request(method, params)`. Discover the endpoint with `GET /clicker/v1/{device_id}/control`; method schemas are at `GET /clicker/v1/control-schema`. HTTP command endpoints and the WebSocket contract are both available. SDK 0.2 raises errors instead of returning an unchecked failure dictionary, so a failed command stops the caller’s workflow. Typing is bounded to 180 seconds per command. diff --git a/tests/test_control.py b/tests/test_control.py index 8ca3485..b8692f7 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -66,7 +66,7 @@ def handler(self, socket): for target in targets: socket.send(json.dumps({"jsonrpc": "2.0", "id": target["id"], "result": { "status": "sent", "device_acknowledged": False, "method": target["method"], - **({"text_timeout_ms": 4682000} if self.mode == "timing" else {}), + **({"text_timeout_ms": 240000} if self.mode == "timing" else {}), }})) except ConnectionClosed: pass @@ -179,7 +179,7 @@ def test_interrupted_send_closes_the_session_and_cannot_be_retried(service): def test_text_timing_from_a_control_state_is_used_without_local_estimates(): - assert text_timeout_seconds({'text_timeout_ms': 4682000}) == 4682 + assert text_timeout_seconds({'text_timeout_ms': 240000}) == 240 @@ -188,8 +188,8 @@ def test_acquire_response_supplies_timing_without_overwriting_newer_state(servic fixture.mode = 'timing' client.state = {'online': False} result = client.request('control.acquire') - assert result['text_timeout_ms'] == 4682000 - assert client.state == {'online': False, 'text_timeout_ms': 4682000} + assert result['text_timeout_ms'] == 240000 + assert client.state == {'online': False, 'text_timeout_ms': 240000} @@ -200,7 +200,7 @@ def test_raw_text_request_uses_the_acquired_servers_budget(service): future = Future() with patch('nomix_clicker.control.Future', return_value=future), patch.object(future, 'result', wraps=future.result) as wait: client.request('keyboard/type', {'text': 'a' * 10000}) - wait.assert_called_once_with(timeout=4682.3) + wait.assert_called_once_with(timeout=240.3) assert fixture.messages[-1]['params']['text'] == 'a' * 10000 diff --git a/tests/test_http_control.py b/tests/test_http_control.py index 1a40858..017d003 100644 --- a/tests/test_http_control.py +++ b/tests/test_http_control.py @@ -12,7 +12,7 @@ def http(): response = MagicMock(status_code=200) response.json.return_value = {'success': True, 'status': 'sent', 'device_acknowledged': False} discovery = MagicMock(status_code=200) - discovery.json.return_value = {'text_timeout_ms': 4682000} + discovery.json.return_value = {'text_timeout_ms': 240000} with patch.object(api_helper.session, 'get', return_value=discovery), patch.object(api_helper.session, 'post', return_value=response) as post, patch.object(api_helper, 'get_api_url', return_value='https://node.invalid/clicker/v1'): yield post, response @@ -84,13 +84,12 @@ def test_http_typing_uses_server_timing_before_sending_the_text(http): post, _ = http Clicker('Device').type('a' * 10000) assert post.call_count == 1 - assert post.call_args.kwargs['timeout'] == 4692 - assert post.call_args.kwargs['timeout'] > 150 + 625 * .15 + assert post.call_args.kwargs['timeout'] == 250 api_helper.type_text('Device', 'a' * 10000) - assert post.call_args.kwargs['timeout'] == 4712 + assert post.call_args.kwargs['timeout'] == 270 -@pytest.mark.parametrize('budget', [None, True, '4682000', 0, -1, float('nan'), float('inf')]) +@pytest.mark.parametrize('budget', [None, True, '240000', 0, -1, float('nan'), float('inf')]) def test_invalid_text_timing_never_sends_input(http, budget): post, _ = http response = MagicMock(status_code=200) @@ -123,4 +122,4 @@ def test_closed_clicker_never_discovers_or_sends_typing(http): def test_raw_typing_without_an_explicit_deadline_also_uses_server_timing(http): post, _ = http api_helper.send_command('Device', 'keyboard/type', {'text': 'hello'}) - assert post.call_args.kwargs['timeout'] == 4712 + assert post.call_args.kwargs['timeout'] == 270